forked from carrydela/Rs_blog_front
feature: add draft
This commit is contained in:
@@ -13,7 +13,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Pass API URL during build time (Next.js inlines this)
|
# Pass API URL during build time (Next.js inlines this)
|
||||||
ARG NEXT_PUBLIC_API_URL=0.0.0.0:8765
|
ARG NEXT_PUBLIC_API_URL
|
||||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
# REPLACE THIS with your actual backend URL (e.g., https://api.yourdomain.com)
|
# REPLACE THIS with your actual backend URL (e.g., https://api.yourdomain.com)
|
||||||
# 0.0.0.0 does NOT work for browsers. Use 127.0.0.1 for local, or public IP for server.
|
# Since this is a client-side var, it cannot be "localhost" if accessed from outside the server.
|
||||||
NEXT_PUBLIC_API_URL: http://127.0.0.1:8765
|
NEXT_PUBLIC_API_URL: https://your-backend-url.com
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
# Maps host port 3001 to container port 3000 to avoid conflict
|
- "127.0.0.1:3367:3000"
|
||||||
- "3001:3000"
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
|||||||
551
docs/apidev_part2.md
Normal file
551
docs/apidev_part2.md
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
# 🔌 后端对接集成文档 Part 2 (API Integration Spec - Extended)
|
||||||
|
|
||||||
|
**延续文档**: `apidev_part1.md`
|
||||||
|
**覆盖功能**: 注册、文章更新/删除、认证管理、草稿流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 补充数据模型 (Extended TypeScript Interfaces)
|
||||||
|
|
||||||
|
在 `src/types/api.ts` 中追加以下接口:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. 注册模型 (Registration)
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string; // 最小 8 字符
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResponse {
|
||||||
|
message: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 文章更新模型 (Post Update)
|
||||||
|
export interface UpdatePostRequest {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
published?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 删除响应 (Delete Response)
|
||||||
|
export interface DeleteResponse {
|
||||||
|
message: string;
|
||||||
|
deleted_at?: string; // ISO 8601 - 软删除时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 分页响应包装 (Paginated Response)
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. API 错误模型 (API Error)
|
||||||
|
export interface ApiError {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
field?: string; // 验证错误时标识具体字段
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API 客户端扩展 (Extended Service Layer)
|
||||||
|
|
||||||
|
在 `src/lib/api.ts` 中追加以下方法:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const api = {
|
||||||
|
// === 原有方法保持不变 ===
|
||||||
|
|
||||||
|
// Auth - 新增注册
|
||||||
|
register: (data: RegisterRequest) => request<RegisterResponse>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Auth - 登出 (清理本地 Token)
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
// 无需调用后端,JWT 无状态
|
||||||
|
},
|
||||||
|
|
||||||
|
// Posts - 更新文章
|
||||||
|
updatePost: (id: string, data: UpdatePostRequest) => request<Post>(`/posts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Posts - 删除文章 (软删除)
|
||||||
|
deletePost: (id: string) => request<DeleteResponse>(`/posts/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Posts - 发布/取消发布 (便捷方法)
|
||||||
|
publishPost: (id: string) => request<Post>(`/posts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ published: true }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
unpublishPost: (id: string) => request<Post>(`/posts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ published: false }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 功能模块集成策略 (续)
|
||||||
|
|
||||||
|
### 3.4 用户注册 (Registration Flow)
|
||||||
|
|
||||||
|
**极简注册表单应与登录共享视觉语言。**
|
||||||
|
|
||||||
|
* **API**: `POST /api/auth/register`
|
||||||
|
* **必填字段**: `email`, `password`
|
||||||
|
* **验证规则**:
|
||||||
|
* Email: 有效邮箱格式
|
||||||
|
* Password: 最少 8 字符
|
||||||
|
|
||||||
|
**UI 交互映射:**
|
||||||
|
|
||||||
|
| 用户行为 | 前端反馈 |
|
||||||
|
|---------|---------|
|
||||||
|
| 输入密码 < 8 字符 | 密码框下边框变灰 `border-neutral-300`,显示字符计数 `5/8` |
|
||||||
|
| 提交时密码过短 | 边框变红 + 微震动,显示 "Min 8 characters" |
|
||||||
|
| Email 已存在 (409) | Email 框下方显示 "Already registered" |
|
||||||
|
| 注册成功 | 按钮文字变为 "Created",1.5s 后跳转登录页 |
|
||||||
|
|
||||||
|
**组件实现建议:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/RegisterForm.tsx
|
||||||
|
const [charCount, setCharCount] = useState(0);
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
onChange={(e) => setCharCount(e.target.value.length)}
|
||||||
|
className={cn(
|
||||||
|
"border-b-2 transition-colors",
|
||||||
|
charCount < 8 && charCount > 0 ? "border-neutral-300" : "border-black"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{charCount > 0 && charCount < 8 && (
|
||||||
|
<span className="text-xs text-neutral-400 mt-1">{charCount}/8</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 文章编辑与更新 (Edit Post Flow)
|
||||||
|
|
||||||
|
**编辑器应复用发布页面,但预填充现有数据。**
|
||||||
|
|
||||||
|
* **API**: `PUT /api/posts/{id}`
|
||||||
|
* **可更新字段**: `title`, `content`, `published`
|
||||||
|
* **权限**: 需要 Bearer Token + 文章所有权
|
||||||
|
|
||||||
|
**页面路由建议:**
|
||||||
|
|
||||||
|
```
|
||||||
|
/posts/[id]/edit -> 编辑页面
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据加载流程:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/posts/[id]/edit/page.tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPost = async () => {
|
||||||
|
try {
|
||||||
|
const post = await api.getPostById(id);
|
||||||
|
setTitle(post.title);
|
||||||
|
setContent(post.content);
|
||||||
|
setPublished(post.published);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 404) router.push('/404');
|
||||||
|
if (err.status === 401) router.push('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadPost();
|
||||||
|
}, [id]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**保存策略:**
|
||||||
|
|
||||||
|
| 策略 | 实现方式 |
|
||||||
|
|-----|---------|
|
||||||
|
| 手动保存 | 点击 "Save" 按钮触发 `PUT` |
|
||||||
|
| 自动保存 (可选) | `debounce` 300ms 后自动调用 `updatePost`,右上角显示 "Saving..." → "Saved" |
|
||||||
|
|
||||||
|
**极简自动保存指示器:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 不要用 spinner,用文字状态
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||||
|
|
||||||
|
{saveStatus === 'saving' && <span className="text-neutral-400 text-xs">Saving...</span>}
|
||||||
|
{saveStatus === 'saved' && <span className="text-neutral-600 text-xs">Saved</span>}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 文章删除 (Delete Post Flow)
|
||||||
|
|
||||||
|
**删除是破坏性操作,但极简设计不使用模态确认框。**
|
||||||
|
|
||||||
|
* **API**: `DELETE /api/posts/{id}`
|
||||||
|
* **行为**: 软删除 (数据库标记 `deleted_at`)
|
||||||
|
* **权限**: 需要 Bearer Token + 文章所有权
|
||||||
|
|
||||||
|
**极简确认模式 (Inline Confirmation):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 第一次点击:文字变化
|
||||||
|
// 第二次点击:执行删除
|
||||||
|
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmDelete) {
|
||||||
|
handleDelete();
|
||||||
|
} else {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
setTimeout(() => setConfirmDelete(false), 3000); // 3秒后重置
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"text-sm transition-colors",
|
||||||
|
confirmDelete ? "text-red-600" : "text-neutral-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{confirmDelete ? "Confirm delete" : "Delete"}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**删除后行为:**
|
||||||
|
|
||||||
|
| 场景 | 反馈 |
|
||||||
|
|-----|-----|
|
||||||
|
| 在列表页删除 | 该行淡出 (`opacity-0 transition-opacity`),然后移除 |
|
||||||
|
| 在详情页删除 | 显示 "Deleted",2秒后跳转首页 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 草稿与发布状态管理 (Draft/Published Toggle)
|
||||||
|
|
||||||
|
**文章有两种状态:草稿 (draft) 和已发布 (published)。**
|
||||||
|
|
||||||
|
* **草稿**: `published: false` — 仅作者可见
|
||||||
|
* **已发布**: `published: true` — 公开可见
|
||||||
|
|
||||||
|
**状态切换 UI:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 极简 Toggle:不用开关组件,用文字按钮
|
||||||
|
<button
|
||||||
|
onClick={() => api.updatePost(id, { published: !isPublished })}
|
||||||
|
className="text-sm text-neutral-500 hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
{isPublished ? "Unpublish" : "Publish"}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**列表页草稿标识:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 草稿文章标题后显示灰色 (Draft) 标记
|
||||||
|
<h2 className="text-lg font-medium">
|
||||||
|
{post.title}
|
||||||
|
{!post.published && (
|
||||||
|
<span className="text-neutral-400 text-sm ml-2">(Draft)</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 认证状态管理 (Auth State Management)
|
||||||
|
|
||||||
|
### 4.1 Token 存储策略
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/auth.ts
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
// 存储 Token
|
||||||
|
setToken: (token: string) => {
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取 Token
|
||||||
|
getToken: () => localStorage.getItem('auth_token'),
|
||||||
|
|
||||||
|
// 检查是否已登录
|
||||||
|
isAuthenticated: () => !!localStorage.getItem('auth_token'),
|
||||||
|
|
||||||
|
// 清除认证信息
|
||||||
|
clear: () => {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 存储用户信息
|
||||||
|
setUser: (user: { id: string; email: string }) => {
|
||||||
|
localStorage.setItem('user_info', JSON.stringify(user));
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
getUser: () => {
|
||||||
|
const stored = localStorage.getItem('user_info');
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 全局认证守卫 (Auth Guard)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/AuthGuard.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isAuthenticated()) {
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated()) {
|
||||||
|
return null; // 或返回骨架屏
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用方式:包裹需要认证的页面
|
||||||
|
// src/app/posts/new/page.tsx
|
||||||
|
export default function NewPostPage() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<PostEditor />
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 401 全局拦截
|
||||||
|
|
||||||
|
更新 `src/lib/api.ts` 中的 `request` 函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
// ... 原有代码 ...
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 401 时自动清除 Token 并跳转
|
||||||
|
if (response.status === 401) {
|
||||||
|
auth.clear();
|
||||||
|
// 使用 window.location 而非 router,确保完全刷新状态
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
throw { status: response.status, message: response.statusText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 原有代码 ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 扩展状态反馈映射表 (Extended UI Feedback)
|
||||||
|
|
||||||
|
| 后端状态 | 场景 | 前端极简反馈 |
|
||||||
|
|---------|-----|-------------|
|
||||||
|
| **201 Created** | 注册成功 | 按钮文字变为 "Created",跳转登录页 |
|
||||||
|
| **409 Conflict** | Email 已存在 | Email 框下方显示 "Already registered" |
|
||||||
|
| **400 Bad Request** | 密码过短 | 显示字符计数 `5/8`,边框变红 |
|
||||||
|
| **200 OK** | 文章更新成功 | 右上角显示 "Saved" (2秒消失) |
|
||||||
|
| **200 OK** | 文章删除成功 | 当前行/页面淡出,跳转 |
|
||||||
|
| **403 Forbidden** | 无权编辑他人文章 | 显示 "Not your post",禁用编辑按钮 |
|
||||||
|
| **422 Unprocessable** | 文章内容不合法 | 对应字段边框变红 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 页面路由规划 (Route Structure)
|
||||||
|
|
||||||
|
```
|
||||||
|
/ # 首页 - 文章列表
|
||||||
|
/login # 登录
|
||||||
|
/register # 注册 (新增)
|
||||||
|
/posts/[id] # 文章详情
|
||||||
|
/posts/[id]/edit # 编辑文章 (新增,需认证)
|
||||||
|
/posts/new # 发布新文章 (需认证)
|
||||||
|
/drafts # 我的草稿列表 (新增,需认证)
|
||||||
|
/404 # 404 页面
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 开发路线图 (续)
|
||||||
|
|
||||||
|
接续 Part 1 的开发顺序:
|
||||||
|
|
||||||
|
6. **Step 6: Register Page** — 完成注册页面,复用登录页样式。
|
||||||
|
7. **Step 7: Edit Flow** — 完成文章编辑功能,实现 `PUT /posts/:id`。
|
||||||
|
8. **Step 8: Delete Flow** — 完成删除功能,实现 Inline Confirmation 模式。
|
||||||
|
9. **Step 9: Draft Management** — 完成草稿列表页,支持发布/取消发布切换。
|
||||||
|
10. **Step 10: Auth Guard** — 添加全局认证守卫,保护需要登录的路由。
|
||||||
|
11. **Step 11: Polish** — 统一错误处理、Loading 状态、过渡动画。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 完整 API 客户端 (Final api.ts)
|
||||||
|
|
||||||
|
汇总 Part 1 + Part 2 的完整 API 客户端:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/api.ts
|
||||||
|
|
||||||
|
import { auth } from './auth';
|
||||||
|
import type {
|
||||||
|
LoginRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
AuthResponse,
|
||||||
|
RegisterResponse,
|
||||||
|
Post,
|
||||||
|
UpdatePostRequest,
|
||||||
|
DeleteResponse,
|
||||||
|
PageParams,
|
||||||
|
} from '@/types/api';
|
||||||
|
|
||||||
|
const BASE_URL = 'http://127.0.0.1:8765/api';
|
||||||
|
|
||||||
|
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const token = auth.getToken();
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
auth.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
const errorBody = await response.json().catch(() => ({}));
|
||||||
|
throw {
|
||||||
|
status: response.status,
|
||||||
|
message: response.statusText,
|
||||||
|
...errorBody
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) return {} as T;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// ===== Auth =====
|
||||||
|
login: (data: LoginRequest) => request<AuthResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
register: (data: RegisterRequest) => request<RegisterResponse>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
logout: () => auth.clear(),
|
||||||
|
|
||||||
|
// ===== Posts - Read =====
|
||||||
|
getPosts: (params: PageParams) => {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: params.page.toString(),
|
||||||
|
limit: params.limit.toString()
|
||||||
|
});
|
||||||
|
return request<Post[]>(`/posts?${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPostById: (id: string) => request<Post>(`/posts/${id}`),
|
||||||
|
|
||||||
|
// ===== Posts - Write =====
|
||||||
|
createPost: (data: Partial<Post>) => request<Post>('/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updatePost: (id: string, data: UpdatePostRequest) => request<Post>(`/posts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deletePost: (id: string) => request<DeleteResponse>(`/posts/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ===== Posts - Convenience =====
|
||||||
|
publishPost: (id: string) => request<Post>(`/posts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ published: true }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
unpublishPost: (id: string) => request<Post>(`/posts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ published: false }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ===== Upload =====
|
||||||
|
uploadImage: async (file: File) => {
|
||||||
|
const token = auth.getToken();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token && { 'Authorization': `Bearer ${token}` })
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
auth.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
现在 Part 1 + Part 2 完整覆盖了你的 OpenAPI 规范中的所有端点。
|
||||||
@@ -6,13 +6,13 @@ import { Container } from "@/components/layout/Container";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Skeleton } from "@/components/ui/Skeleton";
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
import { Tag } from "@/components/ui/Tag";
|
import { Tag } from "@/components/ui/Tag";
|
||||||
|
import { DeleteButton } from "@/components/DeleteButton";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Post } from "@/types/api";
|
import type { Post } from "@/types/api";
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPosts();
|
fetchPosts();
|
||||||
@@ -21,7 +21,7 @@ export default function AdminDashboard() {
|
|||||||
async function fetchPosts() {
|
async function fetchPosts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.getPosts(1, 100);
|
const response = await api.getPosts(1, 50);
|
||||||
setPosts(response.posts ?? []);
|
setPosts(response.posts ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
// Handle error silently
|
// Handle error silently
|
||||||
@@ -31,17 +31,8 @@ export default function AdminDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Are you sure you want to delete this post?")) return;
|
|
||||||
|
|
||||||
setDeleting(id);
|
|
||||||
try {
|
|
||||||
await api.deletePost(id);
|
await api.deletePost(id);
|
||||||
setPosts((prev) => prev.filter((p) => p.id !== id));
|
setPosts((prev) => prev.filter((p) => p.id !== id));
|
||||||
} catch {
|
|
||||||
alert("Failed to delete post");
|
|
||||||
} finally {
|
|
||||||
setDeleting(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,14 +79,7 @@ export default function AdminDashboard() {
|
|||||||
<Link href={`/admin/editor/${post.id}`}>
|
<Link href={`/admin/editor/${post.id}`}>
|
||||||
<Button size="sm">Edit</Button>
|
<Button size="sm">Edit</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<DeleteButton onDelete={() => handleDelete(post.id)} />
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(post.id)}
|
|
||||||
disabled={deleting === post.id}
|
|
||||||
className="border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
|
|
||||||
>
|
|
||||||
{deleting === post.id ? "..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
121
src/app/drafts/page.tsx
Normal file
121
src/app/drafts/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Container } from "@/components/layout/Container";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
|
import { DeleteButton } from "@/components/DeleteButton";
|
||||||
|
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Post } from "@/types/api";
|
||||||
|
|
||||||
|
function DraftsContent() {
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [publishing, setPublishing] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPosts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchPosts() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const myPosts = await api.getMyPosts();
|
||||||
|
setPosts(myPosts.filter((p) => !p.published));
|
||||||
|
} catch {
|
||||||
|
// Handle silently
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublish(id: string) {
|
||||||
|
setPublishing(id);
|
||||||
|
try {
|
||||||
|
await api.publishPost(id);
|
||||||
|
setPosts((prev) => prev.filter((p) => p.id !== id));
|
||||||
|
} catch {
|
||||||
|
alert("Failed to publish");
|
||||||
|
} finally {
|
||||||
|
setPublishing(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await api.deletePost(id);
|
||||||
|
setPosts((prev) => prev.filter((p) => p.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="py-24">
|
||||||
|
<div className="flex items-center justify-between mb-12">
|
||||||
|
<h1 className="text-5xl font-bold tracking-tighter">My Drafts</h1>
|
||||||
|
<Link href="/admin/editor">
|
||||||
|
<Button>New Post</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && posts.length === 0 && (
|
||||||
|
<p className="text-neutral-400 text-lg">No drafts yet. All your posts are published.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && posts.length > 0 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<div
|
||||||
|
key={post.id}
|
||||||
|
className="border border-neutral-200 p-6 flex items-center justify-between transition-opacity duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Link href={`/admin/editor/${post.id}`}>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight truncate hover:opacity-70 transition-opacity">
|
||||||
|
{post.title || "Untitled"}
|
||||||
|
</h2>
|
||||||
|
</Link>
|
||||||
|
<span className="text-neutral-400 text-sm">(Draft)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 truncate">
|
||||||
|
{post.content.slice(0, 100).replace(/[#*`]/g, "")}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 ml-6">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePublish(post.id)}
|
||||||
|
disabled={publishing === post.id}
|
||||||
|
className="border-green-600 text-green-600 hover:bg-green-600 hover:text-white"
|
||||||
|
>
|
||||||
|
{publishing === post.id ? "Publishing..." : "Publish"}
|
||||||
|
</Button>
|
||||||
|
<Link href={`/admin/editor/${post.id}`}>
|
||||||
|
<Button size="sm">Edit</Button>
|
||||||
|
</Link>
|
||||||
|
<DeleteButton onDelete={() => handleDelete(post.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DraftsPage() {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DraftsContent />
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
import { Container } from "@/components/layout/Container";
|
import { Container } from "@/components/layout/Container";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -76,6 +77,13 @@ export default function LoginPage() {
|
|||||||
{loading ? "Signing in..." : "Sign In"}
|
{loading ? "Signing in..." : "Sign In"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center mt-8 text-neutral-500">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/register" className="text-black underline hover:no-underline">
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
203
src/app/posts/[id]/edit/page.tsx
Normal file
203
src/app/posts/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef, use } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Container } from "@/components/layout/Container";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
|
import { MarkdownEditor } from "@/components/editor/MarkdownEditor";
|
||||||
|
import { ImageUpload } from "@/components/editor/ImageUpload";
|
||||||
|
import { PostContent } from "@/components/post/PostContent";
|
||||||
|
import { SaveStatus } from "@/components/SaveStatus";
|
||||||
|
import { DeleteButton } from "@/components/DeleteButton";
|
||||||
|
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
interface EditPostPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTOSAVE_DELAY = 300;
|
||||||
|
|
||||||
|
function EditPostContent({ id }: { id: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [published, setPublished] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const [forbidden, setForbidden] = useState(false);
|
||||||
|
const autosaveRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const hasChangesRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchPost() {
|
||||||
|
try {
|
||||||
|
const post = await api.getPost(id);
|
||||||
|
setTitle(post.title);
|
||||||
|
setContent(post.content);
|
||||||
|
setPublished(post.published);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.message.includes("403") || err.message.toLowerCase().includes("forbidden")) {
|
||||||
|
setForbidden(true);
|
||||||
|
} else if (err.message.includes("404")) {
|
||||||
|
router.push("/not-found");
|
||||||
|
} else if (err.message.includes("401")) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPost();
|
||||||
|
}, [id, router]);
|
||||||
|
|
||||||
|
const savePost = useCallback(async () => {
|
||||||
|
if (!title.trim() || !hasChangesRef.current) return;
|
||||||
|
|
||||||
|
setSaveStatus("saving");
|
||||||
|
try {
|
||||||
|
await api.updatePost(id, { title, content, published });
|
||||||
|
setSaveStatus("saved");
|
||||||
|
hasChangesRef.current = false;
|
||||||
|
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("error");
|
||||||
|
}
|
||||||
|
}, [id, title, content, published]);
|
||||||
|
|
||||||
|
const handleTitleChange = (newTitle: string) => {
|
||||||
|
setTitle(newTitle);
|
||||||
|
hasChangesRef.current = true;
|
||||||
|
scheduleAutosave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (newContent: string) => {
|
||||||
|
setContent(newContent);
|
||||||
|
hasChangesRef.current = true;
|
||||||
|
scheduleAutosave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublishedChange = (newPublished: boolean) => {
|
||||||
|
setPublished(newPublished);
|
||||||
|
hasChangesRef.current = true;
|
||||||
|
scheduleAutosave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleAutosave = () => {
|
||||||
|
if (autosaveRef.current) {
|
||||||
|
clearTimeout(autosaveRef.current);
|
||||||
|
}
|
||||||
|
autosaveRef.current = setTimeout(() => {
|
||||||
|
savePost();
|
||||||
|
}, AUTOSAVE_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (autosaveRef.current) {
|
||||||
|
clearTimeout(autosaveRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImageUpload = (url: string) => {
|
||||||
|
const imageMarkdown = `\n`;
|
||||||
|
setContent((prev) => prev + imageMarkdown);
|
||||||
|
hasChangesRef.current = true;
|
||||||
|
scheduleAutosave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
await api.deletePost(id);
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Container className="py-12">
|
||||||
|
<Skeleton className="h-12 w-1/2 mb-8" />
|
||||||
|
<Skeleton className="h-[60vh]" />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forbidden) {
|
||||||
|
return (
|
||||||
|
<Container className="py-24 min-h-[60vh] flex flex-col items-center justify-center">
|
||||||
|
<p className="text-3xl font-bold tracking-tighter text-red-500">Not Your Post</p>
|
||||||
|
<p className="text-neutral-500 mt-4">You can only edit posts you created.</p>
|
||||||
|
<Button onClick={() => router.push("/")} className="mt-8">
|
||||||
|
Go Home
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="py-12">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
|
placeholder="Post title"
|
||||||
|
className="text-4xl font-bold tracking-tighter bg-transparent focus:outline-none placeholder:text-neutral-300 w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 ml-6">
|
||||||
|
<SaveStatus status={saveStatus} />
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={published}
|
||||||
|
onChange={(e) => handlePublishedChange(e.target.checked)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm tracking-wide">Publish</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button onClick={savePost} disabled={saveStatus === "saving" || !title.trim()}>
|
||||||
|
{saveStatus === "saving" ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DeleteButton onDelete={handleDelete} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<ImageUpload onUpload={handleImageUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-8 h-[calc(100vh-280px)]">
|
||||||
|
<div className="border border-neutral-200 p-6 overflow-auto">
|
||||||
|
<MarkdownEditor value={content} onChange={handleContentChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-neutral-200 p-6 overflow-auto bg-neutral-50">
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
{content ? (
|
||||||
|
<PostContent content={content} />
|
||||||
|
) : (
|
||||||
|
<p className="text-neutral-400">Preview will appear here...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditPostPage({ params }: EditPostPageProps) {
|
||||||
|
const { id } = use(params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<EditPostContent id={id} />
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
import { useState, useEffect, use } from "react";
|
import { useState, useEffect, use } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Container } from "@/components/layout/Container";
|
import { Container } from "@/components/layout/Container";
|
||||||
import { PostContent } from "@/components/post/PostContent";
|
import { PostContent } from "@/components/post/PostContent";
|
||||||
import { PostMeta } from "@/components/post/PostMeta";
|
import { PostMeta } from "@/components/post/PostMeta";
|
||||||
import { Skeleton } from "@/components/ui/Skeleton";
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
import { TextLink } from "@/components/ui/TextLink";
|
import { TextLink } from "@/components/ui/TextLink";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { DeleteButton } from "@/components/DeleteButton";
|
||||||
|
import { useAuth } from "@/lib/hooks/useAuth";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Post } from "@/types/api";
|
import type { Post } from "@/types/api";
|
||||||
|
|
||||||
@@ -18,6 +22,7 @@ interface PostPageProps {
|
|||||||
export default function PostPage({ params }: PostPageProps) {
|
export default function PostPage({ params }: PostPageProps) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const [post, setPost] = useState<Post | null>(null);
|
const [post, setPost] = useState<Post | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
@@ -38,6 +43,11 @@ export default function PostPage({ params }: PostPageProps) {
|
|||||||
fetchPost();
|
fetchPost();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
await api.deletePost(id);
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
router.push("/not-found");
|
router.push("/not-found");
|
||||||
return null;
|
return null;
|
||||||
@@ -45,10 +55,21 @@ export default function PostPage({ params }: PostPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-24">
|
<Container className="py-24">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<TextLink href="/" className="text-sm">
|
<TextLink href="/" className="text-sm">
|
||||||
Back to all posts
|
Back to all posts
|
||||||
</TextLink>
|
</TextLink>
|
||||||
|
|
||||||
|
{isAuthenticated && post && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href={`/posts/${id}/edit`}>
|
||||||
|
<Button size="sm">Edit</Button>
|
||||||
|
</Link>
|
||||||
|
<DeleteButton onDelete={handleDelete} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
|
|||||||
127
src/app/register/page.tsx
Normal file
127
src/app/register/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Container } from "@/components/layout/Container";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const passwordValid = password.length >= MIN_PASSWORD_LENGTH;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!passwordValid) {
|
||||||
|
setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.register({ email, password });
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/login");
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.message.includes("409") || err.message.toLowerCase().includes("already")) {
|
||||||
|
setError("An account with this email already exists");
|
||||||
|
} else {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("Registration failed");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<Container className="py-24 min-h-[60vh] flex flex-col items-center justify-center">
|
||||||
|
<p className="text-3xl font-bold tracking-tighter">Account Created</p>
|
||||||
|
<p className="text-neutral-500 mt-4">Redirecting to login...</p>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="py-24">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<h1 className="text-5xl font-bold tracking-tighter mb-12">Register</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
error={error ? " " : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<label className="block text-xs font-bold tracking-widest uppercase text-neutral-500 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
className={`w-full border-b py-3 bg-transparent text-black placeholder:text-neutral-400 focus:outline-none transition-colors ${
|
||||||
|
error
|
||||||
|
? "border-red-500 animate-shake"
|
||||||
|
: password.length > 0
|
||||||
|
? passwordValid
|
||||||
|
? "border-green-500"
|
||||||
|
: "border-yellow-500"
|
||||||
|
: "border-neutral-200 focus:border-black"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`absolute right-0 top-1/2 -translate-y-1/2 text-xs ${
|
||||||
|
passwordValid ? "text-green-500" : "text-neutral-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{password.length}/{MIN_PASSWORD_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
|
{loading ? "Creating account..." : success ? "Created" : "Create Account"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center mt-8 text-neutral-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="text-black underline hover:no-underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/DeleteButton.tsx
Normal file
68
src/components/DeleteButton.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
|
interface DeleteButtonProps {
|
||||||
|
onDelete: () => Promise<void>;
|
||||||
|
className?: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIRM_TIMEOUT = 3000;
|
||||||
|
|
||||||
|
export function DeleteButton({ onDelete, className, size = "sm" }: DeleteButtonProps) {
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (deleting) return;
|
||||||
|
|
||||||
|
if (!confirming) {
|
||||||
|
setConfirming(true);
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setConfirming(false);
|
||||||
|
}, CONFIRM_TIMEOUT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await onDelete();
|
||||||
|
} catch {
|
||||||
|
setDeleting(false);
|
||||||
|
setConfirming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={deleting}
|
||||||
|
className={clsx(
|
||||||
|
"transition-all duration-200",
|
||||||
|
confirming
|
||||||
|
? "border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
|
||||||
|
: "border-neutral-400 text-neutral-500 hover:border-neutral-600 hover:text-neutral-700",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting..." : confirming ? "Confirm Delete" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/SaveStatus.tsx
Normal file
30
src/components/SaveStatus.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface SaveStatusProps {
|
||||||
|
status: "idle" | "saving" | "saved" | "error";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveStatus({ status, className }: SaveStatusProps) {
|
||||||
|
if (status === "idle") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"text-sm transition-opacity duration-200",
|
||||||
|
{
|
||||||
|
"text-neutral-400": status === "saving",
|
||||||
|
"text-green-500": status === "saved",
|
||||||
|
"text-red-500": status === "error",
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status === "saving" && "Saving..."}
|
||||||
|
{status === "saved" && "Saved"}
|
||||||
|
{status === "error" && "Failed to save"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container } from "./Container";
|
import { Container } from "./Container";
|
||||||
import { TextLink } from "@/components/ui/TextLink";
|
import { TextLink } from "@/components/ui/TextLink";
|
||||||
import { useAuth } from "@/lib/hooks/useAuth";
|
import { useAuth } from "@/lib/hooks/useAuth";
|
||||||
|
|
||||||
|
const subscribe = () => () => {};
|
||||||
|
const getSnapshot = () => true;
|
||||||
|
const getServerSnapshot = () => false;
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
const [mounted, setMounted] = useState(false);
|
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="py-8 border-b border-neutral-100">
|
<header className="py-8 border-b border-neutral-100">
|
||||||
@@ -31,6 +31,7 @@ export function Header() {
|
|||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<TextLink href="/admin">Dashboard</TextLink>
|
<TextLink href="/admin">Dashboard</TextLink>
|
||||||
|
<TextLink href="/drafts">My Drafts</TextLink>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="text-sm tracking-wide uppercase text-neutral-500 hover:text-black transition-colors"
|
className="text-sm tracking-wide uppercase text-neutral-500 hover:text-black transition-colors"
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import type { Post } from "@/types/api";
|
|||||||
|
|
||||||
interface PostCardProps {
|
interface PostCardProps {
|
||||||
post: Post;
|
post: Post;
|
||||||
|
isOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostCard({ post }: PostCardProps) {
|
export function PostCard({ post, isOwner = false }: PostCardProps) {
|
||||||
return (
|
return (
|
||||||
<article className="grid grid-cols-12 gap-6 md:gap-12 mb-24">
|
<article className="grid grid-cols-12 gap-6 md:gap-12 mb-24">
|
||||||
{post.cover_image && (
|
{post.cover_image && (
|
||||||
@@ -38,6 +39,9 @@ export function PostCard({ post }: PostCardProps) {
|
|||||||
<Link href={`/posts/${post.id}`}>
|
<Link href={`/posts/${post.id}`}>
|
||||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter mt-4 hover:opacity-70 transition-opacity leading-tight">
|
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter mt-4 hover:opacity-70 transition-opacity leading-tight">
|
||||||
{post.title}
|
{post.title}
|
||||||
|
{isOwner && !post.published && (
|
||||||
|
<span className="text-neutral-400 text-sm ml-2 font-normal">(Draft)</span>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
PostsResponse,
|
PostsResponse,
|
||||||
UploadResponse,
|
UploadResponse,
|
||||||
} from "@/types/api";
|
} from "@/types/api";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8765";
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8765";
|
||||||
|
|
||||||
@@ -14,9 +15,7 @@ class ApiClient {
|
|||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
if (typeof window !== "undefined") {
|
this.token = auth.getToken();
|
||||||
this.token = localStorage.getItem("auth_token");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): ApiClient {
|
static getInstance(): ApiClient {
|
||||||
@@ -28,16 +27,13 @@ class ApiClient {
|
|||||||
|
|
||||||
setToken(token: string | null) {
|
setToken(token: string | null) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
if (typeof window !== "undefined") {
|
auth.setToken(token);
|
||||||
if (token) {
|
|
||||||
localStorage.setItem("auth_token", token);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem("auth_token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getToken(): string | null {
|
getToken(): string | null {
|
||||||
|
if (!this.token) {
|
||||||
|
this.token = auth.getToken();
|
||||||
|
}
|
||||||
return this.token;
|
return this.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +57,7 @@ class ApiClient {
|
|||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.setToken(null);
|
this.setToken(null);
|
||||||
|
auth.clear();
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
@@ -98,6 +95,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPosts(page = 1, limit = 10): Promise<PostsResponse> {
|
async getPosts(page = 1, limit = 10): Promise<PostsResponse> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const response = await this.request<any>(`/api/posts?page=${page}&limit=${limit}`);
|
const response = await this.request<any>(`/api/posts?page=${page}&limit=${limit}`);
|
||||||
|
|
||||||
// Handle response with 'data' field (backend format)
|
// Handle response with 'data' field (backend format)
|
||||||
@@ -151,6 +149,14 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async publishPost(id: string): Promise<Post> {
|
||||||
|
return this.updatePost(id, { published: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async unpublishPost(id: string): Promise<Post> {
|
||||||
|
return this.updatePost(id, { published: false });
|
||||||
|
}
|
||||||
|
|
||||||
async uploadImage(file: File): Promise<UploadResponse> {
|
async uploadImage(file: File): Promise<UploadResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
|
import { auth } from "./auth";
|
||||||
import type { User, LoginRequest, RegisterRequest } from "@/types/api";
|
import type { User, LoginRequest, RegisterRequest } from "@/types/api";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
@@ -22,32 +22,33 @@ interface AuthContextType {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
function getInitialUser(): User | null {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
if (typeof window === "undefined") return null;
|
||||||
const [loading, setLoading] = useState(true);
|
const token = auth.getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
const storedUser = auth.getUser();
|
||||||
|
return storedUser ?? { id: "", email: "" };
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const token = api.getToken();
|
const [user, setUser] = useState<User | null>(getInitialUser);
|
||||||
if (token) {
|
const [loading] = useState(false);
|
||||||
// Token exists, user is authenticated
|
|
||||||
// In a real app, you might validate the token or fetch user data
|
|
||||||
setUser({ id: "", email: "" });
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(async (data: LoginRequest) => {
|
const login = useCallback(async (data: LoginRequest) => {
|
||||||
const response = await api.login(data);
|
const response = await api.login(data);
|
||||||
|
auth.setUser(response.user);
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const register = useCallback(async (data: RegisterRequest) => {
|
const register = useCallback(async (data: RegisterRequest) => {
|
||||||
const response = await api.register(data);
|
const response = await api.register(data);
|
||||||
|
auth.setUser(response.user);
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
api.logout();
|
api.logout();
|
||||||
|
auth.clear();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
50
src/lib/auth.ts
Normal file
50
src/lib/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { User } from "@/types/api";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "auth_token";
|
||||||
|
const USER_KEY = "auth_user";
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
setToken(token: string | null) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
setUser(user: User | null) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser(): User | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const stored = localStorage.getItem(USER_KEY);
|
||||||
|
if (!stored) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return !!this.getToken();
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -47,3 +47,32 @@ export interface ApiError {
|
|||||||
message: string;
|
message: string;
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registration
|
||||||
|
export interface RegisterResponse {
|
||||||
|
message: string;
|
||||||
|
user: { id: string; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post operations
|
||||||
|
export interface UpdatePostRequest {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
published?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteResponse {
|
||||||
|
message: string;
|
||||||
|
deleted_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination wrapper
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user