Compare commits
72 Commits
b2bff53beb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e23f08eea | |||
| 88a017d6da | |||
| a718a5487a | |||
| a04227016e | |||
| 12724bea7f | |||
| 720ca56eb3 | |||
| 8c01303c6c | |||
| 5e2e18fce6 | |||
| 33053b4a92 | |||
| 1c518b44cc | |||
| cd80375cc5 | |||
| c23e822cd6 | |||
| 375d12ab0f | |||
| 83bdc924b9 | |||
| c75a67c0d9 | |||
| 0b9963bb29 | |||
| b48ed4d903 | |||
| b9d09a16ec | |||
| 8c43f5fa73 | |||
| 3ea57ba023 | |||
| a932178509 | |||
| 2c76d1380f | |||
| 58b7f592fe | |||
| a2e8ddebca | |||
| 13ec36aa8f | |||
| db8d8c429d | |||
| 8dc2473a1c | |||
| 616b1ad389 | |||
| 0ef987932f | |||
| 004548c9df | |||
| 941633bdb4 | |||
| abaa16a0f9 | |||
| f64b9bb469 | |||
| f2afe4f7ee | |||
| dc938fdb01 | |||
| d7c84ea0ce | |||
| 4d30605872 | |||
| fbc12f97db | |||
| 5d62fd89b9 | |||
| 60d8ad8e8a | |||
| ddc9e613e2 | |||
| 93688a0e4e | |||
| e0822528a7 | |||
| 88bcf06e35 | |||
| 7ac8263b6b | |||
| d3a7d03be7 | |||
| 59529519e3 | |||
| 2ca6a1ec42 | |||
| 1e3b9faa8b | |||
| f3e31106d0 | |||
| 700a446e77 | |||
| 204bcff75c | |||
| 35b76b70c9 | |||
| 05480cac6b | |||
| 5c103c4880 | |||
| 8a174fbed1 | |||
| 70b48d1892 | |||
| 9d4607c7cd | |||
| 695577d53a | |||
| 4ca1fb5ac9 | |||
| e4d7bc1a3a | |||
| 71915f415f | |||
| efc87cdbaf | |||
| eb21556797 | |||
| b9a03cb167 | |||
| 4745e2b060 | |||
| d7ea4e52cc | |||
| e233f0d8bc | |||
| 41944f0828 | |||
| da16cf0f04 | |||
| c8a78aff5d | |||
| 97f5d8bad1 |
80
.gitea/workflows/deploy.yml
Normal file
80
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Deploy to K3s
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: localhost:5000/tiny-ci-runner:latest
|
||||
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
KUBECONFIG: /tmp/.kube/config
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- name: Write kubeconfig
|
||||
run: |
|
||||
mkdir -p /tmp/.kube
|
||||
cat << 'EOF' > /tmp/.kube/config
|
||||
${{ secrets.KUBECONFIG_DATA }}
|
||||
EOF
|
||||
chmod 600 /tmp/.kube/config
|
||||
|
||||
- name: Verify Kubernetes access
|
||||
run: |
|
||||
kubectl cluster-info
|
||||
kubectl get nodes
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone --depth=1 --branch master \
|
||||
https://git.tonesc.cn/tone/tonePage.git \
|
||||
/workspace/tone/tonePage
|
||||
cd /workspace/tone/tonePage
|
||||
git log -1 --oneline
|
||||
|
||||
- name: Build and push backend image
|
||||
run: |
|
||||
cd /workspace/tone/tonePage/apps/backend
|
||||
docker build -t localhost:5000/backend:${IMAGE_TAG} .
|
||||
docker push localhost:5000/backend:${IMAGE_TAG}
|
||||
|
||||
- name: Build and push frontend image
|
||||
run: |
|
||||
cd /workspace/tone/tonePage/apps/frontend
|
||||
docker build \
|
||||
--build-arg API_BASE="http://backend-service:3001" \
|
||||
-t localhost:5000/frontend:${IMAGE_TAG} .
|
||||
docker push localhost:5000/frontend:${IMAGE_TAG}
|
||||
|
||||
- name: Deploy to K3s
|
||||
run: |
|
||||
cd /workspace/tone/tonePage/apps/deploy
|
||||
|
||||
# 基础资源
|
||||
kubectl apply -f postgres-deployment.yaml
|
||||
kubectl apply -f backend-deployment.yaml
|
||||
kubectl apply -f frontend-deployment.yaml
|
||||
|
||||
# 更新镜像(触发滚动更新)
|
||||
kubectl set image deployment/backend \
|
||||
backend=localhost:5000/backend:${IMAGE_TAG}
|
||||
|
||||
kubectl set image deployment/frontend \
|
||||
frontend=localhost:5000/frontend:${IMAGE_TAG}
|
||||
|
||||
# 等待滚动完成
|
||||
kubectl rollout status deployment/backend --timeout=120s
|
||||
kubectl rollout status deployment/frontend --timeout=120s
|
||||
|
||||
- name: Post-deploy sanity check
|
||||
run: |
|
||||
kubectl get pods
|
||||
kubectl get svc
|
||||
4
apps/.dockerignore
Normal file
4
apps/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env.local
|
||||
@@ -1,19 +1,23 @@
|
||||
FROM node:22-alpine AS builder
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
RUN npm install -g pnpm
|
||||
RUN CI=true pnpm prune --prod
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["pnpm", "run", "start:prod"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -17,7 +17,10 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"migration:generate": "typeorm migration:generate -d src/data-source.ts",
|
||||
"migration:run": "typeorm migration:run -d dist/data-source.js",
|
||||
"migration:revert": "typeorm migration:revert -d dist/data-source.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.3",
|
||||
@@ -39,6 +42,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.15.6",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
|
||||
9
apps/backend/pnpm-lock.yaml
generated
9
apps/backend/pnpm-lock.yaml
generated
@@ -65,6 +65,9 @@ importers:
|
||||
cookie-parser:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.3
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
@@ -1483,6 +1486,10 @@ packages:
|
||||
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@17.2.3:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5229,6 +5236,8 @@ snapshots:
|
||||
|
||||
dotenv@16.4.7: {}
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
||||
@@ -5,6 +5,9 @@ export class CreateBlogDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
slug: string;// 允许空串,但如果为空则需要手动设置为null,防止数据库唯一键冲突
|
||||
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ export class UpdateBlogDto {
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
contentUrl: string;
|
||||
|
||||
|
||||
@@ -15,20 +15,13 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { CaptchaModule } from './captcha/captcha.module';
|
||||
import { SmsModule } from './sms/sms.module';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { AppDataSource } from './data-source';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
|
||||
username: process.env.DATABASE_USERNAME,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
database: process.env.DATABASE_NAME,
|
||||
autoLoadEntities: true,
|
||||
entities: [],
|
||||
synchronize: process.env.NODE_ENV !== 'production', // Set to false in production
|
||||
TypeOrmModule.forRootAsync({
|
||||
useFactory: () => AppDataSource.options,
|
||||
}),
|
||||
ThrottlerModule.forRoot({
|
||||
ignoreUserAgents: [/googlebot/i, /bingbot/i],
|
||||
|
||||
@@ -31,12 +31,16 @@ export class BlogController {
|
||||
return this.blogService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getBlog(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Get(':id/slug')
|
||||
async getBlogBySlug(
|
||||
@Param('id') slug: string,
|
||||
@Query('p') password?: string,
|
||||
) {
|
||||
const blog = await this.blogService.findById(id);
|
||||
if (slug.trim().length === 0) {
|
||||
throw new BadRequestException('文章不存在');
|
||||
}
|
||||
|
||||
const blog = await this.blogService.findBySlug(slug);
|
||||
if (!blog) throw new BadRequestException('文章不存在或无权限访问');
|
||||
|
||||
if (!blog.permissions.includes(BlogPermission.Public)) {
|
||||
@@ -46,7 +50,7 @@ export class BlogController {
|
||||
} else {
|
||||
// 判断密码是否正确
|
||||
if (
|
||||
!password ||
|
||||
typeof password !== 'string' ||
|
||||
this.blogService.hashPassword(password) !== blog.password_hash
|
||||
) {
|
||||
throw new BadRequestException('文章不存在或无权限访问');
|
||||
@@ -57,10 +61,11 @@ export class BlogController {
|
||||
const blogDataRes = await fetch(`${blog.contentUrl}`);
|
||||
const blogContent = await blogDataRes.text();
|
||||
|
||||
await this.blogService.incrementViewCount(id);
|
||||
this.blogService.incrementViewCount(blog.id).catch(() => null);
|
||||
return {
|
||||
id: blog.id,
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
createdAt: blog.createdAt,
|
||||
content: blogContent,
|
||||
};
|
||||
|
||||
@@ -35,12 +35,13 @@ export class BlogService {
|
||||
return i;
|
||||
}
|
||||
|
||||
const { createdAt, deletedAt, id, title, viewCount, description } = i;
|
||||
const { createdAt, updatedAt, id, title, viewCount, description, slug } = i;
|
||||
return {
|
||||
createdAt,
|
||||
deletedAt,
|
||||
updatedAt,
|
||||
id,
|
||||
title,
|
||||
slug,
|
||||
viewCount,
|
||||
description,
|
||||
};
|
||||
@@ -56,6 +57,9 @@ export class BlogService {
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
if (typeof blog.slug === 'string' && blog.slug.trim().length === 0) {
|
||||
blog.slug = null;
|
||||
}
|
||||
|
||||
const newBlog = this.blogRepository.create(blog);
|
||||
return this.blogRepository.save(newBlog);
|
||||
@@ -92,6 +96,12 @@ export class BlogService {
|
||||
return await this.blogRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
async findBySlug(slug: string) {
|
||||
return this.blogRepository.findOne({
|
||||
where: { slug }
|
||||
})
|
||||
}
|
||||
|
||||
async incrementViewCount(id: string) {
|
||||
await this.blogRepository.increment({ id }, 'viewCount', 1);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ export class Blog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true, nullable: true })
|
||||
slug: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
|
||||
20
apps/backend/src/data-source.ts
Normal file
20
apps/backend/src/data-source.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: Number(process.env.DATABASE_PORT ?? 5432),
|
||||
username: process.env.DATABASE_USERNAME,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
database: process.env.DATABASE_NAME,
|
||||
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
|
||||
entities: ['dist/**/*.entity.js'],
|
||||
migrations: ['dist/migrations/*.js'],
|
||||
});
|
||||
16
apps/backend/src/migrations/1766809565876-AddSlugToBlog.ts
Normal file
16
apps/backend/src/migrations/1766809565876-AddSlugToBlog.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddSlugToBlog1766809565876 implements MigrationInterface {
|
||||
name = 'AddSlugToBlog1766809565876'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "blog" ADD "slug" character varying`);
|
||||
await queryRunner.query(`ALTER TABLE "blog" ADD CONSTRAINT "UQ_0dc7e58d73a1390874a663bd599" UNIQUE ("slug")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "blog" DROP CONSTRAINT "UQ_0dc7e58d73a1390874a663bd599"`);
|
||||
await queryRunner.query(`ALTER TABLE "blog" DROP COLUMN "slug"`);
|
||||
}
|
||||
|
||||
}
|
||||
77
apps/deploy/backend-deployment.yaml
Normal file
77
apps/deploy/backend-deployment.yaml
Normal file
@@ -0,0 +1,77 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: localhost:5000/backend:latest
|
||||
ports:
|
||||
- containerPort: 3001
|
||||
env:
|
||||
- name: DATABASE_HOST
|
||||
value: "postgres-service"
|
||||
- name: DATABASE_PORT
|
||||
value: "5432"
|
||||
- name: DATABASE_NAME
|
||||
value: "tone_page"
|
||||
- name: DATABASE_USERNAME
|
||||
value: "tone_page"
|
||||
- name: DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: DATABASE_PASSWORD
|
||||
- name: ALIYUN_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: ALIYUN_ACCESS_KEY_ID
|
||||
- name: ALIYUN_ACCESS_KEY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: ALIYUN_ACCESS_KEY_SECRET
|
||||
- name: ALIYUN_OSS_STS_ROLE_ARN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: ALIYUN_OSS_STS_ROLE_ARN
|
||||
- name: WEBAUTHN_RP_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: WEBAUTHN_RP_ID
|
||||
- name: WEBAUTHN_ORIGIN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: WEBAUTHN_ORIGIN
|
||||
- name: WEBAUTHN_RP_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: WEBAUTHN_RP_NAME
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend-service
|
||||
spec:
|
||||
selector:
|
||||
app: backend
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3001
|
||||
targetPort: 3001
|
||||
28
apps/deploy/backend-migration-job.yaml
Normal file
28
apps/deploy/backend-migration-job.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: backend-migration
|
||||
spec:
|
||||
backoffLimit: 0 # 失败不自动重试(防止重复执行)
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: migration
|
||||
image: 192.168.0.200:5000/backend:IMAGE_TAG
|
||||
imagePullPolicy: Always
|
||||
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Running database migrations..."
|
||||
node ./node_modules/typeorm/cli.js migration:run \
|
||||
-d dist/data-source.js
|
||||
|
||||
envFrom:
|
||||
# 和 backend Deployment 用同一套
|
||||
- secretRef:
|
||||
name: backend-secret
|
||||
- secretRef:
|
||||
name: postgres-secret
|
||||
13
apps/deploy/backend-secret.yaml
Normal file
13
apps/deploy/backend-secret.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: backend-secret
|
||||
type: Opaque
|
||||
# stringData:
|
||||
# DATABASE_PASSWORD:
|
||||
# ALIYUN_ACCESS_KEY_ID:
|
||||
# ALIYUN_ACCESS_KEY_SECRET:
|
||||
# ALIYUN_OSS_STS_ROLE_ARN:
|
||||
# WEBAUTHN_RP_ID:
|
||||
# WEBAUTHN_ORIGIN:
|
||||
# WEBAUTHN_RP_NAME:
|
||||
32
apps/deploy/frontend-deployment.yaml
Normal file
32
apps/deploy/frontend-deployment.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: localhost:5000/frontend:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend-service
|
||||
spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
nodePort: 30000
|
||||
selector:
|
||||
app: frontend
|
||||
42
apps/deploy/postgres-deployment.yaml
Normal file
42
apps/deploy/postgres-deployment.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: postgres-secret
|
||||
volumeMounts:
|
||||
- name: postgres-storage
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- name: postgres-storage
|
||||
hostPath:
|
||||
path: /var/lib/postgres-data
|
||||
type: DirectoryOrCreate
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres-service
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
12
apps/deploy/postgres-nodeport.yaml
Normal file
12
apps/deploy/postgres-nodeport.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres-nodeport
|
||||
spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
nodePort: 30001
|
||||
selector:
|
||||
app: postgres
|
||||
9
apps/deploy/postgres-secret.yaml
Normal file
9
apps/deploy/postgres-secret.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: postgres-secret
|
||||
type: Opaque
|
||||
# stringData:
|
||||
# POSTGRES_USER:
|
||||
# POSTGRES_PASSWORD:
|
||||
# POSTGRES_DB:
|
||||
@@ -1,27 +1,33 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
ARG API_BASE
|
||||
ENV API_BASE=$API_BASE
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
# 安装依赖
|
||||
FROM node:22-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next.config.ts ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 编译
|
||||
FROM node:22-alpine AS builder
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ARG API_BASE
|
||||
ENV API_BASE=$API_BASE
|
||||
RUN pnpm run build
|
||||
|
||||
# 运行
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ARG API_BASE
|
||||
ENV API_BASE=$API_BASE
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import { PhotoProvider, PhotoView } from 'react-photo-view';
|
||||
import 'react-photo-view/dist/react-photo-view.css';
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import Image from "next/image";
|
||||
|
||||
export function BlogContent({ content }: { content?: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||
components={{
|
||||
h1: ({ ...props }) => <h2 className="text-3xl font-bold py-2" {...props} />,
|
||||
h2: ({ ...props }) => <h3 className="text-2xl font-bold py-1" {...props} />,
|
||||
h3: ({ ...props }) => <h4 className="text-xl font-bold py-0.5" {...props} />,
|
||||
h4: ({ ...props }) => <h5 className="text-lg font-bold" {...props} />,
|
||||
h5: ({ ...props }) => <h6 className="text-md font-bold" {...props} />,
|
||||
p: ({ ...props }) => <p className="py-1 text-zinc-700 dark:text-zinc-300" {...props} />,
|
||||
img: ({ src }) => (
|
||||
<PhotoProvider className="w-full">
|
||||
<PhotoView src={src as string}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Image src={src as string} width={0} height={0} style={{ width: '100%', height: 'auto' }} unoptimized alt="加载失败" />
|
||||
</div>
|
||||
</PhotoView>
|
||||
</PhotoProvider>
|
||||
),
|
||||
th: ({ ...props }) => <th className="text-ellipsis text-nowrap border border-zinc-300 dark:border-zinc-500 p-2" {...props} />,
|
||||
td: ({ ...props }) => <td className="border border-zinc-300 dark:border-zinc-500 p-1" {...props} />,
|
||||
table: ({ ...props }) => <div className="overflow-x-auto"><table {...props} /></div>,
|
||||
pre: ({ ...props }) => <pre className="rounded-sm overflow-hidden shadow" {...props} />,
|
||||
blockquote: ({ ...props }) => <blockquote className="pl-3 border-l-5" {...props} />,
|
||||
a: ({ ...props }) => <a className="hover:underline" {...props} />,
|
||||
}}
|
||||
>{content}</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function BlogComments({ blogId }: { blogId: string }) {
|
||||
handleClearReplayTarget={() => setReplayTarget(null)}
|
||||
/>
|
||||
|
||||
<div className="text-sm text-zinc-600">
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{
|
||||
user ? (<span>当前账户:{user.nickname}</span>) : (<span>当前未登录,留言名称为匿名,登录可前往控制台</span>)
|
||||
}
|
||||
@@ -47,21 +47,21 @@ export function BlogComments({ blogId }: { blogId: string }) {
|
||||
{
|
||||
data.filter(d => !d.parentId)
|
||||
.map((d) => (
|
||||
<div key={d.id} className="border-b border-zinc-300 py-2 last:border-none">
|
||||
<h1 className="text-zinc-500">{d.user ? d.user.nickname : '匿名'}</h1>
|
||||
<div key={d.id} className="border-b border-zinc-300 dark:border-zinc-500 py-2 last:border-none">
|
||||
<h1 className="text-zinc-500 dark:text-zinc-200">{d.user ? d.user.nickname : '匿名'}</h1>
|
||||
<div className="whitespace-pre-wrap break-all">{d.content}</div>
|
||||
<div className="text-xs text-zinc-500 flex gap-2">
|
||||
<p>{new Date(d.createdAt).toLocaleString()}</p>
|
||||
<p>{d.address}</p>
|
||||
<p className="text-zinc-900 cursor-pointer" onClick={() => setReplayTarget(d)}>回复</p>
|
||||
<p className="text-zinc-900 dark:text-zinc-200 cursor-pointer" onClick={() => setReplayTarget(d)}>回复</p>
|
||||
</div>
|
||||
{
|
||||
data.filter(c => c.parentId === d.id).length > 0 && (
|
||||
<div className="flex flex-col ml-5 my-1">
|
||||
{
|
||||
data.filter(c => c.parentId === d.id).map(c => (
|
||||
<div key={c.id} className="border-b border-zinc-300 py-1 last:border-none">
|
||||
<h1 className="text-zinc-500">{c.user ? c.user.nickname : '匿名'}</h1>
|
||||
<div key={c.id} className="border-b border-zinc-300 dark:border-zinc-500 py-1 last:border-none">
|
||||
<h1 className="text-zinc-500 dark:text-zinc-200">{c.user ? c.user.nickname : '匿名'}</h1>
|
||||
<div className="whitespace-pre-wrap break-all">{c.content}</div>
|
||||
<div className="text-xs text-zinc-500 flex gap-2">
|
||||
<p>{new Date().toLocaleString()}</p>
|
||||
|
||||
@@ -1,84 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { base62 } from "@/lib/utils";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import { PhotoProvider, PhotoView } from 'react-photo-view';
|
||||
import 'react-photo-view/dist/react-photo-view.css';
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { BlogContent } from "./BlogContent";
|
||||
import { BlogAPI } from "@/lib/api/server";
|
||||
import { handleAPIError } from "@/lib/api/common";
|
||||
import { BlogComments } from "./components/BlogComments";
|
||||
import Image from "next/image";
|
||||
import { BlogAPI } from "@/lib/api/client";
|
||||
|
||||
export default function Blog() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
interface PageRouteProps {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[] | undefined;
|
||||
} | undefined>
|
||||
}
|
||||
|
||||
const hex = Array.from(base62.decode(params.id as string)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const id = [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20, 32)
|
||||
].join('-');
|
||||
async function parseBlogParams({ params: paramsPromise, searchParams: searchParamsPromise }: PageRouteProps) {
|
||||
const params = await paramsPromise ?? {};
|
||||
const searchParams = await searchParamsPromise ?? {};
|
||||
|
||||
const password = searchParams.get('p');
|
||||
const { data, error, isLoading } = useSWR(
|
||||
`/api/blog/${id}`,
|
||||
() => BlogAPI.getBlog(id, password || undefined),
|
||||
)
|
||||
if (Array.isArray(searchParams.p)) {
|
||||
return {
|
||||
errorMsg: '密码错误或文章不存在'
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof params.id !== 'string' || params.id.trim() === '') {
|
||||
return {
|
||||
errorMsg: '文章不存在或无权限访问'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
p: searchParams.p,
|
||||
}
|
||||
}
|
||||
|
||||
async function getBlog(paramsResult: ReturnType<typeof parseBlogParams>) {
|
||||
const { errorMsg, id, p } = await paramsResult;
|
||||
if (errorMsg) {
|
||||
return {
|
||||
errorMsg,
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const data = await BlogAPI.getBlogBySlug(`${id}`, p);
|
||||
return {
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
errorMsg: handleAPIError(error, ({ message }) => message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params, searchParams }: PageRouteProps) {
|
||||
const { errorMsg, data } = await getBlog(parseBlogParams({ params, searchParams }));
|
||||
if (data) {
|
||||
return {
|
||||
title: `${data.title} - 特恩的日志`,
|
||||
description: `${data.description}`
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: `${errorMsg || '错误'} - 特恩的日志`,
|
||||
description: `出错啦`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page({ params, searchParams }: PageRouteProps) {
|
||||
const res = await parseBlogParams({ params, searchParams });
|
||||
const { id, p } = res;
|
||||
let { errorMsg } = res;
|
||||
|
||||
const data = errorMsg ? null
|
||||
: await BlogAPI.getBlogBySlug(`${id}`, p).catch(e => handleAPIError(e, ({ message }) => { errorMsg = message; return null }));
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<div className="max-w-200 mx-auto px-5 overflow-x-hidden mb-10">
|
||||
{error && <div className="my-20 text-center text-zinc-600">{error.message}</div>}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 mt-10">
|
||||
<Skeleton className="w-full h-10" />
|
||||
<Skeleton className="w-full h-20" />
|
||||
<Skeleton className="w-full h-10" />
|
||||
<Skeleton className="w-full h-20" />
|
||||
<Skeleton className="w-full h-30" />
|
||||
</div>
|
||||
)}
|
||||
{errorMsg && <div className="my-20 text-center text-zinc-600 dark:text-zinc-400">{errorMsg}</div>}
|
||||
{data && (
|
||||
<>
|
||||
<h1 className="text-center text-3xl font-bold mt-10">{data.title}</h1>
|
||||
<p className="text-sm text-zinc-500 text-center my-5">发布于:{new Date(data.createdAt).toLocaleString()}</p>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||
components={{
|
||||
h1: ({ ...props }) => <h1 className="text-3xl font-bold py-2" {...props} />,
|
||||
h2: ({ ...props }) => <h2 className="text-2xl font-bold py-1" {...props} />,
|
||||
h3: ({ ...props }) => <h3 className="text-xl font-bold py-0.5" {...props} />,
|
||||
h4: ({ ...props }) => <h4 className="text-lg font-bold" {...props} />,
|
||||
h5: ({ ...props }) => <h5 className="text-md font-bold" {...props} />,
|
||||
p: ({ ...props }) => <p className="py-1 text-zinc-700" {...props} />,
|
||||
img: ({ src }) => (
|
||||
<PhotoProvider className="w-full">
|
||||
<PhotoView src={src as string}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Image src={src as string} width={0} height={0} style={{ width: '100%', height: 'auto' }} unoptimized alt="加载失败" />
|
||||
</div>
|
||||
</PhotoView>
|
||||
</PhotoProvider>
|
||||
),
|
||||
th: ({ ...props }) => <th className="text-ellipsis text-nowrap border border-zinc-300 p-2" {...props} />,
|
||||
td: ({ ...props }) => <td className="border border-zinc-300 p-1" {...props} />,
|
||||
table: ({ ...props }) => <div className="overflow-x-auto"><table {...props} /></div>,
|
||||
pre: ({ ...props }) => <pre className="rounded-sm overflow-hidden shadow" {...props} />,
|
||||
blockquote: ({ ...props }) => <blockquote className="pl-3 border-l-5" {...props} />,
|
||||
a: ({ ...props }) => <a className="hover:underline" {...props} />,
|
||||
}}
|
||||
>{data.content}</ReactMarkdown>
|
||||
</>
|
||||
<article className="w-full">
|
||||
<header className="flex flex-col items-center">
|
||||
<h1 className="text-center text-2xl sm:text-3xl font-bold mt-10 transition-all duration-500">{data.title}</h1>
|
||||
<time className="text-sm text-zinc-500 dark:text-zinc-300 text-center my-2 sm:my-5 mb-5 transition-all duration-500">发布于:{new Date(data.createdAt).toLocaleString()}</time>
|
||||
</header>
|
||||
<BlogContent content={data.content} />
|
||||
</article>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { base62 } from "@/lib/utils";
|
||||
import { BlogAPI } from "@/lib/api/server";
|
||||
import { handleAPIError } from "@/lib/api/common";
|
||||
|
||||
@@ -18,10 +17,13 @@ const formatNumber = (num: number): string => {
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const getBlogDetailUrl = (id: string): string => {
|
||||
const cleanId = id.replace(/-/g, '');
|
||||
const encoded = base62.encode(Buffer.from(cleanId, 'hex'));
|
||||
return `/blog/${encoded}`;
|
||||
const getBlogDetailUrl = (slug: string): string => {
|
||||
return `/blog/${slug}`;
|
||||
};
|
||||
|
||||
export const metadata = {
|
||||
title: '日志 - 特恩的日志',
|
||||
description: '我随便发点,你也随便看看~',
|
||||
};
|
||||
|
||||
export default async function Blog() {
|
||||
@@ -50,14 +52,13 @@ export default async function Blog() {
|
||||
<h2 className="text-2xl font-medium">
|
||||
<a
|
||||
className="hover:underline focus:outline-none focus:ring-2 focus:ring-zinc-400 rounded"
|
||||
href={getBlogDetailUrl(blog.id)}
|
||||
target="_blank"
|
||||
href={getBlogDetailUrl(blog.slug)}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{blog.title}
|
||||
</a>
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-zinc-600">{blog.description}</p>
|
||||
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">{blog.description}</p>
|
||||
<footer className="mt-3 text-sm text-zinc-500 flex items-center gap-2">
|
||||
<time dateTime={blog.createdAt}>
|
||||
{new Date(blog.createdAt).toLocaleString('zh-CN')}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function LayoutWithHeaderFooter({
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="flex-1 flex flex-col bg-zinc-50">
|
||||
<main className="flex-1 flex flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import favicon from '../favicon.ico';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="w-full flex-1 flex flex-col items-center justify-center">
|
||||
<section className="w-full flex-1 flex flex-col items-center justify-center">
|
||||
<figure className="flex flex-col items-center">
|
||||
<Image
|
||||
src={favicon.src}
|
||||
alt="TONE's avatar"
|
||||
src="/avatar.png"
|
||||
alt="TONE 的个人头像"
|
||||
width={180}
|
||||
height={180}
|
||||
className="rounded-full duration-400 size-35 md:size-45 select-none"
|
||||
unoptimized
|
||||
priority
|
||||
quality={100}
|
||||
/>
|
||||
</figure>
|
||||
<h1 className='text-4xl md:text-5xl font-bold mt-5 md:mt-8 gradient-title duration-400 select-none'>特恩(TONE)</h1>
|
||||
<h2 className='text-lg sm:text-xl md:text-2xl mt-3 font-medium text-zinc-400 duration-400 select-none'>一名在各个领域反复横跳的程序员</h2>
|
||||
<div className='flex sm:flex-row flex-col gap-2 sm:gap-10 mt-5 md:mt-8 duration-400'>
|
||||
<a href='https://space.bilibili.com/474156211' target='_black' className='bg-[#488fe9] hover:bg-[#3972ba] text-center text-white w-45 sm:w-32 px-6 py-2 text-lg rounded-full cursor-pointer'>哔哩哔哩</a>
|
||||
<a href='https://github.com/tonecn' className='bg-[#da843f] hover:bg-[#c87d3e] text-center text-white w-45 sm:w-32 px-6 py-2 text-lg rounded-full cursor-pointer'>GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-lg sm:text-xl md:text-2xl mt-3 font-medium text-zinc-400 dark:text-zinc-200 duration-400 select-none'>一名在各个领域反复横跳的程序员</p>
|
||||
<nav className='flex sm:flex-row flex-col gap-2 sm:gap-10 mt-5 md:mt-8 duration-400' aria-label="社交媒体链接">
|
||||
<a href='https://space.bilibili.com/474156211'
|
||||
target='_black'
|
||||
rel="noopener noreferrer"
|
||||
className='bg-[#488fe9] hover:bg-[#3972ba] text-center text-white w-45 sm:w-32 px-6 py-2 text-lg rounded-full cursor-pointer'
|
||||
>
|
||||
哔哩哔哩
|
||||
</a>
|
||||
<a href='https://github.com/tonecn'
|
||||
target='_black'
|
||||
rel="noopener noreferrer"
|
||||
className='bg-[#da843f] hover:bg-[#c87d3e] text-center text-white w-45 sm:w-32 px-6 py-2 text-lg rounded-full cursor-pointer'
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ResourceCard({ r, ...props }: ResourceCardProps) {
|
||||
</div>
|
||||
<div className="flex-1 overflow-x-hidden">
|
||||
<div className="font-bold text-2xl">{r.title}</div>
|
||||
<div className="font-medium text-sm text-zinc-400 mt-1">{r.description}</div>
|
||||
<div className="font-medium text-sm text-zinc-400 dark:text-zinc-300 mt-1">{r.description}</div>
|
||||
<div className="flex gap-2 flex-wrap mt-4">
|
||||
{
|
||||
r.tags.map((tag) => (
|
||||
|
||||
@@ -16,9 +16,9 @@ export default function ResourceCardImage({ imageUrl }: ResourceCardImage) {
|
||||
alt="资源图片"
|
||||
width={90}
|
||||
height={90}
|
||||
className="rounded-md shadow"
|
||||
className="rounded-md shadow w-22.5 h-22.5"
|
||||
priority
|
||||
quality={80}
|
||||
quality={75}
|
||||
onError={() => setImageError(true)}
|
||||
/>}
|
||||
</>
|
||||
|
||||
@@ -7,15 +7,20 @@ import {
|
||||
import { ResourceAPI } from "@/lib/api/server";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export const metadata = {
|
||||
title: '资源 - 特恩的日志',
|
||||
description: '一些实用工具和学习资源',
|
||||
};
|
||||
|
||||
export default async function Resources() {
|
||||
let errorMsg = '';
|
||||
const data = await ResourceAPI.list().catch(e => { errorMsg = `${e}`; return null; });
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<h1 className="mt-6 md:mt-20 text-2xl md:text-5xl font-medium text-zinc-800 text-center duration-300">精心挑选并收藏的资源</h1>
|
||||
<p className="mt-4 md:mt-8 mx-3 text-zinc-400 text-sm text-center duration-300">请在浏览此部分内容前阅读并同意
|
||||
<a className="text-zinc-600">《使用条款和隐私政策》</a>
|
||||
<h1 className="mt-6 md:mt-20 text-2xl md:text-5xl font-medium text-zinc-800 dark:text-zinc-200 text-center duration-300">精心挑选并收藏的资源</h1>
|
||||
<p className="mt-4 md:mt-8 mx-3 text-zinc-400 dark:text-zinc-300 text-sm text-center duration-300">请在浏览此部分内容前阅读并同意
|
||||
<a className="text-zinc-600 dark:text-zinc-400">《使用条款和隐私政策》</a>
|
||||
,继续使用或浏览表示您接受协议条款。</p>
|
||||
{
|
||||
errorMsg && (
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BlogPermissionCheckBoxs } from "./BlogPermissionCheckBoxs";
|
||||
import { AdminAPI } from "@/lib/api/client";
|
||||
import { copyShareURL } from "./utils";
|
||||
|
||||
interface AddBlogProps {
|
||||
children: React.ReactNode;
|
||||
@@ -27,6 +28,7 @@ export default function AddBlog({ children, onRefresh }: AddBlogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [blog, setBlog] = useState({
|
||||
title: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
contentUrl: "",
|
||||
permissions: [] as BlogPermission[],
|
||||
@@ -44,6 +46,7 @@ export default function AddBlog({ children, onRefresh }: AddBlogProps) {
|
||||
toast.success("添加成功");
|
||||
setBlog({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
contentUrl: '',
|
||||
permissions: [],
|
||||
@@ -92,6 +95,17 @@ export default function AddBlog({ children, onRefresh }: AddBlogProps) {
|
||||
onChange={(e) => setBlog({ ...blog, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="slug" className="text-right">
|
||||
Slug
|
||||
</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
className="col-span-3"
|
||||
value={blog.slug}
|
||||
onChange={(e) => setBlog({ ...blog, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="contentUrl" className="text-right">
|
||||
文章URL
|
||||
@@ -136,9 +150,18 @@ export default function AddBlog({ children, onRefresh }: AddBlogProps) {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter >
|
||||
<div className="flex justify-between w-full">
|
||||
<Button type="button" variant='outline' onClick={() => copyShareURL({
|
||||
slug: blog.slug,
|
||||
password: blog.password,
|
||||
permissions: blog.permissions,
|
||||
})}>复制分享链接</Button>
|
||||
<div>
|
||||
<Button type="button" variant='secondary' onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button type="button" onClick={handleSubmit}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BlogPermissionCheckBoxs } from "./BlogPermissionCheckBoxs"
|
||||
import { BlogPermission } from "@/lib/types/Blog.Permission.enum"
|
||||
import { SetPasswordDialog } from "./SetPasswordDialog"
|
||||
import { AdminAPI } from "@/lib/api/client"
|
||||
import { copyShareURL } from "./utils"
|
||||
|
||||
interface BlogEditProps {
|
||||
id: string;
|
||||
@@ -46,6 +47,7 @@ export default function BlogEdit({ id, children, onRefresh }: BlogEditProps) {
|
||||
await AdminAPI.updateBlog(id, {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
slug: blog.slug,
|
||||
contentUrl: blog.contentUrl,
|
||||
permissions: blog.permissions,
|
||||
});
|
||||
@@ -106,6 +108,17 @@ export default function BlogEdit({ id, children, onRefresh }: BlogEditProps) {
|
||||
onChange={(e) => mutate({ ...blog, description: e.target.value }, false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="slug" className="text-right">
|
||||
Slug
|
||||
</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
className="col-span-3"
|
||||
value={blog.slug}
|
||||
onChange={(e) => mutate({ ...blog, slug: e.target.value }, false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="contentUrl" className="text-right">
|
||||
文章URL
|
||||
@@ -151,6 +164,11 @@ export default function BlogEdit({ id, children, onRefresh }: BlogEditProps) {
|
||||
<div className="w-full flex justify-between">
|
||||
<div>
|
||||
<Button variant='destructive' onClick={handleDelete}>删除</Button>
|
||||
<Button variant='outline' className="ml-2" onClick={() => copyShareURL({
|
||||
slug: blog.slug,
|
||||
permissions: blog.permissions,
|
||||
password: ''
|
||||
})}>复制链接</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button type="button" variant='secondary' onClick={() => setOpen(false)}>取消</Button>
|
||||
|
||||
@@ -28,10 +28,11 @@ export default function BlogTable({ blogs, error, onRefresh }: BlogTableProps) {
|
||||
}
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-25">Id</TableHead>
|
||||
<TableHead className="w-15">Id</TableHead>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>文章URL</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead className="w-25">文章URL</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -42,7 +43,7 @@ export default function BlogTable({ blogs, error, onRefresh }: BlogTableProps) {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="max-w-[100px] overflow-hidden text-ellipsis">{blog.id}</div>
|
||||
<div className="max-w-15 overflow-hidden text-ellipsis">{blog.id}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{blog.id}</p>
|
||||
@@ -52,7 +53,19 @@ export default function BlogTable({ blogs, error, onRefresh }: BlogTableProps) {
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-normal break-all">{blog.title}</TableCell>
|
||||
<TableCell className="whitespace-normal break-all">{blog.description}</TableCell>
|
||||
<TableCell className="whitespace-normal break-all">{blog.contentUrl}</TableCell>
|
||||
<TableCell className="whitespace-normal break-all">{blog.slug}</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="max-w-20 overflow-hidden text-ellipsis">{blog.contentUrl}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{blog.contentUrl}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<BlogEdit id={blog.id} onRefresh={() => onRefresh()}>
|
||||
<Button variant={'outline'} size={'sm'}>编辑</Button>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function copyShareURL(data: {
|
||||
slug: string;
|
||||
password: string;
|
||||
permissions: BlogPermission[];
|
||||
}) {
|
||||
const slug = data.slug.trim();
|
||||
const password = data.password.trim();
|
||||
const permissions = data.permissions;
|
||||
|
||||
if (slug.length === 0) {
|
||||
return toast.warning('请先填写Slug')
|
||||
}
|
||||
|
||||
let url = `${window.location.origin}/blog/${slug}`;
|
||||
|
||||
if (permissions.includes(BlogPermission.ByPassword)) {
|
||||
if (password.length === 0) {
|
||||
return toast.warning('开启了密码保护,但无法获取有效的密码,无法生成有效URL')
|
||||
} else {
|
||||
url += `?p=${password}`;
|
||||
}
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toast.success('复制成功');
|
||||
}, () => {
|
||||
toast.error('复制失败,请手动复制');
|
||||
});
|
||||
};
|
||||
@@ -97,7 +97,7 @@ export default function Login() {
|
||||
alt="Image"
|
||||
width={500}
|
||||
height={500}
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.6] dark:grayscale"
|
||||
priority
|
||||
quality={100}
|
||||
/>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 MiB |
@@ -1,10 +1,10 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "../components/theme-provider";
|
||||
import { metadata } from "./config/metadata";
|
||||
import { Toaster } from "sonner";
|
||||
import { UserAPI } from "@/lib/api/server";
|
||||
import { ClientProvider } from "./ClientProvider";
|
||||
import { Metadata } from "next";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -16,7 +16,10 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export { metadata };
|
||||
export const metadata: Metadata = {
|
||||
title: "特恩的日志",
|
||||
description: "一名在各个领域反复横跳的程序员",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
@@ -26,7 +29,8 @@ export default async function RootLayout({
|
||||
const user = await UserAPI.me().catch(() => null);
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
|
||||
suppressHydrationWarning
|
||||
@@ -38,10 +42,8 @@ export default async function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<main className="flex-1 flex flex-col bg-zinc-50">
|
||||
{children}
|
||||
<Toaster />
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
</ClientProvider>
|
||||
</body>
|
||||
|
||||
12
apps/frontend/app/robots.ts
Normal file
12
apps/frontend/app/robots.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/console',
|
||||
},
|
||||
sitemap: 'https://www.tonesc.cn/sitemap.xml',
|
||||
}
|
||||
}
|
||||
42
apps/frontend/app/sitemap.ts
Normal file
42
apps/frontend/app/sitemap.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BlogAPI } from '@/lib/api/server'
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// 获取所有博客
|
||||
const blogs = await BlogAPI.list().catch(() => [])
|
||||
|
||||
const blogUrls = blogs.map(blog => {
|
||||
return {
|
||||
url: `https://www.tonesc.cn/blog/${blog.slug}`,
|
||||
lastModified: new Date(blog.updatedAt),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}
|
||||
})
|
||||
|
||||
// 静态页面
|
||||
const staticUrls = [
|
||||
{
|
||||
url: 'https://www.tonesc.cn/',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly' as const,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: 'https://www.tonesc.cn/blog',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://www.tonesc.cn/resource',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
},
|
||||
]
|
||||
|
||||
return [...staticUrls, ...blogUrls]
|
||||
}
|
||||
@@ -10,19 +10,19 @@ const EMAIL = "tonesc.cn@gmail.com";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-zinc-300">
|
||||
<div className="bg-zinc-50 px-4 py-3 md:py-5 sm:px-10 md:px-20 flex flex-col sm:flex-row justify-between items-center gap-4 transition-all">
|
||||
<footer className="border-t border-zinc-300 dark:border-zinc-500">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-950 px-4 py-3 md:py-5 sm:px-10 md:px-20 flex flex-col sm:flex-row justify-between items-center gap-4 transition-all">
|
||||
{/* 版权与备案信息 */}
|
||||
<div className="text-center sm:text-left">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-zinc-500 hover:text-zinc-700 hover:underline focus:outline-none focus:underline"
|
||||
className="block text-sm text-zinc-500 dark:text-zinc-300 hover:text-zinc-700 dark:hover:text-zinc-100 hover:underline focus:outline-none focus:underline"
|
||||
>
|
||||
备案号:渝ICP备2023009516号-1
|
||||
</a>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-300">
|
||||
© {new Date().getFullYear()} TONE Page. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
@@ -32,13 +32,13 @@ export default function Footer() {
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' size='sm' >
|
||||
<Mail className="text-zinc-600" />
|
||||
<Mail className="text-zinc-600 dark:text-zinc-300" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit">
|
||||
<a
|
||||
href={`mailto:${EMAIL}`}
|
||||
className="text-sm text-zinc-800 hover:underline focus:outline-none focus:underline"
|
||||
className="text-sm text-zinc-800 dark:text-zinc-200 hover:underline focus:outline-none focus:underline"
|
||||
>
|
||||
{EMAIL}
|
||||
</a>
|
||||
|
||||
@@ -48,13 +48,13 @@ export default function Header() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 backdrop-blur-sm bg-white/40 shadow" role="banner" aria-label="网站顶部导航栏">
|
||||
<header className="sticky top-0 z-50 backdrop-blur-sm bg-white/40 dark:bg-black/40 shadow dark:shadow-zinc-500" role="banner" aria-label="网站顶部导航栏">
|
||||
<div className="flex items-center justify-between px-10 md:h-18 md:px-20 h-14 duration-300" aria-label="主菜单">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"cursor-pointer font-medium text-zinc-500 hover:text-zinc-800 border-b-4 border-transparent duration-200",
|
||||
pathname === "/" && "text-zinc-800"
|
||||
"cursor-pointer font-medium text-zinc-500 dark:text-zinc-300 hover:text-zinc-800 dark:hover:text-zinc-100 border-b-4 border-transparent duration-200",
|
||||
pathname === "/" && "text-zinc-800 dark:text-zinc-100"
|
||||
)}
|
||||
aria-current={pathname === "/" ? "page" : undefined}
|
||||
>
|
||||
@@ -72,8 +72,8 @@ export default function Header() {
|
||||
key={item.name}
|
||||
href={item.path}
|
||||
className={cn(
|
||||
"cursor-pointer md:text-lg font-medium text-zinc-500 hover:text-zinc-800 border-b-4 border-transparent duration-200",
|
||||
pathname.startsWith(item.path) && "text-zinc-800 border-b-pink-500"
|
||||
"cursor-pointer md:text-lg font-medium text-zinc-500 dark:text-zinc-300 hover:text-zinc-800 dark:hover:text-zinc-100 border-b-4 border-transparent duration-200",
|
||||
pathname.startsWith(item.path) && "text-zinc-800 dark:text-zinc-100 border-b-pink-500"
|
||||
)}
|
||||
onClick={e => handleClick(e, item.path)}
|
||||
aria-current={pathname === item.path ? "page" : undefined}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ResourceBadgeProps extends React.HTMLProps<HTMLDivElement> {
|
||||
@@ -10,23 +11,17 @@ export function ResourceBadge({ tag, editMode, onClose, ...props }: ResourceBadg
|
||||
return (
|
||||
<div
|
||||
id={tag.name}
|
||||
className="text-[10px] text-zinc-500 font-medium py-[1px] px-1.5 rounded-full flex items-center gap-1"
|
||||
style={{
|
||||
backgroundColor: (() => {
|
||||
switch (tag.type) {
|
||||
case 'os':
|
||||
return '#dbedfd';
|
||||
default:
|
||||
return '#e4e4e7';
|
||||
}
|
||||
})()
|
||||
}}
|
||||
className={cn(
|
||||
"text-[10px] text-zinc-500 dark:text-zinc-300 dark:border font-medium py-px px-1.5 rounded-full flex items-center gap-1",
|
||||
'bg-[#e4e4e7] dark:bg-[#2d2d30]',
|
||||
tag.type === 'os' || 'bg-[#dbedfd] dark:bg-[#1e3a5f]',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-nowrap">{tag.name}</span>
|
||||
{
|
||||
editMode && (
|
||||
<span onClick={() => onClose?.(tag.name)}><X className="w-3 h-3 text-zinc-800 cursor-pointer" /></span>
|
||||
<span onClick={() => onClose?.(tag.name)}><X className="w-3 h-3 text-zinc-800 dark:text-zinc-200 cursor-pointer" /></span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function normalizeAPIError(error: unknown): never {
|
||||
throw new APIError((error instanceof Error ? `${error.message}` : '') || '未知错误', 400);
|
||||
}
|
||||
|
||||
export function handleAPIError(error: unknown, handler: (e: APIError) => void): void {
|
||||
export function handleAPIError<T>(error: unknown, handler: (e: APIError) => T): T {
|
||||
if (error instanceof APIError) {
|
||||
return handler(error);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { clientFetch } from "../client";
|
||||
import { Blog } from "@/lib/types/blog";
|
||||
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
|
||||
import { Role } from "@/lib/types/role";
|
||||
import { APIError } from "../common";
|
||||
|
||||
export interface UserEntity {
|
||||
userId: string;
|
||||
@@ -87,9 +88,31 @@ export async function updateResource(id: string, data: UpdateResourceParams) {
|
||||
interface CreateBlogParams {
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
contentUrl: string;
|
||||
permissions: BlogPermission[];
|
||||
password: string;
|
||||
}
|
||||
export async function createBlog(data: CreateBlogParams) {
|
||||
data.title = data.title.trim()
|
||||
data.description = data.description.trim()
|
||||
data.slug = data.slug.trim()
|
||||
data.contentUrl = data.contentUrl.trim()
|
||||
data.password = data.password.trim()
|
||||
|
||||
if (data.title.length === 0) {
|
||||
throw new APIError('标题不得为空')
|
||||
}
|
||||
if (data.description.length === 0) {
|
||||
throw new APIError('描述不得为空')
|
||||
}
|
||||
if (data.slug.length === 0) {
|
||||
throw new APIError('Slug不得为空')
|
||||
}
|
||||
if (data.contentUrl.length === 0) {
|
||||
throw new APIError('文章URL不得为空')
|
||||
}
|
||||
|
||||
return clientFetch<Blog>('/api/admin/web/blog', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
@@ -115,14 +138,29 @@ export async function removeBlog(id: string) {
|
||||
interface UpdateBlogParams {
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
contentUrl: string;
|
||||
permissions: BlogPermission[],
|
||||
}
|
||||
export async function updateBlog(id: string, data: UpdateBlogParams) {
|
||||
data.title = data.title.trim();
|
||||
data.description = data.description.trim();
|
||||
data.slug = data.slug.trim();
|
||||
data.contentUrl = data.contentUrl.trim();
|
||||
|
||||
if (data.title.length === 0) {
|
||||
throw new APIError('标题不得为空')
|
||||
}
|
||||
if (data.description.length === 0) {
|
||||
throw new APIError('描述不得为空')
|
||||
}
|
||||
if (data.slug.length === 0) {
|
||||
throw new APIError('Slug不得为空')
|
||||
}
|
||||
if (data.contentUrl.length === 0) {
|
||||
throw new APIError('文章URL不得为空')
|
||||
}
|
||||
|
||||
return clientFetch<Blog>(`/api/admin/web/blog/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
|
||||
@@ -5,6 +5,7 @@ export async function getBlog(id: string, password?: string) {
|
||||
return clientFetch<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
content: string;
|
||||
}>(`/api/blog/${id}` + (password ? `?p=${password}` : ''));
|
||||
|
||||
@@ -2,5 +2,18 @@ import { Blog } from "@/lib/types/blog";
|
||||
import { serverFetch } from "../server";
|
||||
|
||||
export async function list() {
|
||||
return serverFetch<Blog[]>('/api/blog')
|
||||
return serverFetch<Pick<Blog,
|
||||
'id' | 'title' | 'slug' | 'description' | 'viewCount' | 'createdAt' | 'updatedAt'
|
||||
>[]>('/api/blog')
|
||||
}
|
||||
|
||||
export async function getBlogBySlug(slug: string, password?: string) {
|
||||
return serverFetch<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
content: string;
|
||||
}>(`/api/blog/${slug}/slug` + (password ? `?p=${password}` : ''));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ import { BlogPermission } from "./Blog.Permission.enum";
|
||||
export interface Blog {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
viewCount: number;
|
||||
contentUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string;
|
||||
permissions: BlogPermission[];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
devIndicators: {
|
||||
position: 'bottom-right',
|
||||
@@ -16,7 +17,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [new URL('https://tone-personal.oss-cn-chengdu.aliyuncs.com/**')]
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
"drawer": "^0.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.503.0",
|
||||
"next": "15.3.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"pagination": "^0.4.6",
|
||||
"popover": "^2.4.1",
|
||||
@@ -54,22 +54,22 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"select": "^1.1.2",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.7",
|
||||
"swr": "^2.3.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"textarea": "^0.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.76",
|
||||
"zod": "^4.3.4",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/node": "^20.19.26",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/webappsec-credential-management": "^0.6.9",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
796
apps/frontend/pnpm-lock.yaml
generated
796
apps/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
apps/frontend/public/avatar.png
Normal file
BIN
apps/frontend/public/avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
BIN
apps/frontend/public/favicon.ico
Normal file
BIN
apps/frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,7 +15,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -19,9 +23,19 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user