forked from carrydela/Rs_blog_front
first commit
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env.local
|
||||
.env
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Pass API URL during build time (Next.js inlines this)
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
CMD ["node", "server.js"]
|
||||
102
README.md
102
README.md
@@ -1,36 +1,98 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Minimalist AI Blog Frontend
|
||||
|
||||
## Getting Started
|
||||
## 项目简介
|
||||
|
||||
First, run the development server:
|
||||
基于 Next.js 16 + Rust 后端的极简主义博客系统前端。采用瑞士风格设计(黑白灰配色、Inter 字体),专注于内容展示。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: Next.js 16 (App Router)
|
||||
- **语言**: TypeScript
|
||||
- **样式**: Tailwind CSS v4 + @tailwindcss/typography
|
||||
- **渲染**: React Markdown + Rehype Highlight (代码高亮)
|
||||
- **部署**: Docker + Docker Compose
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎨 **极简设计**: 纯粹的内容阅读体验,无干扰元素
|
||||
- 📝 **Markdown 支持**: 完美支持 GFM 语法和代码高亮 (GitHub Light 主题)
|
||||
- 📱 **完全响应式**: 适配移动端和桌面端
|
||||
- 🔐 **后台管理**: 集成登录、文章发布、编辑和删除功能
|
||||
- ⚡ **高性能**: 基于 Next.js 静态生成与增量更新
|
||||
- 🐳 **Docker 部署**: 一键构建与启动
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 本地开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
访问 `http://localhost:3000` 查看效果。
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### 2. 构建生产版本
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## Docker 部署
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
本项目支持使用 Docker Compose 一键部署,无需手动配置 Node.js 环境。
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
### 部署步骤
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
1. **克隆仓库**
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd rs-blog
|
||||
```
|
||||
|
||||
## Deploy on Vercel
|
||||
2. **配置后端地址**
|
||||
打开 `docker-compose.yml` 文件,修改 `NEXT_PUBLIC_API_URL` 为你的服务器公网 IP 或域名:
|
||||
```yaml
|
||||
args:
|
||||
# 将此处修改为你的服务器 IP 或域名
|
||||
NEXT_PUBLIC_API_URL: http://your-server-ip:8765
|
||||
```
|
||||
> **注意**: 不要使用 `127.0.0.1` 或 `localhost`,因为这是构建在前端代码中的地址,用户浏览器需要能够直接访问该地址。
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
3. **启动服务**
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
4. **查看日志** (可选)
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
服务将在 `http://your-server-ip:3000` 启动。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量名 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `NEXT_PUBLIC_API_URL` | 后端 API 地址 | `http://127.0.0.1:8765` |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js 页面路由
|
||||
│ ├── admin/ # 后台管理页面
|
||||
│ ├── login/ # 登录页面
|
||||
│ └── posts/ # 文章详情页
|
||||
├── components/ # React 组件
|
||||
│ ├── layout/ # 布局组件 (Header, Footer)
|
||||
│ ├── post/ # 文章相关组件
|
||||
│ └── ui/ # 基础 UI 组件 (Button, Input)
|
||||
├── lib/ # 工具库 (API 客户端, Hooks)
|
||||
└── types/ # TypeScript 类型定义
|
||||
```
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
blog-frontend:
|
||||
container_name: blog-frontend
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# REPLACE THIS with your actual backend URL (e.g., https://api.yourdomain.com)
|
||||
# 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
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
450
docs/Rust Blog Backend API.openapi.json
Normal file
450
docs/Rust Blog Backend API.openapi.json
Normal file
@@ -0,0 +1,450 @@
|
||||
{
|
||||
"openapi": "3.0.1",
|
||||
"info": {
|
||||
"title": "Rust Blog Backend API",
|
||||
"description": "API collection for testing the Rust Blog Backend",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tags": [],
|
||||
"paths": {
|
||||
"/api/auth/register": {
|
||||
"post": {
|
||||
"summary": "Register - Short Password",
|
||||
"deprecated": false,
|
||||
"description": "Test validation: password too short (min 8 chars)",
|
||||
"tags": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"in": "header",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "application/json",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"email": "test2@example.com",
|
||||
"password": "short"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/api/auth/login": {
|
||||
"post": {
|
||||
"summary": "Login - Wrong Password",
|
||||
"deprecated": false,
|
||||
"description": "Test: wrong password returns 401",
|
||||
"tags": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"in": "header",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "application/json",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"email": "test@example.com",
|
||||
"password": "wrongpassword"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/api/posts": {
|
||||
"post": {
|
||||
"summary": "Create Post - Empty Title",
|
||||
"deprecated": false,
|
||||
"description": "Test validation: empty title returns 400",
|
||||
"tags": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"in": "header",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "application/json",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "Bearer {{token}}",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"published": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"content",
|
||||
"published"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"title": "",
|
||||
"content": "Content without title",
|
||||
"published": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
},
|
||||
"get": {
|
||||
"summary": "List Posts (Page 2)",
|
||||
"deprecated": false,
|
||||
"description": "List posts - second page with 5 items per page.",
|
||||
"tags": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "2",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "5",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/api/posts/post_id": {
|
||||
"get": {
|
||||
"summary": "Get Post by ID",
|
||||
"deprecated": false,
|
||||
"description": "Get a single post by ID. No authentication required.",
|
||||
"tags": [],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
},
|
||||
"put": {
|
||||
"summary": "Update Post (Publish)",
|
||||
"deprecated": false,
|
||||
"description": "Publish a draft post. Only updates the published field.",
|
||||
"tags": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"in": "header",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "application/json",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "Bearer {{token}}",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"published": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"published"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"published": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Delete Post",
|
||||
"deprecated": false,
|
||||
"description": "Soft delete a post. Requires authentication and ownership.",
|
||||
"tags": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "Bearer {{token}}",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/api/upload": {
|
||||
"post": {
|
||||
"summary": "Upload Image",
|
||||
"deprecated": false,
|
||||
"description": "Upload an image file. Requires authentication. Allowed types: jpg, jpeg, png, webp. Max size: 5MB.",
|
||||
"tags": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "",
|
||||
"required": true,
|
||||
"example": "Bearer {{token}}",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Select a JPG, JPEG, PNG, or WebP image (max 5MB)",
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/api/posts/non-existent-id": {
|
||||
"get": {
|
||||
"summary": "Get Post - Not Found",
|
||||
"deprecated": false,
|
||||
"description": "Test: getting non-existent post returns 404",
|
||||
"tags": [],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {},
|
||||
"responses": {},
|
||||
"securitySchemes": {}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://127.0.0.1:8765",
|
||||
"description": "测试环境"
|
||||
}
|
||||
],
|
||||
"security": []
|
||||
}
|
||||
243
docs/apidev.md
Normal file
243
docs/apidev.md
Normal file
@@ -0,0 +1,243 @@
|
||||
太棒了。既然前端视觉风格(极简 + Inter + 锐利图片)已经确认,接下来我们进入 **逻辑层**。
|
||||
|
||||
这是第二份文档:**《Rust Backend API 集成指南 (Backend Integration Spec)》**。
|
||||
|
||||
这份文档将指导你如何将那个“冷峻”的界面与 Rust 后端进行优雅对接。我们不仅关注数据获取,更关注**数据如何驱动极简交互**(例如:如何用数据加载状态代替复杂的 Loading 动画)。
|
||||
|
||||
---
|
||||
|
||||
# 🔌 后端对接集成文档 (API Integration Spec)
|
||||
|
||||
**后端服务**: Rust Blog Backend
|
||||
**Base URL**: `http://127.0.0.1:8765`
|
||||
**协议**: REST / JSON
|
||||
**认证**: Bearer Token
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据模型定义 (TypeScript Interfaces)
|
||||
|
||||
为了确保前端代码的健壮性,我们需要根据 OpenAPI 文档定义严格的 TypeScript 接口。建议存放在 `src/types/api.ts`。
|
||||
|
||||
```typescript
|
||||
// 基础响应包装(根据 Rust 后端习惯,通常可能直接返回数据或包裹在 data 字段)
|
||||
// 针对此 API 文档的直接映射:
|
||||
export interface ApiResponse<T> {
|
||||
[key: string]: any;
|
||||
// 注意:实际调试时请确认后端是否包裹了 { data: ... } 结构
|
||||
}
|
||||
|
||||
// 1. 认证模型 (Auth)
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string; // 假设后端返回 JWT 字段
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 文章模型 (Post)
|
||||
export interface Post {
|
||||
id: string; // 对应 Rust 的 UUID
|
||||
title: string; // 对应 OpenAPI schema
|
||||
content: string; // Markdown 原始内容
|
||||
published: boolean;
|
||||
created_at?: string; // ISO 8601 Date String
|
||||
cover_image?: string; // 文章封面图 URL (可选)
|
||||
}
|
||||
|
||||
// 3. 分页参数 (Pagination)
|
||||
export interface PageParams {
|
||||
page: number; // e.g. 1
|
||||
limit: number; // e.g. 10
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. API 客户端封装 (Service Layer)
|
||||
|
||||
为了保持组件的“极简”,不要在组件内部直接写 `fetch`。建议封装一个 `apiClient`。
|
||||
|
||||
**文件**: `src/lib/api.ts`
|
||||
|
||||
```typescript
|
||||
const BASE_URL = 'http://127.0.0.1:8765/api';
|
||||
|
||||
// 极简的 Fetch 封装,自动处理 Token 和 错误
|
||||
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// 极简错误处理:直接抛出状态码,由 UI 层决定是变红框还是显示线条
|
||||
if (!response.ok) {
|
||||
throw { status: response.status, message: response.statusText };
|
||||
}
|
||||
|
||||
// 处理 204 No Content
|
||||
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),
|
||||
}),
|
||||
|
||||
// Posts
|
||||
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}`),
|
||||
|
||||
createPost: (data: Partial<Post>) => request<Post>('/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Upload (特殊处理 Multipart)
|
||||
uploadImage: async (file: File) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${BASE_URL}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }, // 不设置 Content-Type,让浏览器自动设置 boundary
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
return response.json(); // 返回 { url: "..." }
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能模块集成策略
|
||||
|
||||
### 3.1 首页文章列表 (Infinite Scroll / Pagination)
|
||||
|
||||
**极简主义的数据加载不应有“旋转的菊花”**。
|
||||
|
||||
* **加载状态 (Loading)**: 使用**骨架屏 (Skeleton)**。
|
||||
* 在数据回来之前,渲染 3 个灰色的方块 (`bg-neutral-100`),位置与图片和标题完全重合。这能保持页面的“稳定感”,不会因为数据加载而发生布局跳动。
|
||||
|
||||
|
||||
* **API**: `GET /api/posts?page=1&limit=5`
|
||||
* **错误状态**: 如果 API 挂了,不要弹窗。在列表顶部显示一行极小的灰色文字:*"Could not retrieve latest transmission."*
|
||||
|
||||
### 3.2 文章详情与 Markdown 渲染
|
||||
|
||||
技术博客的核心是代码阅读体验。
|
||||
|
||||
* **API**: `GET /api/posts/{id}`
|
||||
* **Markdown 解析**: 推荐使用 `react-markdown`。
|
||||
* **代码高亮**: 使用 `rehype-highlight` 或 `prismjs`。
|
||||
* **样式定制**:
|
||||
* 为了符合极简风格,必须重写 `<pre>` 和 `<code>` 的样式。
|
||||
* **代码块背景**: `#fafafa` (极淡灰)。
|
||||
* **字体**: `JetBrains Mono`。
|
||||
* **边框**: 无边框,直角。
|
||||
|
||||
|
||||
|
||||
```typescript
|
||||
// Markdown 组件配置示例
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
// 覆盖默认图片样式,确保圆角为 0
|
||||
img: ({node, ...props}) => (
|
||||
<img {...props} className="w-full h-auto aspect-video object-cover my-8 border border-neutral-100" />
|
||||
),
|
||||
// 覆盖代码块
|
||||
code: ({node, inline, className, children, ...props}) => {
|
||||
return !inline ? (
|
||||
<pre className="bg-neutral-50 p-6 overflow-x-auto text-sm my-8">
|
||||
<code className={className} {...props}>{children}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<code className="bg-neutral-100 px-1 py-0.5 text-sm font-mono text-red-600" {...props}>{children}</code>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{post.content}
|
||||
</ReactMarkdown>
|
||||
|
||||
```
|
||||
|
||||
### 3.3 管理员发布文章 (Writer Mode)
|
||||
|
||||
这是一个关键的交互点。我们需要一个**所见即所得 (WYSIWYG) 但又极简**的编辑器。
|
||||
|
||||
* **布局**: 左侧 Markdown 输入框 (Monospace),右侧实时预览。
|
||||
* **图片上传集成**:
|
||||
1. 用户将截图直接拖入 Markdown 输入框。
|
||||
2. 触发 `onDrop` 事件 -> 调用 `api.uploadImage(file)`。
|
||||
3. 上传中:光标处显示 `![Uploading...]` (灰色)。
|
||||
4. 上传完成:替换为 ``。
|
||||
|
||||
|
||||
* **API 调用**:
|
||||
* 点击 "Publish" -> `POST /api/posts` -> 成功后跳转至详情页。
|
||||
* 验证错误 (400 Empty Title) -> 标题输入框下方的黑线变红 (`border-red-600`),不显示文字提示,或者仅显示极小的 "Required"。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4. 状态反馈映射表 (UI Feedback)
|
||||
|
||||
在极简设计中,我们利用**微交互**来反馈后端状态,而非模态框。
|
||||
|
||||
| 后端状态 (Status) | 场景 (Context) | 前端极简反馈 (Visual Feedback) |
|
||||
| --- | --- | --- |
|
||||
| **200 OK** | 登录成功 | 按钮文字从 "Enter" 变为 "Welcome",淡出并跳转。 |
|
||||
| **200 OK** | 文章发布成功 | 页面右上角显示 "Saved." (纯文本,3秒消失)。 |
|
||||
| **401 Unauthorized** | Token 过期 | 自动静默跳转至 `/login`,无提示。 |
|
||||
| **400 Bad Request** | 密码太短 | 密码框下边框变红,同时有一个极轻微的左右震动动画 (Shake)。 |
|
||||
| **404 Not Found** | 文章 ID 错误 | 页面中心显示巨大的 "404",配文 "Signal Lost"。 |
|
||||
| **500 Server Error** | 服务器崩溃 | 页面顶部出现 2px 高的红色静止线条。 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 开发路线图 (Roadmap)
|
||||
|
||||
建议按以下顺序开发,以最快速度跑通 MVP:
|
||||
|
||||
1. **Step 1: API Client**: 搭建 `api.ts`,跑通 `fetch` 封装。
|
||||
2. **Step 2: Read-Only**: 完成首页列表 (`GET /posts`) 和详情页 (`GET /posts/:id`)。此时数据可以是 Mock 的。
|
||||
3. **Step 3: Markdown**: 调优 `react-markdown` 样式,确保代码块和图片符合极简规范。
|
||||
4. **Step 4: Auth**: 完成极简登录页 (`POST /login`)。
|
||||
5. **Step 5: Editor**: 完成发布功能和图片上传 (`POST /upload` + Drag & Drop)。
|
||||
|
||||
准备好开始编码了吗?建议从配置 `api.ts` 和 `tailwind.config.js` 开始。
|
||||
217
docs/design.md
Normal file
217
docs/design.md
Normal file
@@ -0,0 +1,217 @@
|
||||
这是为您定制的 **《极简主义博客前端设计规范 v1.0 (The Minimalist Tech Spec)》**。
|
||||
|
||||
这份文档不仅是开发指南,更是视觉准则。它将指导您如何使用 Tailwind CSS 构建一个冷峻、理性且充满科技感的 AI 博客。
|
||||
|
||||
---
|
||||
|
||||
# 📐 前端设计规范 (Frontend Design System)
|
||||
|
||||
**项目名称**: Minimalist AI Blog
|
||||
**核心理念**: 内容即界面 (Content is Interface)
|
||||
**视觉风格**: 瑞士国际主义 (Swiss Style) + 科技极简 (Tech Utilitarianism)
|
||||
|
||||
---
|
||||
|
||||
## 1. 基础系统 (Foundations)
|
||||
|
||||
### 1.1 色彩体系 (Color Palette)
|
||||
|
||||
我们摒弃所有非功能性的色彩。界面只存在黑、白、灰。唯一的色彩将来自**文章配图**和**代码高亮**。
|
||||
|
||||
| 语义名称 | Tailwind 类名 | Hex 值 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Canvas** | `bg-white` | `#ffffff` | 页面背景,卡片背景 |
|
||||
| **Ink** | `text-black` | `#000000` | 标题,主要按钮,强强调 |
|
||||
| **Graphite** | `text-neutral-800` | `#262626` | 正文阅读色 (纯黑阅读易疲劳) |
|
||||
| **Ash** | `text-neutral-500` | `#737373` | 辅助信息,日期,元数据 |
|
||||
| **Vapor** | `bg-neutral-50` | `#fafafa` | 极淡背景 (代码块,引用) |
|
||||
| **Line** | `border-neutral-200` | `#e5e5e5` | 结构分割线 (极少使用) |
|
||||
| **Error** | `text-red-600` | `#dc2626` | 错误状态 (仅用于表单反馈) |
|
||||
|
||||
### 1.2 排版系统 (Typography)
|
||||
|
||||
全站强制使用 **Inter**。通过字重 (Weight) 和字间距 (Tracking) 的极端对比来建立层级,而非依赖颜色。
|
||||
|
||||
* **字体栈**: `font-sans` -> `Inter, system-ui, sans-serif`
|
||||
* **字重策略**:
|
||||
* **标题**: `font-bold` (700) 或 `font-medium` (500)
|
||||
* **正文**: `font-normal` (400)
|
||||
* **元数据**: `font-medium` (500) + 全大写
|
||||
|
||||
|
||||
|
||||
| 样式名称 | 大小 (Size) | 行高 (Leading) | 字间距 (Tracking) | Tailwind 组合 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Display** | 72px (6xl) | 1.1 | -0.04em | `text-6xl font-bold tracking-tighter leading-tight` |
|
||||
| **Heading 1** | 48px (5xl) | 1.2 | -0.03em | `text-5xl font-bold tracking-tight leading-tight` |
|
||||
| **Heading 2** | 30px (3xl) | 1.3 | -0.02em | `text-3xl font-semibold tracking-tight` |
|
||||
| **Body** | 18px (lg) | 1.8 | -0.01em | `text-lg font-normal leading-relaxed text-neutral-800` |
|
||||
| **Caption** | 12px (xs) | 1.5 | 0.05em | `text-xs font-medium tracking-wide uppercase text-neutral-500` |
|
||||
|
||||
### 1.3 网格与间距 (Grid & Spacing)
|
||||
|
||||
* **容器**: 最大宽度 `max-w-screen-xl` (1280px)。
|
||||
* **网格**: 12 列网格,列间距 `gap-8` (32px)。
|
||||
* **呼吸感**: 垂直方向的留白必须奢侈。
|
||||
* **Section Spacing**: `py-24` (96px) 或 `py-32` (128px)。
|
||||
* **Component Spacing**: `mb-12` (48px)。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2. 组件规范 (Components)
|
||||
|
||||
### 2.1 图片处理 (Imagery)
|
||||
|
||||
图片是极简页面中唯一的“重”元素。
|
||||
|
||||
* **形状**: **严格直角** (No Border Radius)。
|
||||
* **比例**: 统一使用 `aspect-video` (16:9) 或 `aspect-[3/2]`。
|
||||
* **边框**: 无边框。图片直接与背景接触。
|
||||
* **蒙版**: 默认原图。Hover 时可叠加 5% 的黑色遮罩 (`bg-black/5`) 以增加质感。
|
||||
|
||||
```html
|
||||
<figure class="relative w-full aspect-video overflow-hidden bg-neutral-100">
|
||||
<img src="..." class="object-cover w-full h-full" alt="Tech" />
|
||||
</figure>
|
||||
|
||||
```
|
||||
|
||||
### 2.2 按钮与交互 (Buttons & Interactions)
|
||||
|
||||
拒绝实体色块按钮。使用 **“幽灵按钮” (Ghost Button)** 或 **“文本链接”**。
|
||||
|
||||
* **Primary Button**: 黑框,白底,黑字。Hover 变黑底白字。
|
||||
* `border border-black px-6 py-3 text-sm font-medium hover:bg-black hover:text-white transition-colors duration-300`
|
||||
|
||||
|
||||
* **Text Link**: 纯文字,底部有一条 1px 的黑线。Hover 时线消失或变粗。
|
||||
* `border-b border-black pb-0.5 hover:border-transparent transition-colors`
|
||||
|
||||
|
||||
* **Input Field**: 无背景,仅保留底部边框。
|
||||
* `w-full border-b border-neutral-200 py-3 bg-transparent focus:border-black focus:outline-none transition-colors`
|
||||
|
||||
|
||||
|
||||
### 2.3 代码块 (Code Blocks)
|
||||
|
||||
作为技术博客的核心,代码块必须干净。
|
||||
|
||||
* **Theme**: 推荐 GitHub Light 或类似的高对比度浅色主题。
|
||||
* **容器**: 浅灰色背景 `#fafafa`,无边框,直角。
|
||||
* **字体**: JetBrains Mono 或 Fira Code。
|
||||
|
||||
---
|
||||
|
||||
## 3. Tailwind 配置文件 (Configuration)
|
||||
|
||||
请将以下代码直接覆盖您的 `tailwind.config.js`。
|
||||
|
||||
```javascript
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
// 1. 字体系统:强制 Inter
|
||||
fontFamily: {
|
||||
sans: ['Inter', ...defaultTheme.fontFamily.sans],
|
||||
mono: ['JetBrains Mono', 'Fira Code', ...defaultTheme.fontFamily.mono],
|
||||
},
|
||||
// 2. 扩展间距:为了极致的留白
|
||||
spacing: {
|
||||
'18': '4.5rem', // 72px
|
||||
'24': '6rem', // 96px
|
||||
'32': '8rem', // 128px
|
||||
},
|
||||
// 3. 极简色板
|
||||
colors: {
|
||||
neutral: {
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
200: '#e5e5e5', // 边框
|
||||
300: '#d4d4d4',
|
||||
400: '#a3a3a3',
|
||||
500: '#737373', // 辅助文字
|
||||
600: '#525252',
|
||||
700: '#404040',
|
||||
800: '#262626', // 正文
|
||||
900: '#171717',
|
||||
950: '#0a0a0a', // 接近纯黑
|
||||
}
|
||||
},
|
||||
// 4. 排版微调
|
||||
letterSpacing: {
|
||||
tighter: '-0.04em',
|
||||
tight: '-0.02em',
|
||||
normal: '-0.01em',
|
||||
wide: '0.02em',
|
||||
widest: '0.08em', // 用于全大写标签
|
||||
},
|
||||
lineHeight: {
|
||||
relaxed: '1.8', // 黄金阅读行高
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'), // 必须安装: npm install -D @tailwindcss/typography
|
||||
],
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 典型页面布局示例 (Layout Mockup)
|
||||
|
||||
### 首页文章列表 (The "Grid" List)
|
||||
|
||||
这是一个结合了 **Inter 字体** 和 **锐利图片** 的列表项设计。
|
||||
|
||||
```html
|
||||
<article class="group grid grid-cols-12 gap-6 md:gap-12 mb-24 cursor-pointer">
|
||||
|
||||
<div class="col-span-12 md:col-span-5 relative">
|
||||
<div class="aspect-[3/2] w-full overflow-hidden bg-neutral-100">
|
||||
<img
|
||||
src="/api/placeholder/800/600"
|
||||
alt="AI Neural Network"
|
||||
class="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105 group-hover:opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-7 flex flex-col justify-center">
|
||||
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<span class="text-xs font-bold tracking-widest text-black uppercase border border-black px-2 py-1">
|
||||
Algorithm
|
||||
</span>
|
||||
<span class="text-xs font-medium tracking-wide text-neutral-400 uppercase">
|
||||
Oct 24, 2026
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl md:text-5xl font-bold tracking-tighter text-black mb-6 leading-[1.1] group-hover:underline decoration-1 underline-offset-8">
|
||||
Optimizing Transformer Attention Mechanisms
|
||||
</h2>
|
||||
|
||||
<p class="text-lg text-neutral-600 leading-relaxed mb-8 md:w-10/12 line-clamp-3">
|
||||
Deep dive into the architecture of FlashAttention-2. We explore how memory hierarchy awareness can significantly speed up training for large language models.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center text-sm font-semibold text-black group-hover:translate-x-2 transition-transform duration-300">
|
||||
Read Analysis <span class="ml-2">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
```
|
||||
|
||||
**下一步**:如果您确认这份风格文档(尤其是对字体 Inter、图片直角处理、黑白灰配色)满意,我将为您生成第二部分:**前后端对接集成文档**。
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1689
package-lock.json
generated
1689
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -9,18 +9,26 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// postcss.config.mjs
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
|
||||
132
src/app/admin/editor/[id]/page.tsx
Normal file
132
src/app/admin/editor/[id]/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, 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 { api } from "@/lib/api";
|
||||
|
||||
interface EditPostPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function EditPostEditor({ params }: EditPostPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [published, setPublished] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPost() {
|
||||
try {
|
||||
const post = await api.getPost(id);
|
||||
setTitle(post.title);
|
||||
setContent(post.content);
|
||||
setPublished(post.published);
|
||||
} catch {
|
||||
router.push("/admin");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchPost();
|
||||
}, [id, router]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updatePost(id, {
|
||||
title,
|
||||
content,
|
||||
published,
|
||||
});
|
||||
setSaved(true);
|
||||
} catch {
|
||||
alert("Failed to save post");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [id, title, content, published]);
|
||||
|
||||
const handleImageUpload = (url: string) => {
|
||||
const imageMarkdown = `\n`;
|
||||
setContent((prev) => prev + imageMarkdown);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (saved) {
|
||||
const timer = setTimeout(() => setSaved(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saved]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<Skeleton className="h-12 w-1/2 mb-8" />
|
||||
<Skeleton className="h-[60vh]" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(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">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={published}
|
||||
onChange={(e) => setPublished(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm tracking-wide">Publish</span>
|
||||
</label>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving || !title.trim()}>
|
||||
{saving ? "Saving..." : saved ? "Saved." : "Save"}
|
||||
</Button>
|
||||
</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={setContent} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
102
src/app/admin/editor/page.tsx
Normal file
102
src/app/admin/editor/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { MarkdownEditor } from "@/components/editor/MarkdownEditor";
|
||||
import { ImageUpload } from "@/components/editor/ImageUpload";
|
||||
import { PostContent } from "@/components/post/PostContent";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export default function NewPostEditor() {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [published, setPublished] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const post = await api.createPost({
|
||||
title,
|
||||
content,
|
||||
published,
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
router.push(`/admin/editor/${post.id}`);
|
||||
}, 1000);
|
||||
} catch {
|
||||
alert("Failed to save post");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [title, content, published, router]);
|
||||
|
||||
const handleImageUpload = (url: string) => {
|
||||
const imageMarkdown = `\n`;
|
||||
setContent((prev) => prev + imageMarkdown);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (saved) {
|
||||
const timer = setTimeout(() => setSaved(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saved]);
|
||||
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(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">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={published}
|
||||
onChange={(e) => setPublished(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm tracking-wide">Publish</span>
|
||||
</label>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving || !title.trim()}>
|
||||
{saving ? "Saving..." : saved ? "Saved." : "Save"}
|
||||
</Button>
|
||||
</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={setContent} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
12
src/app/admin/layout.tsx
Normal file
12
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
return <ProtectedRoute>{children}</ProtectedRoute>;
|
||||
}
|
||||
106
src/app/admin/page.tsx
Normal file
106
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"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 { Tag } from "@/components/ui/Tag";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Post } from "@/types/api";
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, []);
|
||||
|
||||
async function fetchPosts() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getPosts(1, 100);
|
||||
setPosts(response.posts ?? []);
|
||||
} catch {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-24">
|
||||
<div className="flex items-center justify-between mb-12">
|
||||
<h1 className="text-5xl font-bold tracking-tighter">Dashboard</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 posts yet. Create your first one.</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"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-xl font-bold tracking-tight truncate">
|
||||
{post.title || "Untitled"}
|
||||
</h2>
|
||||
<Tag>{post.published ? "Published" : "Draft"}</Tag>
|
||||
</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">
|
||||
<Link href={`/admin/editor/${post.id}`}>
|
||||
<Button size="sm">Edit</Button>
|
||||
</Link>
|
||||
<Button
|
||||
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>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,86 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
|
||||
--spacing-18: 4.5rem;
|
||||
--spacing-24: 6rem;
|
||||
--spacing-32: 8rem;
|
||||
|
||||
--tracking-tighter: -0.04em;
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: -0.01em;
|
||||
--tracking-wide: 0.02em;
|
||||
--tracking-widest: 0.08em;
|
||||
|
||||
--leading-relaxed: 1.8;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
/* Syntax highlighting for code blocks */
|
||||
.hljs {
|
||||
background: #fafafa !important;
|
||||
padding: 1.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-built_in {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-attr {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-literal {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hljs-type,
|
||||
.hljs-class {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Prose customizations */
|
||||
.prose pre {
|
||||
background-color: #fafafa;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import "highlight.js/styles/github.css";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "AI Blog",
|
||||
description: "Minimalist blog powered by AI",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,9 +31,13 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans font-medium antialiased bg-white text-black min-h-screen flex flex-col`}
|
||||
>
|
||||
{children}
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
82
src/app/login/page.tsx
Normal file
82
src/app/login/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAuth } from "@/lib/hooks/useAuth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
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 handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login({ email, password });
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
router.push("/admin");
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login 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">Welcome</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-24">
|
||||
<div className="max-w-md mx-auto">
|
||||
<h1 className="text-5xl font-bold tracking-tighter mb-12">Login</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}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
error={error ? " " : undefined}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
21
src/app/not-found.tsx
Normal file
21
src/app/not-found.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<Container className="py-24 min-h-[60vh] flex flex-col items-center justify-center text-center">
|
||||
<h1 className="text-[12rem] md:text-[20rem] font-bold tracking-tighter leading-none">
|
||||
404
|
||||
</h1>
|
||||
<p className="text-2xl md:text-3xl tracking-tight mt-4">
|
||||
Signal Lost
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-12 text-sm font-bold tracking-widest uppercase border-b border-black pb-0.5 hover:border-transparent transition-colors"
|
||||
>
|
||||
Return Home
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
135
src/app/page.tsx
135
src/app/page.tsx
@@ -1,65 +1,86 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { PostCard } from "@/components/post/PostCard";
|
||||
import { PostCardSkeleton } from "@/components/post/PostCardSkeleton";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Post } from "@/types/api";
|
||||
|
||||
export default function HomePage() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const limit = 10;
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPosts() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.getPosts(page, limit);
|
||||
setPosts(response.posts ?? []);
|
||||
setTotal(response.total ?? 0);
|
||||
} catch {
|
||||
setError("Could not retrieve latest transmission.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchPosts();
|
||||
}, [page]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
<Container className="py-24">
|
||||
<h1 className="text-6xl md:text-8xl font-bold tracking-tighter mb-24">
|
||||
Latest
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
|
||||
{error && (
|
||||
<p className="text-neutral-400 text-lg">{error}</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<>
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && posts?.length === 0 && (
|
||||
<p className="text-neutral-400 text-lg">No posts yet.</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && posts?.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 mt-24">
|
||||
<Button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm tracking-widest uppercase text-neutral-500">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/app/posts/[id]/page.tsx
Normal file
92
src/app/posts/[id]/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { api } from "@/lib/api";
|
||||
import type { Post } from "@/types/api";
|
||||
|
||||
interface PostPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function PostPage({ params }: PostPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPost() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getPost(id);
|
||||
setPost(data);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchPost();
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
router.push("/not-found");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-24">
|
||||
<TextLink href="/" className="text-sm">
|
||||
Back to all posts
|
||||
</TextLink>
|
||||
|
||||
{loading && (
|
||||
<div className="mt-12">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-16 w-full mt-4" />
|
||||
<Skeleton className="h-16 w-3/4 mt-2" />
|
||||
<Skeleton className="aspect-video w-full mt-12" />
|
||||
<Skeleton className="h-4 w-full mt-12" />
|
||||
<Skeleton className="h-4 w-full mt-2" />
|
||||
<Skeleton className="h-4 w-2/3 mt-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && post && (
|
||||
<article className="mt-12">
|
||||
<PostMeta date={post.created_at} />
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold tracking-tighter mt-4 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{post.cover_image && (
|
||||
<div className="aspect-video overflow-hidden mt-12 bg-neutral-100">
|
||||
<Image
|
||||
src={post.cover_image}
|
||||
alt={post.title}
|
||||
width={1200}
|
||||
height={675}
|
||||
className="w-full h-full object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12 max-w-3xl">
|
||||
<PostContent content={post.content} />
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
35
src/components/auth/ProtectedRoute.tsx
Normal file
35
src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/hooks/useAuth";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isAuthenticated, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container className="py-24 min-h-[60vh] flex items-center justify-center">
|
||||
<p className="text-neutral-400">Loading...</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
119
src/components/editor/ImageUpload.tsx
Normal file
119
src/components/editor/ImageUpload.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, type DragEvent, type ChangeEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface ImageUploadProps {
|
||||
onUpload: (url: string) => void;
|
||||
}
|
||||
|
||||
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
export function ImageUpload({ onUpload }: ImageUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
return "Only JPG, PNG, and WebP files are allowed.";
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return "File size must be under 5MB.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
setTimeout(() => setError(null), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const response = await api.uploadImage(file);
|
||||
onUpload(response.url);
|
||||
} catch {
|
||||
setError("Upload failed. Please try again.");
|
||||
setTimeout(() => setError(null), 3000);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={clsx(
|
||||
"border-2 border-dashed p-8 text-center cursor-pointer transition-colors",
|
||||
isDragging && "border-black bg-neutral-50",
|
||||
error && "border-red-500 animate-shake",
|
||||
!isDragging && !error && "border-neutral-300 hover:border-neutral-400"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES.join(",")}
|
||||
onChange={handleChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploading ? (
|
||||
<p className="text-sm text-neutral-500">Uploading...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">
|
||||
Drop an image here or click to upload
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
JPG, PNG, WebP up to 5MB
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/editor/MarkdownEditor.tsx
Normal file
28
src/components/editor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { type ChangeEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownEditor({ value, onChange, className }: MarkdownEditorProps) {
|
||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder="Write your content in Markdown..."
|
||||
className={clsx(
|
||||
"w-full h-full resize-none bg-transparent font-mono text-sm leading-relaxed focus:outline-none placeholder:text-neutral-400",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
src/components/layout/Container.tsx
Normal file
15
src/components/layout/Container.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface ContainerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Container({ children, className }: ContainerProps) {
|
||||
return (
|
||||
<div className={clsx("max-w-screen-xl mx-auto px-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/components/layout/Footer.tsx
Normal file
13
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Container } from "./Container";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="py-12 border-t border-neutral-100 mt-24">
|
||||
<Container>
|
||||
<p className="text-sm text-neutral-400 tracking-wide">
|
||||
{new Date().getFullYear()}
|
||||
</p>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
51
src/components/layout/Header.tsx
Normal file
51
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Container } from "./Container";
|
||||
import { TextLink } from "@/components/ui/TextLink";
|
||||
import { useAuth } from "@/lib/hooks/useAuth";
|
||||
|
||||
export function Header() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="py-8 border-b border-neutral-100">
|
||||
<Container>
|
||||
<nav className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold tracking-tighter hover:opacity-70 transition-opacity"
|
||||
>
|
||||
AI Blog
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
{mounted && (
|
||||
<>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<TextLink href="/admin">Dashboard</TextLink>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm tracking-wide uppercase text-neutral-500 hover:text-black transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<TextLink href="/login">Login</TextLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</Container>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
58
src/components/post/PostCard.tsx
Normal file
58
src/components/post/PostCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { PostMeta } from "./PostMeta";
|
||||
import type { Post } from "@/types/api";
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
export function PostCard({ post }: PostCardProps) {
|
||||
return (
|
||||
<article className="grid grid-cols-12 gap-6 md:gap-12 mb-24">
|
||||
{post.cover_image && (
|
||||
<div className="col-span-12 md:col-span-5">
|
||||
<Link href={`/posts/${post.id}`}>
|
||||
<div className="aspect-[3/2] overflow-hidden bg-neutral-100">
|
||||
<Image
|
||||
src={post.cover_image}
|
||||
alt={post.title}
|
||||
width={600}
|
||||
height={400}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
post.cover_image
|
||||
? "col-span-12 md:col-span-7 flex flex-col justify-center"
|
||||
: "col-span-12 flex flex-col justify-center"
|
||||
}
|
||||
>
|
||||
<PostMeta date={post.created_at} />
|
||||
|
||||
<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">
|
||||
{post.title}
|
||||
</h2>
|
||||
</Link>
|
||||
|
||||
<p className="text-neutral-600 mt-6 leading-relaxed line-clamp-3">
|
||||
{post.content.slice(0, 200).replace(/[#*`]/g, "")}
|
||||
{post.content.length > 200 ? "..." : ""}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`/posts/${post.id}`}
|
||||
className="mt-6 text-sm font-bold tracking-widest uppercase border-b border-black pb-0.5 hover:border-transparent transition-colors self-start"
|
||||
>
|
||||
Read More
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
21
src/components/post/PostCardSkeleton.tsx
Normal file
21
src/components/post/PostCardSkeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
|
||||
export function PostCardSkeleton() {
|
||||
return (
|
||||
<article className="grid grid-cols-12 gap-6 md:gap-12 mb-24">
|
||||
<div className="col-span-12 md:col-span-5">
|
||||
<Skeleton className="aspect-[3/2]" />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 md:col-span-7 flex flex-col justify-center">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-12 w-full mt-4" />
|
||||
<Skeleton className="h-12 w-3/4 mt-2" />
|
||||
<Skeleton className="h-4 w-full mt-6" />
|
||||
<Skeleton className="h-4 w-full mt-2" />
|
||||
<Skeleton className="h-4 w-2/3 mt-2" />
|
||||
<Skeleton className="h-4 w-20 mt-6" />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
52
src/components/post/PostContent.tsx
Normal file
52
src/components/post/PostContent.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface PostContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function PostContent({ content }: PostContentProps) {
|
||||
return (
|
||||
<div className="prose prose-lg max-w-none prose-headings:tracking-tight prose-headings:font-bold prose-p:leading-relaxed prose-p:text-neutral-700 prose-a:border-b prose-a:border-black prose-a:no-underline hover:prose-a:border-transparent prose-img:my-8 prose-pre:bg-[#f6f8fa] prose-pre:p-6 prose-code:font-mono prose-code:text-base">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
img: ({ src, alt }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
className="w-full aspect-video object-cover my-8"
|
||||
/>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-[#f6f8fa] p-6 font-mono text-base overflow-x-auto text-black rounded-none border border-neutral-200">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="bg-neutral-100 px-1.5 py-0.5 font-mono text-base text-black" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/post/PostMeta.tsx
Normal file
21
src/components/post/PostMeta.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
interface PostMetaProps {
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export function PostMeta({ date }: PostMetaProps) {
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "";
|
||||
const d = new Date(dateString);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<time className="text-xs font-bold tracking-widest uppercase text-neutral-400">
|
||||
{formatDate(date)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
42
src/components/ui/Button.tsx
Normal file
42
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: "ghost" | "solid";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "ghost",
|
||||
size = "md",
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"font-medium tracking-wide uppercase transition-all duration-200",
|
||||
{
|
||||
"border border-black hover:bg-black hover:text-white": variant === "ghost",
|
||||
"bg-black text-white hover:bg-neutral-800": variant === "solid",
|
||||
},
|
||||
{
|
||||
"px-4 py-2 text-xs": size === "sm",
|
||||
"px-6 py-3 text-sm": size === "md",
|
||||
"px-8 py-4 text-sm": size === "lg",
|
||||
},
|
||||
{
|
||||
"opacity-50 cursor-not-allowed": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
37
src/components/ui/Input.tsx
Normal file
37
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type InputHTMLAttributes, forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, className, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-xs font-bold tracking-widest uppercase text-neutral-500 mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
"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"
|
||||
: "border-neutral-200 focus:border-black",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
13
src/components/ui/Skeleton.tsx
Normal file
13
src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx("animate-pulse bg-neutral-200", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
src/components/ui/Tag.tsx
Normal file
20
src/components/ui/Tag.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface TagProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tag({ children, className }: TagProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-xs font-bold tracking-widest uppercase border border-black px-2 py-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/TextLink.tsx
Normal file
33
src/components/ui/TextLink.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface TextLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export function TextLink({ href, children, className, external }: TextLinkProps) {
|
||||
const baseStyles = "border-b border-black pb-0.5 hover:border-transparent transition-colors";
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={clsx(baseStyles, className)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={clsx(baseStyles, className)}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
177
src/lib/api.ts
Normal file
177
src/lib/api.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
AuthResponse,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
Post,
|
||||
PostsResponse,
|
||||
UploadResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8765";
|
||||
|
||||
class ApiClient {
|
||||
private static instance: ApiClient;
|
||||
private token: string | null = null;
|
||||
|
||||
private constructor() {
|
||||
if (typeof window !== "undefined") {
|
||||
this.token = localStorage.getItem("auth_token");
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(): ApiClient {
|
||||
if (!ApiClient.instance) {
|
||||
ApiClient.instance = new ApiClient();
|
||||
}
|
||||
return ApiClient.instance;
|
||||
}
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
if (typeof window !== "undefined") {
|
||||
if (token) {
|
||||
localStorage.setItem("auth_token", token);
|
||||
} else {
|
||||
localStorage.removeItem("auth_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
(headers as Record<string, string>)["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
this.setToken(null);
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: "Request failed" }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async login(data: LoginRequest): Promise<AuthResponse> {
|
||||
const response = await this.request<AuthResponse>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
this.setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
async register(data: RegisterRequest): Promise<AuthResponse> {
|
||||
const response = await this.request<AuthResponse>("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
this.setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
this.setToken(null);
|
||||
}
|
||||
|
||||
async getPosts(page = 1, limit = 10): Promise<PostsResponse> {
|
||||
const response = await this.request<any>(`/api/posts?page=${page}&limit=${limit}`);
|
||||
|
||||
// Handle response with 'data' field (backend format)
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
return {
|
||||
posts: response.data,
|
||||
total: response.total || response.data.length,
|
||||
page: response.page || page,
|
||||
limit: response.limit || limit,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle array response
|
||||
if (Array.isArray(response)) {
|
||||
return {
|
||||
posts: response,
|
||||
total: response.length,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getPost(id: string): Promise<Post> {
|
||||
return this.request<Post>(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
async getMyPosts(): Promise<Post[]> {
|
||||
return this.request<Post[]>("/api/posts/me");
|
||||
}
|
||||
|
||||
async createPost(data: Partial<Post>): Promise<Post> {
|
||||
return this.request<Post>("/api/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updatePost(id: string, data: Partial<Post>): Promise<Post> {
|
||||
return this.request<Post>(`/api/posts/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deletePost(id: string): Promise<void> {
|
||||
await this.request<void>(`/api/posts/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async uploadImage(file: File): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const headers: HeadersInit = {};
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/upload`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const api = ApiClient.getInstance();
|
||||
76
src/lib/auth-context.tsx
Normal file
76
src/lib/auth-context.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api } from "./api";
|
||||
import type { User, LoginRequest, RegisterRequest } from "@/types/api";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (data: LoginRequest) => Promise<void>;
|
||||
register: (data: RegisterRequest) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = api.getToken();
|
||||
if (token) {
|
||||
// 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 response = await api.login(data);
|
||||
setUser(response.user);
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: RegisterRequest) => {
|
||||
const response = await api.register(data);
|
||||
setUser(response.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
api.logout();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthenticated: !!user || !!api.getToken(),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
1
src/lib/hooks/useAuth.ts
Normal file
1
src/lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useAuth } from "../auth-context";
|
||||
49
src/types/api.ts
Normal file
49
src/types/api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
published: boolean;
|
||||
created_at?: string;
|
||||
cover_image?: string;
|
||||
}
|
||||
|
||||
export interface PostsResponse {
|
||||
posts: Post[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PageParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
Reference in New Issue
Block a user