[feature/frontend] create posts (wip)
This commit is contained in:
parent
e86d8c1576
commit
086c9761a9
10 changed files with 598 additions and 115 deletions
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"categories": {
|
"categories": {
|
||||||
"man": "Man",
|
"man": "Human",
|
||||||
"machine": "Machine",
|
"machine": "Machine",
|
||||||
"earth": "Earth",
|
"earth": "Earth",
|
||||||
"space": "Space",
|
"space": "Space",
|
||||||
|
@ -29,8 +29,6 @@
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"save": "Save",
|
|
||||||
"saving": "Saving...",
|
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"published": "Published",
|
"published": "Published",
|
||||||
|
@ -48,12 +46,10 @@
|
||||||
"joinDate": "Join Date",
|
"joinDate": "Join Date",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"language": "Language",
|
"save": "Save",
|
||||||
"theme": {
|
"saving": "Saving...",
|
||||||
"light": "Light Mode",
|
"noData": "No data available",
|
||||||
"dark": "Dark Mode",
|
"unsavedChanges": "You have unsaved changes. Are you sure you want to leave?"
|
||||||
"system": "System"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"totalPosts": "Total Posts",
|
"totalPosts": "Total Posts",
|
||||||
|
@ -61,13 +57,33 @@
|
||||||
"totalUsers": "Total Users",
|
"totalUsers": "Total Users",
|
||||||
"totalContributors": "Total Contributors"
|
"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": {
|
"login": {
|
||||||
"title": "Admin Login",
|
"title": "Admin Login",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"remember": "Remember me",
|
"remember": "Remember me",
|
||||||
"submit": "Sign in",
|
"submit": "Login",
|
||||||
"loading": "Signing in...",
|
"loading": "Logging in...",
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Login failed",
|
"failed": "Login failed",
|
||||||
"retry": "Login failed, please try again later"
|
"retry": "Login failed, please try again later"
|
||||||
|
@ -76,7 +92,7 @@
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"posts": "Posts",
|
"posts": "Posts",
|
||||||
"daily": "Daily Quotes",
|
"daily": "Daily",
|
||||||
"medias": "Media",
|
"medias": "Media",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
|
|
|
@ -47,7 +47,9 @@
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中..."
|
"saving": "保存中...",
|
||||||
|
"noData": "暂无数据",
|
||||||
|
"unsavedChanges": "你有未保存的更改,确定要离开吗?"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"totalPosts": "文章总数",
|
"totalPosts": "文章总数",
|
||||||
|
@ -55,6 +57,26 @@
|
||||||
"totalUsers": "用户总数",
|
"totalUsers": "用户总数",
|
||||||
"totalContributors": "贡献者总数"
|
"totalContributors": "贡献者总数"
|
||||||
},
|
},
|
||||||
|
"posts": {
|
||||||
|
"title": "标题",
|
||||||
|
"categories": "分类",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"status": "状态",
|
||||||
|
"noTitle": "无标题",
|
||||||
|
"deleteConfirm": "确定要删除这篇文章吗?",
|
||||||
|
"create": "创建文章",
|
||||||
|
"edit": "编辑文章",
|
||||||
|
"slug": "文章链接",
|
||||||
|
"content": "内容",
|
||||||
|
"summary": "摘要",
|
||||||
|
"metaKeywords": "关键词",
|
||||||
|
"metaDescription": "描述",
|
||||||
|
"selectCategories": "选择分类",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"publishing": "发布中...",
|
||||||
|
"saveDraft": "保存草稿",
|
||||||
|
"publish": "发布文章"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "管理员登录",
|
"title": "管理员登录",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
"lastLogin": "最後登入",
|
"lastLogin": "最後登入",
|
||||||
"joinDate": "加入時間",
|
"joinDate": "加入時間",
|
||||||
"username": "用戶名",
|
"username": "用戶名",
|
||||||
"logout": "退出登錄",
|
"logout": "退出登入",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": "淺色模式",
|
"light": "淺色模式",
|
||||||
|
@ -53,7 +53,9 @@
|
||||||
"system": "跟隨系統"
|
"system": "跟隨系統"
|
||||||
},
|
},
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中..."
|
"saving": "保存中...",
|
||||||
|
"noData": "暫無數據",
|
||||||
|
"unsavedChanges": "你有未保存的更改,確定要離開嗎?"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "儀表板",
|
"dashboard": "儀表板",
|
||||||
|
@ -83,15 +85,15 @@
|
||||||
"uploadDate": "上傳日期"
|
"uploadDate": "上傳日期"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "管理員登錄",
|
"title": "管理員登入",
|
||||||
"username": "用戶名",
|
"username": "用戶名",
|
||||||
"password": "密碼",
|
"password": "密碼",
|
||||||
"remember": "記住我",
|
"remember": "記住我",
|
||||||
"submit": "登錄",
|
"submit": "登入",
|
||||||
"loading": "登錄中...",
|
"loading": "登入中...",
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "登錄失敗",
|
"failed": "登入失敗",
|
||||||
"retry": "登錄失敗,請稍後重試"
|
"retry": "登入失敗,請稍後重試"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
|
|
|
@ -12,7 +12,10 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@tss-rocks/api": "workspace:*",
|
"@tss-rocks/api": "workspace:*",
|
||||||
|
"@types/classnames": "^2.3.4",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
|
|
26
frontend/src/contexts/PageTitleContext.tsx
Normal file
26
frontend/src/contexts/PageTitleContext.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { createContext, useContext, FC, ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
interface PageTitleContextType {
|
||||||
|
title: string;
|
||||||
|
setTitle: (title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageTitleContext = createContext<PageTitleContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const PageTitleProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTitleContext.Provider value={{ title, setTitle }}>
|
||||||
|
{children}
|
||||||
|
</PageTitleContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePageTitle = () => {
|
||||||
|
const context = useContext(PageTitleContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('usePageTitle must be used within a PageTitleProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
|
@ -19,6 +19,7 @@ import { useTheme } from '../../../hooks/useTheme';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import LoadingSpinner from '../../../components/LoadingSpinner';
|
import LoadingSpinner from '../../../components/LoadingSpinner';
|
||||||
import { useUser } from '../../../contexts/UserContext';
|
import { useUser } from '../../../contexts/UserContext';
|
||||||
|
import { PageTitleProvider, usePageTitle } from '../../../contexts/PageTitleContext';
|
||||||
|
|
||||||
interface AdminLayoutProps {}
|
interface AdminLayoutProps {}
|
||||||
|
|
||||||
|
@ -56,12 +57,13 @@ const languageMap: LanguageMap = {
|
||||||
'zh-Hant': 'en'
|
'zh-Hant': 'en'
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdminLayout: FC<AdminLayoutProps> = () => {
|
const AdminLayoutContent: FC = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { user, loading, error, fetchUser } = useUser();
|
const { user, loading, error, fetchUser } = useUser();
|
||||||
|
const { title } = usePageTitle();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 如果没有 token,重定向到登录页
|
// 如果没有 token,重定向到登录页
|
||||||
|
@ -196,7 +198,7 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||||
<div className="h-16 px-8 flex items-center justify-between">
|
<div className="h-16 px-8 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">
|
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">
|
||||||
{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')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
@ -224,4 +226,12 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||||
|
return (
|
||||||
|
<PageTitleProvider>
|
||||||
|
<AdminLayoutContent />
|
||||||
|
</PageTitleProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default AdminLayout;
|
export default AdminLayout;
|
||||||
|
|
296
frontend/src/pages/admin/posts/PostEditor.tsx
Normal file
296
frontend/src/pages/admin/posts/PostEditor.tsx
Normal file
|
@ -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<Post>({
|
||||||
|
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<PostContent>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
setIsDirty(true);
|
||||||
|
setPost(prev => ({ ...prev, slug: e.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Slug */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||||
|
{t('admin.posts.slug')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={post.slug}
|
||||||
|
onChange={handleSlugChange}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Language Tabs */}
|
||||||
|
<div className="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<nav className="-mb-px flex space-x-8" aria-label="Language">
|
||||||
|
{LANGUAGES.map(({ code, label }) => (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
onClick={() => setActiveTab(code)}
|
||||||
|
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === code
|
||||||
|
? 'border-slate-900 dark:border-white text-slate-900 dark:text-white'
|
||||||
|
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||||
|
{t('admin.posts.title')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeContent.title}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||||
|
{t('admin.posts.content')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={activeContent.content_markdown}
|
||||||
|
onChange={e => updateContent({ content_markdown: e.target.value })}
|
||||||
|
rows={10}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||||
|
{t('admin.posts.summary')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={activeContent.summary || ''}
|
||||||
|
onChange={e => updateContent({ summary: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta Keywords */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||||
|
{t('admin.posts.metaKeywords')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeContent.meta_keywords || ''}
|
||||||
|
onChange={e => updateContent({ meta_keywords: 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||||
|
{t('admin.posts.metaDescription')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={activeContent.meta_description || ''}
|
||||||
|
onChange={e => updateContent({ meta_description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
{/* TODO: Add category selection */}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end space-x-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSubmit(false)}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-md hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? t('admin.posts.saving') : t('admin.posts.saveDraft')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSubmit(true)}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-slate-900 dark:bg-slate-700 rounded-md hover:bg-slate-800 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? t('admin.posts.publishing') : t('admin.posts.publish')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostEditor;
|
|
@ -1,73 +1,183 @@
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, FC, ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Table from '../../../components/admin/Table';
|
import Table from '../../../components/admin/Table';
|
||||||
import TableActions from '../../../components/admin/TableActions';
|
import TableActions from '../../../components/admin/TableActions';
|
||||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
import { RiSearchLine } from 'react-icons/ri';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { usePageTitle } from '../../../contexts/PageTitleContext';
|
||||||
|
|
||||||
|
// 初始化 dayjs 插件
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
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 {
|
interface Post {
|
||||||
id: string;
|
id: number;
|
||||||
title: string;
|
slug: string;
|
||||||
category: string;
|
|
||||||
publishDate: string;
|
|
||||||
status: 'draft' | 'published';
|
status: 'draft' | 'published';
|
||||||
|
contents: PostContent[];
|
||||||
|
categories: Category[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TablePost = {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
displayTitle: string;
|
||||||
|
displayCategories: string;
|
||||||
|
};
|
||||||
|
|
||||||
const PostsManagement: FC = () => {
|
const PostsManagement: FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [posts, setPosts] = useState<TablePost[]>([]);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setTitle } = usePageTitle();
|
||||||
|
|
||||||
// 这里后续会通过 API 获取数据
|
useEffect(() => {
|
||||||
const posts: Post[] = [
|
setTitle(t('admin.nav.posts'));
|
||||||
{
|
return () => setTitle('');
|
||||||
id: '1',
|
}, [setTitle, t]);
|
||||||
title: '示例文章标题',
|
|
||||||
category: '示例分类',
|
|
||||||
publishDate: '2024-02-20',
|
|
||||||
status: 'published',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleEdit = (post: Post) => {
|
useEffect(() => {
|
||||||
console.log('Edit post:', post);
|
fetchPosts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPosts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/v1/posts?sort=-created_at');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch posts');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 处理数据,添加显示字段
|
||||||
|
const processedPosts = data.data.map((post: Post): TablePost => ({
|
||||||
|
id: post.id,
|
||||||
|
slug: post.slug,
|
||||||
|
status: post.status,
|
||||||
|
created_at: post.created_at,
|
||||||
|
updated_at: post.updated_at,
|
||||||
|
displayTitle: getPostTitle(post),
|
||||||
|
displayCategories: getCategoryNames(post)
|
||||||
|
}));
|
||||||
|
|
||||||
|
setPosts(processedPosts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching posts:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (post: Post) => {
|
const getPostTitle = (post: Post) => {
|
||||||
console.log('Delete post:', post);
|
// Try to get English title first
|
||||||
|
const englishContent = post.contents.find(c => c.language_code === 'en');
|
||||||
|
if (englishContent?.title) return englishContent.title;
|
||||||
|
|
||||||
|
// Fallback to the first available title
|
||||||
|
const firstContent = post.contents[0];
|
||||||
|
return firstContent?.title || t('admin.posts.noTitle');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryNames = (post: Post) => {
|
||||||
|
return post.categories
|
||||||
|
.map(category => {
|
||||||
|
const englishContent = category.contents.find(c => c.language_code === 'en');
|
||||||
|
if (englishContent?.name) return englishContent.name;
|
||||||
|
return category.contents[0]?.name || '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = async (post: TablePost) => {
|
||||||
|
navigate(post.slug);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (post: TablePost) => {
|
||||||
|
if (!window.confirm(t('admin.posts.deleteConfirm'))) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/posts/${post.slug}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete post');
|
||||||
|
|
||||||
|
await fetchPosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting post:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'title' as keyof Post,
|
key: 'displayTitle' as keyof TablePost,
|
||||||
title: t('admin.posts.title'),
|
title: t('admin.posts.title'),
|
||||||
|
render: (value: string): ReactNode => value
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'category' as keyof Post,
|
key: 'displayCategories' as keyof TablePost,
|
||||||
title: t('admin.posts.category'),
|
title: t('admin.posts.categories'),
|
||||||
|
render: (value: string): ReactNode => value
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'publishDate' as keyof Post,
|
key: 'created_at' as keyof TablePost,
|
||||||
title: t('admin.posts.publishDate'),
|
title: t('admin.posts.createdAt'),
|
||||||
},
|
render: (value: string): ReactNode => (
|
||||||
{
|
<span title={dayjs(value).format('YYYY-MM-DD HH:mm:ss')}>
|
||||||
key: 'status' as keyof Post,
|
{dayjs(value).fromNow()}
|
||||||
title: t('admin.posts.status'),
|
|
||||||
render: (value: Post['status']) => (
|
|
||||||
<span
|
|
||||||
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
|
||||||
value === 'published'
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
|
||||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(`admin.common.${value}`)}
|
|
||||||
</span>
|
</span>
|
||||||
),
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'status' as keyof TablePost,
|
||||||
|
title: t('admin.posts.status'),
|
||||||
|
render: (value: string | number): ReactNode => {
|
||||||
|
const status = value as TablePost['status'];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
status === 'published'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.common.${status}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
// TODO: 实现创建文章的逻辑
|
navigate('new');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -89,59 +199,13 @@ const PostsManagement: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Table<Post>
|
<Table<TablePost>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={posts}
|
data={posts}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
>
|
/>
|
||||||
{({ data, onEdit, onDelete }) => (
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
|
||||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tl-md">{t('admin.common.title')}</th>
|
|
||||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.category')}</th>
|
|
||||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.publishDate')}</th>
|
|
||||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.status')}</th>
|
|
||||||
<th className="text-right py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.map((post) => (
|
|
||||||
<tr key={post.id} className="border-b border-slate-200 dark:border-slate-700 last:border-0">
|
|
||||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.title}</td>
|
|
||||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.category}</td>
|
|
||||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.publishDate}</td>
|
|
||||||
<td className="py-4 px-6">
|
|
||||||
<span className={`px-2 py-1 text-sm font-medium rounded-full ${
|
|
||||||
post.status === 'published'
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
|
||||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
|
||||||
}`}>
|
|
||||||
{t(`admin.common.${post.status}`)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-4 px-6 text-right">
|
|
||||||
<button
|
|
||||||
className="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 px-2 py-1 rounded-md transition-colors"
|
|
||||||
onClick={() => onEdit(post)}
|
|
||||||
>
|
|
||||||
{t('admin.common.edit')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
|
||||||
onClick={() => onDelete(post)}
|
|
||||||
>
|
|
||||||
{t('admin.common.delete')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -14,6 +14,7 @@ const Footer = lazy(() => import('./components/Footer'));
|
||||||
// 管理页面组件
|
// 管理页面组件
|
||||||
const Dashboard = lazy(() => import('./pages/admin/dashboard/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/admin/dashboard/Dashboard'));
|
||||||
const PostsManagement = lazy(() => import('./pages/admin/posts/PostsManagement'));
|
const PostsManagement = lazy(() => import('./pages/admin/posts/PostsManagement'));
|
||||||
|
const PostEditor = lazy(() => import('./pages/admin/posts/PostEditor'));
|
||||||
const DailyManagement = lazy(() => import('./pages/admin/daily/DailyManagement'));
|
const DailyManagement = lazy(() => import('./pages/admin/daily/DailyManagement'));
|
||||||
const MediasManagement = lazy(() => import('./pages/admin/medias/MediasManagement'));
|
const MediasManagement = lazy(() => import('./pages/admin/medias/MediasManagement'));
|
||||||
const CategoriesManagement = lazy(() => import('./pages/admin/categories/CategoriesManagement'));
|
const CategoriesManagement = lazy(() => import('./pages/admin/categories/CategoriesManagement'));
|
||||||
|
@ -140,11 +141,32 @@ const router = createBrowserRouter([
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'posts',
|
path: 'posts',
|
||||||
element: (
|
children: [
|
||||||
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
{
|
||||||
<PostsManagement />
|
index: true,
|
||||||
</Suspense>
|
element: (
|
||||||
),
|
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||||
|
<PostsManagement />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'new',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||||
|
<PostEditor />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':postId',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||||
|
<PostEditor />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'daily',
|
path: 'daily',
|
||||||
|
|
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
|
@ -22,9 +22,18 @@ importers:
|
||||||
'@tss-rocks/api':
|
'@tss-rocks/api':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../api
|
version: link:../api
|
||||||
|
'@types/classnames':
|
||||||
|
specifier: ^2.3.4
|
||||||
|
version: 2.3.4
|
||||||
'@types/markdown-it':
|
'@types/markdown-it':
|
||||||
specifier: ^14.1.2
|
specifier: ^14.1.2
|
||||||
version: 14.1.2
|
version: 14.1.2
|
||||||
|
classnames:
|
||||||
|
specifier: ^2.5.1
|
||||||
|
version: 2.5.1
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^24.2.2
|
specifier: ^24.2.2
|
||||||
version: 24.2.2(typescript@5.7.3)
|
version: 24.2.2(typescript@5.7.3)
|
||||||
|
@ -843,6 +852,10 @@ packages:
|
||||||
'@types/babel__traverse@7.20.6':
|
'@types/babel__traverse@7.20.6':
|
||||||
resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
|
resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
|
||||||
|
|
||||||
|
'@types/classnames@2.3.4':
|
||||||
|
resolution: {integrity: sha512-dwmfrMMQb9ujX1uYGvB5ERDlOzBNywnZAZBtOe107/hORWP05ESgU4QyaanZMWYYfd2BzrG78y13/Bju8IQcMQ==}
|
||||||
|
deprecated: This is a stub types definition. classnames provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/cookie@0.6.0':
|
'@types/cookie@0.6.0':
|
||||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||||
|
|
||||||
|
@ -1080,6 +1093,9 @@ packages:
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
|
dayjs@1.11.13:
|
||||||
|
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||||
|
|
||||||
debug@4.4.0:
|
debug@4.4.0:
|
||||||
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
|
@ -2898,6 +2914,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.26.9
|
'@babel/types': 7.26.9
|
||||||
|
|
||||||
|
'@types/classnames@2.3.4':
|
||||||
|
dependencies:
|
||||||
|
classnames: 2.5.1
|
||||||
|
|
||||||
'@types/cookie@0.6.0': {}
|
'@types/cookie@0.6.0': {}
|
||||||
|
|
||||||
'@types/estree@1.0.6': {}
|
'@types/estree@1.0.6': {}
|
||||||
|
@ -3164,6 +3184,8 @@ snapshots:
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
|
dayjs@1.11.13: {}
|
||||||
|
|
||||||
debug@4.4.0:
|
debug@4.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue