diff --git a/Dockerfile b/Dockerfile index ee31379..036e379 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # 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} RUN npm run build diff --git a/docker-compose.yml b/docker-compose.yml index dd7035a..5a8aea5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,11 +8,10 @@ services: dockerfile: Dockerfile args: # 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. - NEXT_PUBLIC_API_URL: http://127.0.0.1:8765 + # Since this is a client-side var, it cannot be "localhost" if accessed from outside the server. + NEXT_PUBLIC_API_URL: https://your-backend-url.com restart: always ports: - # Maps host port 3001 to container port 3000 to avoid conflict - - "3001:3000" + - "127.0.0.1:3367:3000" environment: - NODE_ENV=production diff --git a/docs/apidev.md b/docs/apidev_part1.md similarity index 100% rename from docs/apidev.md rename to docs/apidev_part1.md diff --git a/docs/apidev_part2.md b/docs/apidev_part2.md new file mode 100644 index 0000000..d8fa271 --- /dev/null +++ b/docs/apidev_part2.md @@ -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 { + 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('/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(`/posts/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + + // Posts - 删除文章 (软删除) + deletePost: (id: string) => request(`/posts/${id}`, { + method: 'DELETE', + }), + + // Posts - 发布/取消发布 (便捷方法) + publishPost: (id: string) => request(`/posts/${id}`, { + method: 'PUT', + body: JSON.stringify({ published: true }), + }), + + unpublishPost: (id: string) => request(`/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); + + 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 && ( + {charCount}/8 +)} +``` + +--- + +### 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' && Saving...} +{saveStatus === 'saved' && Saved} +``` + +--- + +### 3.6 文章删除 (Delete Post Flow) + +**删除是破坏性操作,但极简设计不使用模态确认框。** + +* **API**: `DELETE /api/posts/{id}` +* **行为**: 软删除 (数据库标记 `deleted_at`) +* **权限**: 需要 Bearer Token + 文章所有权 + +**极简确认模式 (Inline Confirmation):** + +```typescript +// 第一次点击:文字变化 +// 第二次点击:执行删除 + +const [confirmDelete, setConfirmDelete] = useState(false); + + +``` + +**删除后行为:** + +| 场景 | 反馈 | +|-----|-----| +| 在列表页删除 | 该行淡出 (`opacity-0 transition-opacity`),然后移除 | +| 在详情页删除 | 显示 "Deleted",2秒后跳转首页 | + +--- + +### 3.7 草稿与发布状态管理 (Draft/Published Toggle) + +**文章有两种状态:草稿 (draft) 和已发布 (published)。** + +* **草稿**: `published: false` — 仅作者可见 +* **已发布**: `published: true` — 公开可见 + +**状态切换 UI:** + +```typescript +// 极简 Toggle:不用开关组件,用文字按钮 + +``` + +**列表页草稿标识:** + +```typescript +// 草稿文章标题后显示灰色 (Draft) 标记 +

+ {post.title} + {!post.published && ( + (Draft) + )} +

+``` + +--- + +## 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 ( + + + + ); +} +``` + +### 4.3 401 全局拦截 + +更新 `src/lib/api.ts` 中的 `request` 函数: + +```typescript +async function request(endpoint: string, options: RequestInit = {}): Promise { + // ... 原有代码 ... + + 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(endpoint: string, options: RequestInit = {}): Promise { + 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('/auth/login', { + method: 'POST', + body: JSON.stringify(data), + }), + + register: (data: RegisterRequest) => request('/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(`/posts?${query}`); + }, + + getPostById: (id: string) => request(`/posts/${id}`), + + // ===== Posts - Write ===== + createPost: (data: Partial) => request('/posts', { + method: 'POST', + body: JSON.stringify(data), + }), + + updatePost: (id: string, data: UpdatePostRequest) => request(`/posts/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + + deletePost: (id: string) => request(`/posts/${id}`, { + method: 'DELETE', + }), + + // ===== Posts - Convenience ===== + publishPost: (id: string) => request(`/posts/${id}`, { + method: 'PUT', + body: JSON.stringify({ published: true }), + }), + + unpublishPost: (id: string) => request(`/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 规范中的所有端点。 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 39aeecf..1aedfbe 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -6,13 +6,13 @@ import { Container } from "@/components/layout/Container"; import { Button } from "@/components/ui/Button"; import { Skeleton } from "@/components/ui/Skeleton"; import { Tag } from "@/components/ui/Tag"; +import { DeleteButton } from "@/components/DeleteButton"; import { api } from "@/lib/api"; import type { Post } from "@/types/api"; export default function AdminDashboard() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); - const [deleting, setDeleting] = useState(null); useEffect(() => { fetchPosts(); @@ -21,7 +21,7 @@ export default function AdminDashboard() { async function fetchPosts() { setLoading(true); try { - const response = await api.getPosts(1, 100); + const response = await api.getPosts(1, 50); setPosts(response.posts ?? []); } catch { // Handle error silently @@ -31,17 +31,8 @@ export default function AdminDashboard() { } async function handleDelete(id: string) { - if (!confirm("Are you sure you want to delete this post?")) return; - - setDeleting(id); - try { - await api.deletePost(id); - setPosts((prev) => prev.filter((p) => p.id !== id)); - } catch { - alert("Failed to delete post"); - } finally { - setDeleting(null); - } + await api.deletePost(id); + setPosts((prev) => prev.filter((p) => p.id !== id)); } return ( @@ -88,14 +79,7 @@ export default function AdminDashboard() { - + handleDelete(post.id)} /> ))} diff --git a/src/app/drafts/page.tsx b/src/app/drafts/page.tsx new file mode 100644 index 0000000..6dc3061 --- /dev/null +++ b/src/app/drafts/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [publishing, setPublishing] = useState(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 ( + +
+

My Drafts

+ + + +
+ + {loading && ( +
+ + + +
+ )} + + {!loading && posts.length === 0 && ( +

No drafts yet. All your posts are published.

+ )} + + {!loading && posts.length > 0 && ( +
+ {posts.map((post) => ( +
+
+
+ +

+ {post.title || "Untitled"} +

+ + (Draft) +
+

+ {post.content.slice(0, 100).replace(/[#*`]/g, "")}... +

+
+ +
+ + + + + handleDelete(post.id)} /> +
+
+ ))} +
+ )} +
+ ); +} + +export default function DraftsPage() { + return ( + + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index df75d7b..d0782e7 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,6 +2,7 @@ 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"; @@ -76,6 +77,13 @@ export default function LoginPage() { {loading ? "Signing in..." : "Sign In"} + +

+ Don't have an account?{" "} + + Register + +

); diff --git a/src/app/posts/[id]/edit/page.tsx b/src/app/posts/[id]/edit/page.tsx new file mode 100644 index 0000000..477f213 --- /dev/null +++ b/src/app/posts/[id]/edit/page.tsx @@ -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(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 = `![](${url})\n`; + setContent((prev) => prev + imageMarkdown); + hasChangesRef.current = true; + scheduleAutosave(); + }; + + const handleDelete = async () => { + await api.deletePost(id); + router.push("/"); + }; + + if (loading) { + return ( + + + + + ); + } + + if (forbidden) { + return ( + +

Not Your Post

+

You can only edit posts you created.

+ +
+ ); + } + + return ( + +
+ 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" + /> + +
+ + + + + + + +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ {content ? ( + + ) : ( +

Preview will appear here...

+ )} +
+
+
+
+ ); +} + +export default function EditPostPage({ params }: EditPostPageProps) { + const { id } = use(params); + + return ( + + + + ); +} diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index fc28b42..d6b069d 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -2,12 +2,16 @@ import { useState, useEffect, use } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import Image from "next/image"; import { Container } from "@/components/layout/Container"; import { PostContent } from "@/components/post/PostContent"; import { PostMeta } from "@/components/post/PostMeta"; import { Skeleton } from "@/components/ui/Skeleton"; 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 type { Post } from "@/types/api"; @@ -18,6 +22,7 @@ interface PostPageProps { export default function PostPage({ params }: PostPageProps) { const { id } = use(params); const router = useRouter(); + const { isAuthenticated } = useAuth(); const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -38,6 +43,11 @@ export default function PostPage({ params }: PostPageProps) { fetchPost(); }, [id]); + const handleDelete = async () => { + await api.deletePost(id); + router.push("/"); + }; + if (error) { router.push("/not-found"); return null; @@ -45,9 +55,20 @@ export default function PostPage({ params }: PostPageProps) { return ( - - Back to all posts - +
+ + Back to all posts + + + {isAuthenticated && post && ( +
+ + + + +
+ )} +
{loading && (
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..7adb29f --- /dev/null +++ b/src/app/register/page.tsx @@ -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(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 ( + +

Account Created

+

Redirecting to login...

+
+ ); + } + + return ( + +
+

Register

+ +
+ setEmail(e.target.value)} + placeholder="you@example.com" + required + error={error ? " " : undefined} + /> + +
+ +
+ 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" + }`} + /> + + {password.length}/{MIN_PASSWORD_LENGTH} + +
+
+ + {error &&

{error}

} + + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/src/components/DeleteButton.tsx b/src/components/DeleteButton.tsx new file mode 100644 index 0000000..80d412a --- /dev/null +++ b/src/components/DeleteButton.tsx @@ -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; + 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(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 ( + + ); +} diff --git a/src/components/SaveStatus.tsx b/src/components/SaveStatus.tsx new file mode 100644 index 0000000..b2db802 --- /dev/null +++ b/src/components/SaveStatus.tsx @@ -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 ( + + {status === "saving" && "Saving..."} + {status === "saved" && "Saved"} + {status === "error" && "Failed to save"} + + ); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index ee4d496..ccd8286 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,18 +1,18 @@ "use client"; -import { useState, useEffect } from "react"; +import { useSyncExternalStore } from "react"; import Link from "next/link"; import { Container } from "./Container"; import { TextLink } from "@/components/ui/TextLink"; import { useAuth } from "@/lib/hooks/useAuth"; +const subscribe = () => () => {}; +const getSnapshot = () => true; +const getServerSnapshot = () => false; + export function Header() { const { isAuthenticated, logout } = useAuth(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); return (
@@ -31,6 +31,7 @@ export function Header() { {isAuthenticated ? ( <> Dashboard + My Drafts