Compare commits
38 Commits
fd236962b1
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9094f5e278 | ||
|
|
bb987b1f8b | ||
|
|
3fcaefba1b | ||
|
|
5a375d67c7 | ||
|
|
81446e56f5 | ||
|
|
e14450f373 | ||
|
|
0eca2b1e68 | ||
| 2c3e6578e9 | |||
|
|
69db666a4b | ||
| a4cffbed23 | |||
| d4a18b0759 | |||
|
|
b63e6cc3a8 | ||
| 55833665a8 | |||
| bc4a443f9f | |||
|
|
78623924ef | ||
|
|
7f8d8373a9 | ||
| 33a41ad066 | |||
| 8d6225f136 | |||
|
|
18b3e318ab | ||
|
|
482e8571af | ||
|
|
51f8a125e9 | ||
|
|
a96ef069cc | ||
|
|
ea8fa90a31 | ||
| bbefe7d28a | |||
| 74f17c3f3f | |||
|
|
0cbed8f5ec | ||
| 38ff90b13f | |||
| f22a8049f7 | |||
|
|
de2735eb16 | ||
| 534203b47e | |||
| 5d8b271da2 | |||
|
|
af84137f31 | ||
| d145ff537a | |||
| 3241413d0d | |||
|
|
48029ac276 | ||
| 6f550882f7 | |||
| 1fdfb05f6d | |||
| 48bcbe6f06 |
@@ -44,10 +44,14 @@ jobs:
|
|||||||
username: ${{ steps.import-secrets.outputs.SERVER_USER }}
|
username: ${{ steps.import-secrets.outputs.SERVER_USER }}
|
||||||
key: ${{ steps.import-secrets.outputs.SSH_KEY }}
|
key: ${{ steps.import-secrets.outputs.SSH_KEY }}
|
||||||
script: |
|
script: |
|
||||||
|
mkdir -p /opt/d3m0k1d/data
|
||||||
docker login -u d3m0k1d -p ${{ steps.import-secrets.outputs.GITEA_TOKEN }} gitea.d3m0k1d.ru
|
docker login -u d3m0k1d -p ${{ steps.import-secrets.outputs.GITEA_TOKEN }} gitea.d3m0k1d.ru
|
||||||
docker pull gitea.d3m0k1d.ru/d3m0k1d/backend:latest
|
docker pull gitea.d3m0k1d.ru/d3m0k1d/backend:latest
|
||||||
docker rm -f d3m0k1d-backend || true
|
docker rm -f d3m0k1d-backend || true
|
||||||
docker run --name d3m0k1d-backend -d -p 8080:8080 \
|
docker run --name d3m0k1d-backend -d -p 8080:8080 \
|
||||||
|
--network d3m0k1d-network \
|
||||||
|
-v /opt/d3m0k1d/data:/data \
|
||||||
|
-e DB_PATH="/data/d3m0k1d.db" \
|
||||||
-e JWT_SECRET="${{ steps.import-secrets.outputs.JWT_SECRET }}" \
|
-e JWT_SECRET="${{ steps.import-secrets.outputs.JWT_SECRET }}" \
|
||||||
-e GITHUB_CLIENT_ID="${{ steps.import-secrets.outputs.GITHUB_CLIENT_ID }}" \
|
-e GITHUB_CLIENT_ID="${{ steps.import-secrets.outputs.GITHUB_CLIENT_ID }}" \
|
||||||
-e GITHUB_CLIENT_SECRET="${{ steps.import-secrets.outputs.GITHUB_CLIENT_SECRET }}" \
|
-e GITHUB_CLIENT_SECRET="${{ steps.import-secrets.outputs.GITHUB_CLIENT_SECRET }}" \
|
||||||
|
|||||||
@@ -44,4 +44,4 @@ jobs:
|
|||||||
docker login -u d3m0k1d -p ${{ steps.import-secrets.outputs.GITEA_TOKEN }} gitea.d3m0k1d.ru
|
docker login -u d3m0k1d -p ${{ steps.import-secrets.outputs.GITEA_TOKEN }} gitea.d3m0k1d.ru
|
||||||
docker pull gitea.d3m0k1d.ru/d3m0k1d/frontend:latest
|
docker pull gitea.d3m0k1d.ru/d3m0k1d/frontend:latest
|
||||||
docker rm -f d3m0k1d-frontend || true
|
docker rm -f d3m0k1d-frontend || true
|
||||||
docker run --name d3m0k1d-frontend -d -p 80:80 --restart unless-stopped gitea.d3m0k1d.ru/d3m0k1d/frontend:latest
|
docker run --name d3m0k1d-frontend -d -p 80:80 --restart unless-stopped --network d3m0k1d-network gitea.d3m0k1d.ru/d3m0k1d/frontend:latest
|
||||||
|
|||||||
6
backend/.dockerignore
Normal file
6
backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
docker-compose.yml
|
||||||
|
data/
|
||||||
|
Makefile
|
||||||
|
.env
|
||||||
|
docs/
|
||||||
@@ -3,18 +3,21 @@ FROM golang:1.25.6 AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
ENV GIN_MODE=release
|
ENV GIN_MODE=release
|
||||||
RUN go mod tidy
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
RUN go build -ldflags "-s -w" -o backend ./cmd/main.go
|
RUN go build -ldflags "-s -w" -o backend ./cmd/main.go
|
||||||
|
|
||||||
|
|
||||||
FROM alpine:3.23.0
|
FROM alpine:3.23.0
|
||||||
|
RUN adduser -D appuser && apk add --no-cache curl
|
||||||
COPY --from=builder /app/backend .
|
COPY --from=builder /app/backend .
|
||||||
|
RUN chown appuser:appuser ./backend
|
||||||
|
USER appuser
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
RUN apk add --no-cache curl
|
|
||||||
HEALTHCHECK CMD curl --fail http://localhost:8080/health || exit 1
|
HEALTHCHECK CMD curl --fail http://localhost:8080/health || exit 1
|
||||||
CMD ["./backend"]
|
CMD ["./backend"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: test build clean lint dev run-docker docs-upd docker
|
.PHONY: test build clean lint dev run-docker docs-upd docker docker-run
|
||||||
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@@ -29,6 +29,9 @@ docker:
|
|||||||
swag init -g ./cmd/main.go --parseDependency --parseInternal
|
swag init -g ./cmd/main.go --parseDependency --parseInternal
|
||||||
docker build -t backend .
|
docker build -t backend .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker run --rm -p 8080:8080 --env-file .env --network host -v /opt/d3m0k1d.ru/data:/data backend:latest
|
||||||
|
|
||||||
run-docker:
|
run-docker:
|
||||||
docker build -t backend .
|
docker build -t backend .
|
||||||
docker run --rm -p 8080:8080 --env-file .env backend:latest
|
docker run --rm -p 8080:8080 --env-file .env backend:latest
|
||||||
|
|||||||
@@ -15,6 +15,67 @@ const docTemplate = `{
|
|||||||
"host": "{{.Host}}",
|
"host": "{{.Host}}",
|
||||||
"basePath": "{{.BasePath}}",
|
"basePath": "{{.BasePath}}",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/admin/posts": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"Bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Get all posts",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "Get all posts",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_storage.PostReq"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid ID format",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No Post found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/auth/github": {
|
"/auth/github": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Redirects to GitHub authorization",
|
"description": "Redirects to GitHub authorization",
|
||||||
@@ -89,7 +150,7 @@ const docTemplate = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"posts"
|
"posts"
|
||||||
],
|
],
|
||||||
"summary": "Get all posts",
|
"summary": "Get all published posts",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -412,6 +473,88 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/upload": {
|
||||||
|
"post": {
|
||||||
|
"description": "Upload static content to the server",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"static"
|
||||||
|
],
|
||||||
|
"summary": "Upload static content",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/upload/{file}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get static content",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"static"
|
||||||
|
],
|
||||||
|
"summary": "Get static content",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "File name",
|
||||||
|
"name": "file",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Static content",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "File not found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@@ -455,6 +598,9 @@ const docTemplate = `{
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"published": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -466,6 +612,9 @@ const docTemplate = `{
|
|||||||
"content": {
|
"content": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"published": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -477,6 +626,9 @@ const docTemplate = `{
|
|||||||
"content": {
|
"content": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,67 @@
|
|||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/admin/posts": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"Bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Get all posts",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "Get all posts",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_storage.PostReq"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid ID format",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No Post found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/auth/github": {
|
"/auth/github": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Redirects to GitHub authorization",
|
"description": "Redirects to GitHub authorization",
|
||||||
@@ -78,7 +139,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"posts"
|
"posts"
|
||||||
],
|
],
|
||||||
"summary": "Get all posts",
|
"summary": "Get all published posts",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -401,6 +462,88 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/upload": {
|
||||||
|
"post": {
|
||||||
|
"description": "Upload static content to the server",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"static"
|
||||||
|
],
|
||||||
|
"summary": "Upload static content",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/upload/{file}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get static content",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"static"
|
||||||
|
],
|
||||||
|
"summary": "Get static content",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "File name",
|
||||||
|
"name": "file",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Static content",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "File not found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@@ -444,6 +587,9 @@
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"published": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -455,6 +601,9 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"published": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -466,6 +615,9 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
|
published:
|
||||||
|
type: boolean
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
@@ -32,6 +34,8 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
content:
|
content:
|
||||||
type: string
|
type: string
|
||||||
|
published:
|
||||||
|
type: boolean
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
@@ -39,6 +43,8 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
content:
|
content:
|
||||||
type: string
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
id:
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
title:
|
title:
|
||||||
@@ -47,6 +53,42 @@ definitions:
|
|||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
paths:
|
paths:
|
||||||
|
/admin/posts:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get all posts
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_storage.PostReq'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Invalid ID format
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: No Post found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
summary: Get all posts
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
/auth/github:
|
/auth/github:
|
||||||
get:
|
get:
|
||||||
description: Redirects to GitHub authorization
|
description: Redirects to GitHub authorization
|
||||||
@@ -119,7 +161,7 @@ paths:
|
|||||||
description: Internal server error
|
description: Internal server error
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse'
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse'
|
||||||
summary: Get all posts
|
summary: Get all published posts
|
||||||
tags:
|
tags:
|
||||||
- posts
|
- posts
|
||||||
post:
|
post:
|
||||||
@@ -292,6 +334,57 @@ paths:
|
|||||||
summary: Get user session
|
summary: Get user session
|
||||||
tags:
|
tags:
|
||||||
- auth
|
- auth
|
||||||
|
/upload:
|
||||||
|
post:
|
||||||
|
description: Upload static content to the server
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse'
|
||||||
|
summary: Upload static content
|
||||||
|
tags:
|
||||||
|
- static
|
||||||
|
/upload/{file}:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get static content
|
||||||
|
parameters:
|
||||||
|
- description: File name
|
||||||
|
in: path
|
||||||
|
name: file
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Static content
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.SuccessResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: File not found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_d3m0k1d_ru_backend_internal_models.ErrorResponse'
|
||||||
|
summary: Get static content
|
||||||
|
tags:
|
||||||
|
- static
|
||||||
securityDefinitions:
|
securityDefinitions:
|
||||||
Bearer:
|
Bearer:
|
||||||
description: Type "Bearer" followed by a space and the JWT token.
|
description: Type "Bearer" followed by a space and the JWT token.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/auth"
|
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/auth"
|
||||||
@@ -16,15 +15,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandlers struct {
|
type AuthHandlers struct {
|
||||||
repo repositories.AuthRepository
|
repo repositories.AuthRepository
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
config *oauth2.Config
|
config *oauth2.Config
|
||||||
|
frontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandlers(repo repositories.AuthRepository) *AuthHandlers {
|
func NewAuthHandlers(repo repositories.AuthRepository) *AuthHandlers {
|
||||||
clientID := os.Getenv("GITHUB_CLIENT_ID")
|
clientID := os.Getenv("GITHUB_CLIENT_ID")
|
||||||
clientSecret := os.Getenv("GITHUB_CLIENT_SECRET")
|
clientSecret := os.Getenv("GITHUB_CLIENT_SECRET")
|
||||||
redirectURL := os.Getenv("REDIRECT_URL")
|
redirectURL := os.Getenv("REDIRECT_URL")
|
||||||
|
frontendURL := os.Getenv("FRONTEND_URL")
|
||||||
|
|
||||||
if clientID == "" || clientSecret == "" {
|
if clientID == "" || clientSecret == "" {
|
||||||
panic("GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET must be set")
|
panic("GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET must be set")
|
||||||
@@ -32,10 +33,14 @@ func NewAuthHandlers(repo repositories.AuthRepository) *AuthHandlers {
|
|||||||
if redirectURL == "" {
|
if redirectURL == "" {
|
||||||
redirectURL = "http://localhost:8080/api/v1/callback/github"
|
redirectURL = "http://localhost:8080/api/v1/callback/github"
|
||||||
}
|
}
|
||||||
|
if frontendURL == "" {
|
||||||
|
frontendURL = "https://d3m0k1d.ru"
|
||||||
|
}
|
||||||
|
|
||||||
return &AuthHandlers{
|
return &AuthHandlers{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
logger: logger.New(false),
|
logger: logger.New(false),
|
||||||
|
frontendURL: frontendURL,
|
||||||
config: &oauth2.Config{
|
config: &oauth2.Config{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
@@ -75,7 +80,7 @@ func (h *AuthHandlers) CallbackGithub(c *gin.Context) {
|
|||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
if code == "" {
|
if code == "" {
|
||||||
h.logger.Error("missing code")
|
h.logger.Error("missing code")
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/login?error=missing_code")
|
c.Redirect(302, h.frontendURL+"/login?error=missing_code")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +89,7 @@ func (h *AuthHandlers) CallbackGithub(c *gin.Context) {
|
|||||||
token, err := h.config.Exchange(c.Request.Context(), code)
|
token, err := h.config.Exchange(c.Request.Context(), code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Exchange failed: " + err.Error())
|
h.logger.Error("Exchange failed: " + err.Error())
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/login?error=auth_failed")
|
c.Redirect(302, h.frontendURL+"/login?error=auth_failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +97,7 @@ func (h *AuthHandlers) CallbackGithub(c *gin.Context) {
|
|||||||
resp, err := client.Get("https://api.github.com/user")
|
resp, err := client.Get("https://api.github.com/user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Get failed: " + err.Error())
|
h.logger.Error("Get failed: " + err.Error())
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/login?error=github_api_failed")
|
c.Redirect(302, h.frontendURL+"/login?error=github_api_failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,14 +105,14 @@ func (h *AuthHandlers) CallbackGithub(c *gin.Context) {
|
|||||||
err = json.NewDecoder(resp.Body).Decode(&ghUser)
|
err = json.NewDecoder(resp.Body).Decode(&ghUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Decode failed: " + err.Error())
|
h.logger.Error("Decode failed: " + err.Error())
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/login?error=decode_failed")
|
c.Redirect(302, h.frontendURL+"/login?error=decode_failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isreg, err := h.repo.IsRegistered(c.Request.Context(), ghUser.GithubID)
|
isreg, err := h.repo.IsRegistered(c.Request.Context(), ghUser.GithubID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Database check failed: " + err.Error())
|
h.logger.Error("Database check failed: " + err.Error())
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/login?error=database_error")
|
c.Redirect(302, h.frontendURL+"/login?error=database_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +121,7 @@ func (h *AuthHandlers) CallbackGithub(c *gin.Context) {
|
|||||||
id, err = h.repo.Register(c.Request.Context(), ghUser)
|
id, err = h.repo.Register(c.Request.Context(), ghUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Registration failed: " + err.Error())
|
h.logger.Error("Registration failed: " + err.Error())
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/login?error=registration_failed")
|
c.Redirect(302, h.frontendURL+"/login?error=registration_failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -124,7 +129,7 @@ func (h *AuthHandlers) CallbackGithub(c *gin.Context) {
|
|||||||
user, err := h.repo.GetUserByGithubID(c.Request.Context(), ghUser.GithubID)
|
user, err := h.repo.GetUserByGithubID(c.Request.Context(), ghUser.GithubID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to fetch user: " + err.Error())
|
h.logger.Error("Failed to fetch user: " + err.Error())
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/login?error=user_fetch_failed")
|
c.Redirect(302, h.frontendURL+"/login?error=user_fetch_failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id = user.ID
|
id = user.ID
|
||||||
@@ -144,13 +149,13 @@ func (h *AuthHandlers) CallbackGithub(c *gin.Context) {
|
|||||||
jwtToken, err := auth.GenerateJWT(user)
|
jwtToken, err := auth.GenerateJWT(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("JWT generation failed: " + err.Error())
|
h.logger.Error("JWT generation failed: " + err.Error())
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/login?error=token_failed")
|
c.Redirect(302, h.frontendURL+"/login?error=token_failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("Authentication successful for user: " + ghUser.GithubLogin)
|
h.logger.Info("Authentication successful for user: " + ghUser.GithubLogin)
|
||||||
|
|
||||||
c.Redirect(302, "https://d3m0k1d.ru/auth/callback#token="+jwtToken)
|
c.Redirect(302, h.frontendURL+"/auth/callback#token="+jwtToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSession godoc
|
// GetSession godoc
|
||||||
|
|||||||
72
backend/internal/handlers/comments_handlers.go
Normal file
72
backend/internal/handlers/comments_handlers.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/logger"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommentsHandlers struct {
|
||||||
|
logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCommentsHandlers() *CommentsHandlers {
|
||||||
|
return &CommentsHandlers{logger: logger.New(false)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllComments godoc
|
||||||
|
// @Summary Get all comments
|
||||||
|
// @Description Get all comments
|
||||||
|
// @Tags comments
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} models.SuccessResponse{data=[]storage.Comment}
|
||||||
|
// @Failure 404 {object} models.ErrorResponse "No Comment found"
|
||||||
|
// @Failure 500 {object} models.ErrorResponse "Internal server error"
|
||||||
|
// @Router /comments [get]
|
||||||
|
func (h *CommentsHandlers) GetAllComments(c *gin.Context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommentsOfPost godoc
|
||||||
|
// @Summary Get comments of post
|
||||||
|
// @Description Get comments of post
|
||||||
|
// @Tags comments
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Post ID"
|
||||||
|
// @Success 200 {object} models.SuccessResponse{data=[]storage.Comment}
|
||||||
|
// @Failure 404 {object} models.ErrorResponse "No Comment found"
|
||||||
|
// @Failure 500 {object} models.ErrorResponse "Internal server error"
|
||||||
|
// @Router /posts/{id}/comments [get]
|
||||||
|
func (h *CommentsHandlers) GetCommentsOfPost(c *gin.Context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateComment godoc
|
||||||
|
// @Summary Create comment
|
||||||
|
// @Description Create new comment
|
||||||
|
// @Tags comments
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param comment body storage.CommentCreate true "Comment data"
|
||||||
|
// @Security Bearer
|
||||||
|
// @Success 200 {object} models.SuccessResponse{data=storage.Comment}
|
||||||
|
// @Failure 400 {object} models.ErrorResponse "Invalid request"
|
||||||
|
// @Failure 500 {object} models.ErrorResponse "Internal server error"
|
||||||
|
// @Router /comments [post]
|
||||||
|
func (h *CommentsHandlers) CreateComment(c *gin.Context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComment godoc
|
||||||
|
// @Summary Delete comment
|
||||||
|
// @Description Delete comment
|
||||||
|
// @Tags admin
|
||||||
|
// @Security Bearer
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Comment ID"
|
||||||
|
// @Success 200 {object} models.SuccessResponse{data=storage.Comment}
|
||||||
|
// @Failure 400 {object} models.ErrorResponse "Invalid ID format"
|
||||||
|
// @Failure 404 {object} models.ErrorResponse "Comment not found"
|
||||||
|
// @Failure 500 {object} models.ErrorResponse "Internal server error"
|
||||||
|
// @Router admin/comments/{id} [delete]
|
||||||
|
func (h *CommentsHandlers) DeleteComment(c *gin.Context) {
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ func NewPostHandlers(repo repositories.PostRepository) *PostHandlers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPosts godoc
|
// GetPosts godoc
|
||||||
// @Summary Get all posts
|
// @Summary Get all published posts
|
||||||
// @Description Get all posts
|
// @Description Get all posts
|
||||||
// @Tags posts
|
// @Tags posts
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -110,6 +110,7 @@ func (h *PostHandlers) CreatePost(c *gin.Context) {
|
|||||||
post := storage.Post{
|
post := storage.Post{
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
|
Tags: req.Tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.repo.Create(c.Request.Context(), post); err != nil {
|
if err := h.repo.Create(c.Request.Context(), post); err != nil {
|
||||||
@@ -192,3 +193,32 @@ func (h *PostHandlers) DeletePost(c *gin.Context) {
|
|||||||
models.Success(c, "Post deleted")
|
models.Success(c, "Post deleted")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPosts godoc
|
||||||
|
// @Summary Get all posts
|
||||||
|
// @Description Get all posts
|
||||||
|
// @Tags admin
|
||||||
|
// @Security Bearer
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} models.SuccessResponse{data=[]storage.PostReq}
|
||||||
|
// @Failure 404 {object} models.ErrorResponse "No Post found"
|
||||||
|
// @Failure 500 {object} models.ErrorResponse "Internal server error"
|
||||||
|
// @Failure 400 {object} models.ErrorResponse "Invalid ID format"
|
||||||
|
// @Router /admin/posts [get]
|
||||||
|
func (h *PostHandlers) AdminGetAll(c *gin.Context) {
|
||||||
|
var result []storage.PostReq
|
||||||
|
result, err := h.repo.GetAllAdmin(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("error request: " + err.Error())
|
||||||
|
models.Error(c, 500, "Internal server error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
models.Error(c, 404, "No Post found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info("200 OK GET /posts")
|
||||||
|
models.Success(c, result)
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ import (
|
|||||||
func Register(router *gin.Engine, db *sql.DB) {
|
func Register(router *gin.Engine, db *sql.DB) {
|
||||||
handler_posts := NewPostHandlers(repositories.NewPostRepository(db))
|
handler_posts := NewPostHandlers(repositories.NewPostRepository(db))
|
||||||
handler_auth := NewAuthHandlers(repositories.NewAuthRepository(db))
|
handler_auth := NewAuthHandlers(repositories.NewAuthRepository(db))
|
||||||
|
handler_static := NewStaticHandlers()
|
||||||
router.GET("/health", func(c *gin.Context) { c.Status(200) })
|
router.GET("/health", func(c *gin.Context) { c.Status(200) })
|
||||||
v1 := router.Group("api/v1")
|
v1 := router.Group("api/v1")
|
||||||
|
admin := v1.Group("admin")
|
||||||
|
{
|
||||||
|
admin.GET("/posts", auth.JWTMiddleware(), auth.RequireAdmin(), handler_posts.AdminGetAll)
|
||||||
|
}
|
||||||
|
v1.Static("/uploads", "/data/uploads")
|
||||||
|
v1.POST("/upload", auth.JWTMiddleware(), auth.RequireAdmin(), handler_static.PostStatic)
|
||||||
|
v1.GET("/upload/:file", handler_static.GetStatic)
|
||||||
v1.GET("/callback/github", handler_auth.CallbackGithub)
|
v1.GET("/callback/github", handler_auth.CallbackGithub)
|
||||||
v1.GET("/auth/github", handler_auth.LoginGithub)
|
v1.GET("/auth/github", handler_auth.LoginGithub)
|
||||||
v1.GET("/session", auth.JWTMiddleware(), handler_auth.GetSession)
|
v1.GET("/session", auth.JWTMiddleware(), handler_auth.GetSession)
|
||||||
|
|||||||
91
backend/internal/handlers/static_handlers.go
Normal file
91
backend/internal/handlers/static_handlers.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/logger"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StaticHandlers struct {
|
||||||
|
logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStaticHandlers() *StaticHandlers {
|
||||||
|
return &StaticHandlers{
|
||||||
|
logger: logger.New(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostStatic godoc
|
||||||
|
// @Summary Upload static content
|
||||||
|
// @Description Upload static content to the server
|
||||||
|
// @Tags static
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} models.SuccessResponse(data=string) "Static content"
|
||||||
|
// @Failure 500 {object} models.ErrorResponse "Internal server error"
|
||||||
|
// @Router /upload [post]
|
||||||
|
func (h *StaticHandlers) PostStatic(c *gin.Context) {
|
||||||
|
content, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("error request: " + err.Error())
|
||||||
|
models.Error(c, 500, "Internal server error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := "/data/upload/" + content.Filename
|
||||||
|
if err = c.SaveUploadedFile(content, dst); err != nil {
|
||||||
|
h.logger.Error("error request: " + err.Error())
|
||||||
|
models.Error(c, 500, "Internal server error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Success(c, "Static content saved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatic godoc
|
||||||
|
// @Summary Get static content
|
||||||
|
// @Description Get static content
|
||||||
|
// @Tags static
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param file path string true "File name"
|
||||||
|
// @Success 200 {object} models.SuccessResponse{data=string} "Static content"
|
||||||
|
// @Failure 500 {object} models.ErrorResponse "Internal server error"
|
||||||
|
// @Failure 404 {object} models.ErrorResponse "File not found"
|
||||||
|
// @Router /upload/{file} [get]
|
||||||
|
func (h *StaticHandlers) GetStatic(c *gin.Context) {
|
||||||
|
|
||||||
|
filename := c.Param("file")
|
||||||
|
if filename == "" {
|
||||||
|
models.Error(c, 404, "File not found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename = filepath.Clean(filename)
|
||||||
|
|
||||||
|
if strings.Contains(filename, "..") {
|
||||||
|
models.Error(c, 400, "Invalid file path", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.IsAbs(filename) {
|
||||||
|
models.Error(c, 400, "Invalid file path", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := "/data/upload/"
|
||||||
|
fullPath := filepath.Join(baseDir, filename)
|
||||||
|
if !strings.HasPrefix(fullPath, baseDir) {
|
||||||
|
models.Error(c, 400, "Invalid file path", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||||
|
models.Error(c, 404, "File not found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.File(fullPath)
|
||||||
|
}
|
||||||
86
backend/internal/repositories/comments_repository.go
Normal file
86
backend/internal/repositories/comments_repository.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/logger"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commentsRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCommentsRepository(db *sql.DB) CommentRepository {
|
||||||
|
return &commentsRepository{
|
||||||
|
db: db,
|
||||||
|
logger: logger.New(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commentsRepository) CreateComment(
|
||||||
|
ctx context.Context,
|
||||||
|
comment *storage.CommentCreate,
|
||||||
|
) error {
|
||||||
|
_, err := c.db.Exec(
|
||||||
|
"INSERT INTO comments(content, post_id) VALUES(?, ?, ?)",
|
||||||
|
comment.Content,
|
||||||
|
comment.PostID,
|
||||||
|
comment.UserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Error("error insert: " + err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commentsRepository) GetAllComments(ctx context.Context) ([]storage.Comment, error) {
|
||||||
|
var result []storage.Comment
|
||||||
|
rows, err := c.db.Query("SELECT id, content, post_id, user_id, created_at FROM comments")
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Error("error scan " + err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var content string
|
||||||
|
var postID int
|
||||||
|
var userID int
|
||||||
|
var createdAt string
|
||||||
|
err := rows.Scan(&id, &content, &postID, &userID, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Error("error scan: " + err.Error())
|
||||||
|
}
|
||||||
|
result = append(result, storage.Comment{
|
||||||
|
ID: id,
|
||||||
|
Content: content,
|
||||||
|
PostID: postID,
|
||||||
|
UserID: userID,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commentsRepository) GetCommentsOfPost(
|
||||||
|
ctx context.Context,
|
||||||
|
id int,
|
||||||
|
) ([]storage.Comment, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commentsRepository) DeleteComment(ctx context.Context, id int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commentsRepository) UpdateComment(
|
||||||
|
ctx context.Context,
|
||||||
|
id int,
|
||||||
|
comment *storage.Comment,
|
||||||
|
) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
type PostRepository interface {
|
type PostRepository interface {
|
||||||
GetAll(ctx context.Context) ([]storage.PostReq, error)
|
GetAll(ctx context.Context) ([]storage.PostReq, error)
|
||||||
|
GetAllAdmin(ctx context.Context) ([]storage.PostReq, error)
|
||||||
GetByID(ctx context.Context, id int) (storage.PostReq, error)
|
GetByID(ctx context.Context, id int) (storage.PostReq, error)
|
||||||
GetLastID(ctx context.Context) (int, error)
|
GetLastID(ctx context.Context) (int, error)
|
||||||
IsExist(ctx context.Context, id int) bool
|
IsExist(ctx context.Context, id int) bool
|
||||||
@@ -21,3 +22,11 @@ type AuthRepository interface {
|
|||||||
IsRegistered(ctx context.Context, github_id int) (bool, error)
|
IsRegistered(ctx context.Context, github_id int) (bool, error)
|
||||||
GetUserByGithubID(ctx context.Context, githubID int) (*storage.User, error)
|
GetUserByGithubID(ctx context.Context, githubID int) (*storage.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CommentRepository interface {
|
||||||
|
CreateComment(ctx context.Context, comment *storage.CommentCreate) error
|
||||||
|
GetAllComments(ctx context.Context) ([]storage.Comment, error)
|
||||||
|
GetCommentsOfPost(ctx context.Context, id int) ([]storage.Comment, error)
|
||||||
|
DeleteComment(ctx context.Context, id int) error
|
||||||
|
UpdateComment(ctx context.Context, id int, comment *storage.Comment) error
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repositories
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/logger"
|
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/logger"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/storage"
|
"gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/storage"
|
||||||
@@ -21,7 +22,7 @@ func NewPostRepository(db *sql.DB) PostRepository {
|
|||||||
}
|
}
|
||||||
func (p *postRepository) GetAll(ctx context.Context) ([]storage.PostReq, error) {
|
func (p *postRepository) GetAll(ctx context.Context) ([]storage.PostReq, error) {
|
||||||
var result []storage.PostReq
|
var result []storage.PostReq
|
||||||
rows, err := p.db.Query("SELECT id, title, content FROM posts")
|
rows, err := p.db.Query("SELECT id, title, content, CREATED_AT FROM posts WHERE published = 1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Error(err.Error())
|
p.logger.Error(err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -30,14 +31,16 @@ func (p *postRepository) GetAll(ctx context.Context) ([]storage.PostReq, error)
|
|||||||
var title string
|
var title string
|
||||||
var content string
|
var content string
|
||||||
var id int
|
var id int
|
||||||
err := rows.Scan(&id, &title, &content)
|
var createdAt string
|
||||||
|
err := rows.Scan(&id, &title, &content, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Error("error scan: " + err.Error())
|
p.logger.Error("error scan: " + err.Error())
|
||||||
}
|
}
|
||||||
result = append(result, storage.PostReq{
|
result = append(result, storage.PostReq{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
CreatedAt: createdAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -45,26 +48,32 @@ func (p *postRepository) GetAll(ctx context.Context) ([]storage.PostReq, error)
|
|||||||
|
|
||||||
func (p *postRepository) GetByID(ctx context.Context, id int) (storage.PostReq, error) {
|
func (p *postRepository) GetByID(ctx context.Context, id int) (storage.PostReq, error) {
|
||||||
var result storage.PostReq
|
var result storage.PostReq
|
||||||
row := p.db.QueryRow("SELECT title, content FROM posts WHERE id = ?", id)
|
row := p.db.QueryRow(
|
||||||
|
"SELECT title, content, CREATED_AT FROM posts WHERE id = ? AND published = 1",
|
||||||
|
id,
|
||||||
|
)
|
||||||
var title string
|
var title string
|
||||||
var content string
|
var content string
|
||||||
err := row.Scan(&title, &content)
|
var createdAt string
|
||||||
|
err := row.Scan(&title, &content, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Error("error scan: " + err.Error())
|
p.logger.Error("error scan: " + err.Error())
|
||||||
}
|
}
|
||||||
result = storage.PostReq{
|
result = storage.PostReq{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
CreatedAt: createdAt,
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postRepository) Create(ctx context.Context, post storage.Post) error {
|
func (p *postRepository) Create(ctx context.Context, post storage.Post) error {
|
||||||
query, err := p.db.Exec(
|
query, err := p.db.Exec(
|
||||||
"INSERT INTO posts(title, content) VALUES(?, ?)",
|
"INSERT INTO posts(title, content) VALUES(?, ?, ?)",
|
||||||
post.Title,
|
post.Title,
|
||||||
post.Content,
|
post.Content,
|
||||||
|
post.Tags,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -78,17 +87,32 @@ func (p *postRepository) Create(ctx context.Context, post storage.Post) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *postRepository) Update(ctx context.Context, id int, post storage.Post) error {
|
func (p *postRepository) Update(ctx context.Context, id int, post storage.Post) error {
|
||||||
_, err := p.db.Exec(
|
query := "UPDATE posts SET "
|
||||||
"UPDATE posts SET title = ?, content = ? WHERE id = ?",
|
args := []interface{}{}
|
||||||
post.Title,
|
updates := []string{}
|
||||||
post.Content,
|
|
||||||
id,
|
if post.Title != "" {
|
||||||
)
|
updates = append(updates, "title = ?")
|
||||||
if err != nil {
|
args = append(args, post.Title)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
p.logger.Info("Post updated:", "id", id)
|
if post.Tags != "" {
|
||||||
return nil
|
updates = append(updates, "tags = ?")
|
||||||
|
args = append(args, post.Tags)
|
||||||
|
}
|
||||||
|
if post.Content != "" {
|
||||||
|
updates = append(updates, "content = ?")
|
||||||
|
args = append(args, post.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = append(updates, "published = ?")
|
||||||
|
args = append(args, post.Published)
|
||||||
|
updates = append(updates, "updated_at = CURRENT_TIMESTAMP")
|
||||||
|
query += strings.Join(updates, ", ")
|
||||||
|
query += " WHERE id = ?"
|
||||||
|
args = append(args, id)
|
||||||
|
|
||||||
|
_, err := p.db.ExecContext(ctx, query, args...)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postRepository) Delete(ctx context.Context, id int) error {
|
func (p *postRepository) Delete(ctx context.Context, id int) error {
|
||||||
@@ -122,3 +146,31 @@ func (p *postRepository) IsExist(ctx context.Context, id int) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *postRepository) GetAllAdmin(ctx context.Context) ([]storage.PostReq, error) {
|
||||||
|
result := []storage.PostReq{}
|
||||||
|
rows, err := p.db.Query("SELECT id, title, content, tags, CREATED_AT FROM posts")
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Error(err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var title string
|
||||||
|
var content string
|
||||||
|
var id int
|
||||||
|
var tags string
|
||||||
|
var createdAt string
|
||||||
|
err := rows.Scan(&id, &title, &content, &tags, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Error("error scan: " + err.Error())
|
||||||
|
}
|
||||||
|
result = append(result, storage.PostReq{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
Tags: tags,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ const Migrations = `
|
|||||||
CREATE TABLE IF NOT EXISTS posts(
|
CREATE TABLE IF NOT EXISTS posts(
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
|
published BOOLEAN DEFAULT 0,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP
|
CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME,
|
||||||
|
tags TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users(
|
CREATE TABLE IF NOT EXISTS users(
|
||||||
@@ -15,4 +18,14 @@ CREATE TABLE IF NOT EXISTS users(
|
|||||||
github_login TEXT,
|
github_login TEXT,
|
||||||
avatar_url TEXT
|
avatar_url TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
post_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
|
// Post
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID int `db:"id" json:"id"`
|
ID int `db:"id" json:"id"`
|
||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
|
Published bool `db:"published" json:"published"`
|
||||||
Content string `db:"content" json:"content"`
|
Content string `db:"content" json:"content"`
|
||||||
CreatedAt string `db:"created_at" json:"created_at"`
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
|
Tags string `db:"tags" json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostReq struct {
|
type PostReq struct {
|
||||||
ID int `db:"id" json:"id"`
|
ID int `db:"id" json:"id"`
|
||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
Content string `db:"content" json:"content"`
|
Content string `db:"content" json:"content"`
|
||||||
|
CreatedAt string `db:"created" json:"created_at"`
|
||||||
|
Tags string `db:"tags" json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostCreate struct {
|
type PostCreate struct {
|
||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
Content string `db:"content" json:"content"`
|
Published bool `db:"published" json:"published"`
|
||||||
|
Content string `db:"content" json:"content"`
|
||||||
|
Tags string `db:"tags" json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `db:"id" json:"id"`
|
ID int `db:"id" json:"id"`
|
||||||
Email string `db:"email" json:"email"`
|
Email string `db:"email" json:"email"`
|
||||||
@@ -32,3 +40,26 @@ type UserReg struct {
|
|||||||
GithubLogin string `json:"login"`
|
GithubLogin string `json:"login"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comment
|
||||||
|
type Comment struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
PostID int `db:"post_id" json:"post_id"`
|
||||||
|
UserID int `db:"user_id" json:"user_id"`
|
||||||
|
Content string `db:"content" json:"content"`
|
||||||
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentReq struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
PostID int `db:"post_id" json:"post_id"`
|
||||||
|
UserID int `db:"user_id" json:"user_id"`
|
||||||
|
Content string `db:"content" json:"content"`
|
||||||
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentCreate struct {
|
||||||
|
PostID int `db:"post_id" json:"post_id"`
|
||||||
|
UserID int `db:"user_id" json:"user_id"`
|
||||||
|
Content string `db:"content" json:"content"`
|
||||||
|
}
|
||||||
|
|||||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Makefile
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
@@ -8,8 +8,19 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://d3m0k1d-backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Кэширование статики
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
|
|||||||
1772
frontend/package-lock.json
generated
1772
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"daisyui": "^5.5.14",
|
"daisyui": "^5.5.14",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
color: oklch(var(--bc));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: oklch(var(--bc));
|
||||||
|
background-color: oklch(var(--b1));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
|
// frontend/src/App.tsx
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext.tsx";
|
||||||
import Navigation from "./components/Navigation.tsx";
|
import Navigation from "./components/Navigation.tsx";
|
||||||
import Footer from "./components/Footer.tsx";
|
import Footer from "./components/Footer.tsx";
|
||||||
import AuthCallback from "./components/AuthCallback.tsx";
|
import AuthCallback from "./components/AuthCallback.tsx";
|
||||||
import Home from "./pages/Home.tsx";
|
import Home from "./pages/Home.tsx";
|
||||||
import About from "./components/Skills.tsx";
|
import About from "./components/Skills.tsx";
|
||||||
import Login from "./pages/Login.tsx";
|
import Login from "./pages/Login.tsx";
|
||||||
|
import Admin from "./pages/Admin.tsx";
|
||||||
|
import Upload from "./pages/Upload.tsx";
|
||||||
|
import Blog from "./pages/Blog.tsx";
|
||||||
|
import BlogPost from "./pages/BlogPost.tsx";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -14,27 +20,34 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<div className="min-h-screen flex flex-col">
|
<BrowserRouter>
|
||||||
<Navigation />
|
<div className="min-h-screen flex flex-col">
|
||||||
<main className="flex-grow">
|
<Navigation />
|
||||||
<Routes>
|
<main className="flex-grow">
|
||||||
<Route
|
<Routes>
|
||||||
path="/"
|
<Route
|
||||||
element={
|
path="/"
|
||||||
<>
|
element={
|
||||||
<Home />
|
<>
|
||||||
<About />
|
<Home />
|
||||||
</>
|
<About />
|
||||||
}
|
</>
|
||||||
/>
|
}
|
||||||
<Route path="/login" element={<Login />} />
|
/>
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/blog" element={<Blog />} />
|
||||||
</Routes>
|
<Route path="/blog/:id" element={<BlogPost />} />{" "}
|
||||||
</main>
|
{/* Новый роут */}
|
||||||
<Footer />
|
<Route path="/login" element={<Login />} />
|
||||||
</div>
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
</BrowserRouter>
|
<Route path="/admin" element={<Admin />} />
|
||||||
|
<Route path="/admin/upload" element={<Upload />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
|
||||||
export default function AuthCallback() {
|
export default function AuthCallback() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { checkAuth } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash.substring(1);
|
const processAuth = async () => {
|
||||||
const params = new URLSearchParams(hash);
|
const hash = window.location.hash.substring(1);
|
||||||
const token = params.get("token");
|
const params = new URLSearchParams(hash);
|
||||||
|
const token = params.get("token");
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem("auth_token", token);
|
localStorage.setItem("auth_token", token);
|
||||||
|
console.log("Token saved, loading user...");
|
||||||
|
|
||||||
navigate("/");
|
await checkAuth();
|
||||||
} else {
|
|
||||||
navigate("/login?error=no_token");
|
window.location.href = "/";
|
||||||
}
|
} else {
|
||||||
}, [navigate]);
|
console.error("No token in URL");
|
||||||
|
navigate("/login?error=no_token");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processAuth();
|
||||||
|
}, [navigate, checkAuth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="fixed inset-0 bg-black flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="w-12 h-12 border-2 border-gray-800 border-t-[hsl(270,73%,63%)] rounded-full animate-spin"></div>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[hsl(270,73%,63%)] mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-400">Completing authentication...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,15 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
|
||||||
interface User {
|
function AccountAvatar() {
|
||||||
name?: string;
|
const { user, isLoading, logout } = useAuth();
|
||||||
email?: string;
|
|
||||||
avatar?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Navigation() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem("auth_token");
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch("/api/v1/auth/session", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setUser(data.user);
|
|
||||||
console.log("User loaded:", data.user);
|
|
||||||
} else {
|
|
||||||
console.error("Token invalid, removing");
|
|
||||||
localStorage.removeItem("auth_token");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Auth check failed:", error);
|
|
||||||
localStorage.removeItem("auth_token");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("auth_token");
|
logout();
|
||||||
setUser(null);
|
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitials = (user: User): string => {
|
const getInitials = (user: { name?: string; email?: string }): string => {
|
||||||
if (user.name) {
|
if (user.name) {
|
||||||
return user.name.substring(0, 2).toUpperCase();
|
return user.name.substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -62,70 +19,73 @@ export default function Navigation() {
|
|||||||
return "?";
|
return "?";
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountAvatar = () => {
|
if (isLoading) {
|
||||||
if (isLoading) {
|
return <div className="w-10 h-10 rounded-full bg-gray-200 animate-pulse" />;
|
||||||
return (
|
}
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-200 animate-pulse" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user ? (
|
{user ? (
|
||||||
<div
|
<div
|
||||||
className="relative cursor-pointer group"
|
className="relative cursor-pointer group"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
title={`Logout (${user.name || user.email})`}
|
title={`Logout (${user.name || user.email})`}
|
||||||
>
|
>
|
||||||
{user.avatar ? (
|
{user.avatar ? (
|
||||||
<img
|
<img
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
alt={user.name || user.email || "User"}
|
alt={user.name || user.email || "User"}
|
||||||
className="w-10 h-10 rounded-full object-cover border-2 border-[hsl(270,73%,63%)] group-hover:border-red-500 transition-colors"
|
className="w-10 h-10 rounded-full object-cover border-2 border-[hsl(270,73%,63%)] group-hover:border-red-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-semibold text-sm group-hover:from-red-500 group-hover:to-red-600 transition-colors">
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-semibold text-sm group-hover:from-red-500 group-hover:to-red-600 transition-colors">
|
||||||
{getInitials(user)}
|
{getInitials(user)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Tooltip при наведении */}
|
|
||||||
<div className="absolute top-12 right-0 bg-black text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
|
||||||
Click to logout
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-12 right-0 bg-black text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||||
|
Click to logout
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<a
|
) : (
|
||||||
href="/login"
|
<a
|
||||||
className="w-10 h-10 rounded-full bg-gray-300 flex items-center justify-center hover:bg-gray-400 transition-colors"
|
href="/login"
|
||||||
|
className="w-10 h-10 rounded-full bg-gray-300 flex items-center justify-center hover:bg-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
className="w-6 h-6 text-gray-600"
|
strokeLinecap="round"
|
||||||
fill="none"
|
strokeLinejoin="round"
|
||||||
stroke="currentColor"
|
strokeWidth={2}
|
||||||
viewBox="0 0 24 24"
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
>
|
/>
|
||||||
<path
|
</svg>
|
||||||
strokeLinecap="round"
|
</a>
|
||||||
strokeLinejoin="round"
|
)}
|
||||||
strokeWidth={2}
|
</>
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
);
|
||||||
/>
|
}
|
||||||
</svg>
|
|
||||||
</a>
|
export default function Navigation() {
|
||||||
)}
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
</>
|
const { user, logout } = useAuth();
|
||||||
);
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Account Avatar - Fixed position, synced with nav */}
|
|
||||||
<div className="hidden md:block fixed right-4 lg:right-8 top-3 z-50">
|
<div className="hidden md:block fixed right-4 lg:right-8 top-3 z-50">
|
||||||
<AccountAvatar />
|
<AccountAvatar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="sticky top-0 z-50 py-3">
|
<nav className="sticky top-0 z-50 py-3">
|
||||||
{/* Desktop Navigation */}
|
|
||||||
<div className="hidden md:flex gap-8 lg:gap-12 justify-center">
|
<div className="hidden md:flex gap-8 lg:gap-12 justify-center">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
@@ -147,7 +107,6 @@ export default function Navigation() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile */}
|
|
||||||
<div className="md:hidden flex justify-between items-center px-4">
|
<div className="md:hidden flex justify-between items-center px-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
@@ -180,7 +139,6 @@ export default function Navigation() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
86
frontend/src/contexts/AuthContext.tsx
Normal file
86
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setUser(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/v1/session", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUser(data.user);
|
||||||
|
console.log("User loaded:", data.user);
|
||||||
|
} else {
|
||||||
|
console.error("Token invalid, removing");
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth check failed:", error);
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, isLoading, checkAuth, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
561
frontend/src/pages/Admin.tsx
Normal file
561
frontend/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
// frontend/src/pages/Admin.tsx
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
published: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Admin() {
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [selectedPost, setSelectedPost] = useState<Post | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [published, setPublished] = useState(false);
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
fetchPosts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
if (!token) {
|
||||||
|
navigate("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/session", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth check failed:", error);
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPosts = async () => {
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/admin/posts", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setPosts(data.data || []);
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch posts:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
alert("Title and content are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/posts/", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
published,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setPublished(false);
|
||||||
|
setSelectedPost(null);
|
||||||
|
fetchPosts();
|
||||||
|
alert("Post created successfully!");
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
alert("Failed to create post: " + error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create post:", error);
|
||||||
|
alert("Network error");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!selectedPost || !title.trim() || !content.trim()) {
|
||||||
|
alert("Title and content are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/posts/${selectedPost.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
published,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setPublished(false);
|
||||||
|
setSelectedPost(null);
|
||||||
|
fetchPosts();
|
||||||
|
alert("Post updated successfully!");
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
alert("Failed to update post: " + error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update post:", error);
|
||||||
|
alert("Network error");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm("Delete this post? This action cannot be undone.")) return;
|
||||||
|
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/posts/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
fetchPosts();
|
||||||
|
if (selectedPost?.id === id) {
|
||||||
|
setSelectedPost(null);
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setPublished(false);
|
||||||
|
}
|
||||||
|
alert("Post deleted successfully!");
|
||||||
|
} else {
|
||||||
|
alert("Failed to delete post");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete post:", error);
|
||||||
|
alert("Network error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPost = (post: Post) => {
|
||||||
|
setSelectedPost(post);
|
||||||
|
setTitle(post.title);
|
||||||
|
setContent(post.content);
|
||||||
|
setPublished(post.published);
|
||||||
|
setShowPreview(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearForm = () => {
|
||||||
|
setSelectedPost(null);
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setPublished(false);
|
||||||
|
setShowPreview(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Markdown shortcut helpers
|
||||||
|
const insertMarkdown = (syntax: string, placeholder: string = "") => {
|
||||||
|
const textarea = document.querySelector("textarea");
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end) || placeholder;
|
||||||
|
const beforeText = content.substring(0, start);
|
||||||
|
const afterText = content.substring(end);
|
||||||
|
|
||||||
|
let newText = "";
|
||||||
|
let cursorPosition = start;
|
||||||
|
|
||||||
|
if (syntax === "link") {
|
||||||
|
newText = `${beforeText}[${selectedText}](url)${afterText}`;
|
||||||
|
cursorPosition = start + selectedText.length + 3;
|
||||||
|
} else if (syntax === "image") {
|
||||||
|
newText = `${beforeText}${afterText}`;
|
||||||
|
cursorPosition = start + selectedText.length + 4;
|
||||||
|
} else if (syntax === "code") {
|
||||||
|
newText = `${beforeText}\`\`\`\n${selectedText}\n\`\`\`${afterText}`;
|
||||||
|
cursorPosition = start + 4;
|
||||||
|
} else if (syntax === "bold") {
|
||||||
|
newText = `${beforeText}**${selectedText}**${afterText}`;
|
||||||
|
cursorPosition = start + 2 + selectedText.length;
|
||||||
|
} else if (syntax === "italic") {
|
||||||
|
newText = `${beforeText}*${selectedText}*${afterText}`;
|
||||||
|
cursorPosition = start + 1 + selectedText.length;
|
||||||
|
} else if (syntax === "h1") {
|
||||||
|
newText = `${beforeText}# ${selectedText}${afterText}`;
|
||||||
|
cursorPosition = start + 2 + selectedText.length;
|
||||||
|
} else if (syntax === "h2") {
|
||||||
|
newText = `${beforeText}## ${selectedText}${afterText}`;
|
||||||
|
cursorPosition = start + 3 + selectedText.length;
|
||||||
|
} else if (syntax === "list") {
|
||||||
|
newText = `${beforeText}- ${selectedText}${afterText}`;
|
||||||
|
cursorPosition = start + 2 + selectedText.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(newText);
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(cursorPosition, cursorPosition);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white font-['Commit_Mono',monospace]">
|
||||||
|
<div className="max-w-[2000px] mx-auto px-2 sm:px-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="pt-6 pb-4 border-b border-gray-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold text-[hsl(270,73%,63%)]">
|
||||||
|
Admin Panel
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 text-xs sm:text-sm mt-1">
|
||||||
|
Blog Editor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 sm:gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/admin/upload")}
|
||||||
|
className="px-3 py-1.5 sm:px-4 sm:py-2 border border-gray-700 rounded hover:border-[hsl(270,73%,63%)] transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 sm:px-4 sm:py-2 border border-gray-700 rounded hover:border-red-500 hover:text-red-500 transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12 gap-3 sm:gap-4 mt-4">
|
||||||
|
{/* Sidebar - Posts List - Адаптивная ширина */}
|
||||||
|
<div className="col-span-12 xl:col-span-2">
|
||||||
|
<div className="xl:sticky xl:top-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-base sm:text-lg font-semibold">
|
||||||
|
Posts ({posts.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={clearForm}
|
||||||
|
className="px-2 py-1 sm:px-3 sm:py-1 bg-[hsl(270,73%,63%)] text-white rounded text-xs sm:text-sm hover:bg-[hsl(270,73%,70%)] transition-colors"
|
||||||
|
>
|
||||||
|
+ New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[300px] xl:max-h-[calc(100vh-200px)] overflow-y-auto custom-scrollbar">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<div
|
||||||
|
key={post.id}
|
||||||
|
onClick={() => selectPost(post)}
|
||||||
|
className={`p-2 sm:p-3 rounded border cursor-pointer transition-all ${
|
||||||
|
selectedPost?.id === post.id
|
||||||
|
? "border-[hsl(270,73%,63%)] bg-[hsl(270,73%,63%)]/10"
|
||||||
|
: "border-gray-800 hover:border-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-1">
|
||||||
|
<h3 className="font-medium text-xs sm:text-sm truncate flex-1">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
{post.published ? (
|
||||||
|
<span
|
||||||
|
className="text-xs text-green-500"
|
||||||
|
title="Published"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-600" title="Draft">
|
||||||
|
○
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
#{post.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 line-clamp-2 hidden sm:block">
|
||||||
|
{post.content.substring(0, 60)}...
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-1 sm:mt-2">
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{new Date(post.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(post.id);
|
||||||
|
}}
|
||||||
|
className="text-xs text-red-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Editor - Увеличенная рабочая область */}
|
||||||
|
<div className="col-span-12 xl:col-span-10">
|
||||||
|
<div className="bg-black border border-gray-800 rounded-lg">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="border-b border-gray-800 p-2 sm:p-3 flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => insertMarkdown("bold", "bold text")}
|
||||||
|
className="p-1.5 sm:p-2 hover:bg-gray-900 rounded transition-colors text-xs sm:text-sm"
|
||||||
|
title="Bold"
|
||||||
|
>
|
||||||
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertMarkdown("italic", "italic text")}
|
||||||
|
className="p-1.5 sm:p-2 hover:bg-gray-900 rounded transition-colors text-xs sm:text-sm"
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-4 sm:h-6 bg-gray-800"></div>
|
||||||
|
<button
|
||||||
|
onClick={() => insertMarkdown("h1", "Heading 1")}
|
||||||
|
className="p-1.5 sm:p-2 hover:bg-gray-900 rounded transition-colors text-xs sm:text-sm"
|
||||||
|
title="H1"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertMarkdown("h2", "Heading 2")}
|
||||||
|
className="p-1.5 sm:p-2 hover:bg-gray-900 rounded transition-colors text-xs sm:text-sm"
|
||||||
|
title="H2"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-4 sm:h-6 bg-gray-800"></div>
|
||||||
|
<button
|
||||||
|
onClick={() => insertMarkdown("link", "link text")}
|
||||||
|
className="p-1.5 sm:p-2 hover:bg-gray-900 rounded transition-colors text-xs sm:text-sm"
|
||||||
|
title="Link"
|
||||||
|
>
|
||||||
|
🔗
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertMarkdown("image", "alt text")}
|
||||||
|
className="p-1.5 sm:p-2 hover:bg-gray-900 rounded transition-colors text-xs sm:text-sm"
|
||||||
|
title="Image"
|
||||||
|
>
|
||||||
|
🖼️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertMarkdown("code", "code")}
|
||||||
|
className="p-1.5 sm:p-2 hover:bg-gray-900 rounded transition-colors text-xs sm:text-sm"
|
||||||
|
title="Code"
|
||||||
|
>
|
||||||
|
</>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertMarkdown("list", "list item")}
|
||||||
|
className="p-1.5 sm:p-2 hover:bg-gray-900 rounded transition-colors text-xs sm:text-sm"
|
||||||
|
title="List"
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
className={`px-3 py-1.5 sm:px-4 sm:py-2 rounded text-xs sm:text-sm transition-colors ${
|
||||||
|
showPreview
|
||||||
|
? "bg-[hsl(270,73%,63%)] text-white"
|
||||||
|
: "border border-gray-700 hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showPreview ? "📝 Editor" : "👁️ Preview"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="p-3 sm:p-4 md:p-6">
|
||||||
|
{!showPreview ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Post title..."
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full bg-transparent border-none outline-none text-2xl sm:text-3xl font-bold mb-4 sm:mb-6 placeholder-gray-700"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Чекбокс Published */}
|
||||||
|
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
||||||
|
<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 sm:w-5 sm:h-5 rounded border-gray-700 bg-transparent text-[hsl(270,73%,63%)] focus:ring-[hsl(270,73%,63%)] focus:ring-offset-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-xs sm:text-sm">
|
||||||
|
Publish this post
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{published
|
||||||
|
? "(Visible to everyone)"
|
||||||
|
: "(Draft - only admins)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
placeholder="Write your post content in Markdown...
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Heading
|
||||||
|
**bold** *italic*
|
||||||
|
[link](url)
|
||||||
|

|
||||||
|
\`\`\`code\`\`\`
|
||||||
|
"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
className="w-full h-[400px] sm:h-[500px] md:h-[600px] lg:h-[700px] bg-transparent border border-gray-800 rounded p-3 sm:p-4 outline-none focus:border-gray-700 transition-colors resize-none font-mono text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="prose prose-invert prose-sm sm:prose-lg max-w-none">
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold mb-4 sm:mb-6 text-[hsl(270,73%,63%)]">
|
||||||
|
{title || "Untitled Post"}
|
||||||
|
</h1>
|
||||||
|
<div className="mb-4 sm:mb-6 text-xs sm:text-sm">
|
||||||
|
{published ? (
|
||||||
|
<span className="text-green-500">✓ Published</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-yellow-500">○ Draft</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="markdown-content">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
|
>
|
||||||
|
{content || "*No content yet...*"}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t border-gray-800 p-3 sm:p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
<div className="text-xs sm:text-sm text-gray-500">
|
||||||
|
{selectedPost
|
||||||
|
? `Editing post #${selectedPost.id}`
|
||||||
|
: "Creating new post"}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 sm:gap-3 w-full sm:w-auto">
|
||||||
|
{selectedPost && (
|
||||||
|
<button
|
||||||
|
onClick={clearForm}
|
||||||
|
className="flex-1 sm:flex-none px-3 py-1.5 sm:px-4 sm:py-2 border border-gray-700 rounded hover:border-gray-600 transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={selectedPost ? handleUpdate : handleCreate}
|
||||||
|
disabled={isLoading || !title.trim() || !content.trim()}
|
||||||
|
className="flex-1 sm:flex-none px-4 py-1.5 sm:px-6 sm:py-2 bg-[hsl(270,73%,63%)] text-white rounded hover:bg-[hsl(270,73%,70%)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm font-medium"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Saving..."
|
||||||
|
: selectedPost
|
||||||
|
? "Update"
|
||||||
|
: published
|
||||||
|
? "Publish"
|
||||||
|
: "Save Draft"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Admin;
|
||||||
151
frontend/src/pages/Blog.tsx
Normal file
151
frontend/src/pages/Blog.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// frontend/src/pages/Blog.tsx
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Blog() {
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPosts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPosts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/posts");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const fetchedPosts = data.data || [];
|
||||||
|
|
||||||
|
// Сортируем от новых к старым
|
||||||
|
const sorted = fetchedPosts.sort((a: Post, b: Post) => {
|
||||||
|
const dateA = new Date(a.created_at).getTime();
|
||||||
|
const dateB = new Date(b.created_at).getTime();
|
||||||
|
return dateB - dateA; // От нового к старому
|
||||||
|
});
|
||||||
|
|
||||||
|
setPosts(sorted);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch posts:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateContent = (content: string, sentences: number = 3) => {
|
||||||
|
const plainText = content
|
||||||
|
.replace(/#{1,6}\\s/g, "")
|
||||||
|
.replace(/\\*\\*(.+?)\\*\\*/g, "$1")
|
||||||
|
.replace(/\\*(.+?)\\*/g, "$1")
|
||||||
|
.replace(/`{3}[\\s\\S]*?`{3}/g, "")
|
||||||
|
.replace(/`(.+?)`/g, "$1")
|
||||||
|
.replace(/\\[(.+?)\\]\\(.+?\\)/g, "$1")
|
||||||
|
.replace(/!\\[.*?\\]\\(.+?\\)/g, "")
|
||||||
|
.replace(/\\n+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const sentenceRegex = /[^.!?]+[.!?]+/g;
|
||||||
|
const matches = plainText.match(sentenceRegex);
|
||||||
|
|
||||||
|
if (!matches || matches.length === 0) {
|
||||||
|
if (plainText.length <= 200) return plainText;
|
||||||
|
return plainText.substring(0, 200).trim() + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = matches.slice(0, sentences).join(" ");
|
||||||
|
return truncated + (matches.length > sentences ? "..." : "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return "";
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
console.error("Invalid date from API:", dateString);
|
||||||
|
return "recent";
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||||
|
<div className="text-white text-lg font-['Commit_Mono',monospace]">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white font-['Commit_Mono',monospace]">
|
||||||
|
<div className="max-w-[900px] mx-auto px-4 sm:px-6 py-10 sm:py-16">
|
||||||
|
{/* Минимальный заголовок */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-gray-400 mb-1">
|
||||||
|
blog
|
||||||
|
</h1>
|
||||||
|
<div className="h-px w-24 bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<div className="py-16">
|
||||||
|
<p className="text-gray-500 text-sm">No posts yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-10 sm:space-y-12">
|
||||||
|
{posts.map((post, index) => (
|
||||||
|
<div key={post.id}>
|
||||||
|
<article className="flex flex-col gap-3 text-left">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-semibold text-[hsl(270,73%,63%)]">
|
||||||
|
{post.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="text-xs sm:text-sm text-gray-500">
|
||||||
|
{formatDate(post.created_at)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-300 text-sm sm:text-base leading-relaxed">
|
||||||
|
{truncateContent(post.content, 3)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/blog/${post.id}`)}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-gray-700 rounded hover:border-[hsl(270,73%,63%)] hover:text-[hsl(270,73%,63%)] transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Read more
|
||||||
|
<span className="ml-2 text-[hsl(270,73%,63%)]">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{index !== posts.length - 1 && (
|
||||||
|
<div className="mt-6 sm:mt-8">
|
||||||
|
<div className="h-px w-full bg-gradient-to-r from-[hsl(270,73%,63%)] via-gray-800 to-transparent opacity-60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Blog;
|
||||||
284
frontend/src/pages/BlogPost.tsx
Normal file
284
frontend/src/pages/BlogPost.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// frontend/src/pages/BlogPost.tsx
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
|
import { oneDark as theme } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlogPost() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [post, setPost] = useState<Post | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchPost(id);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchPost = async (postId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/posts/${postId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setPost(data.data || data);
|
||||||
|
} else {
|
||||||
|
navigate("/blog");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch post:", error);
|
||||||
|
navigate("/blog");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyCode = useCallback((code: string) => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||||
|
<div className="text-[hsl(270,73%,63%)] text-xl font-['Commit_Mono',monospace]">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||||
|
<div className="text-gray-500 text-xl font-['Commit_Mono',monospace]">
|
||||||
|
Post not found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white font-['Commit_Mono',monospace]">
|
||||||
|
<div className="max-w-[900px] mx-auto px-4 sm:px-6 py-12 sm:py-16">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/blog")}
|
||||||
|
className="flex items-center gap-2 text-gray-400 hover:text-[hsl(270,73%,63%)] transition-colors mb-8 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Back to blog page
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header className="mb-8 sm:mb-12">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[hsl(270,73%,63%)] mb-4 leading-tight">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<time dateTime={post.created_at}>
|
||||||
|
{formatDate(post.created_at)}
|
||||||
|
</time>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{Math.ceil(post.content.length / 1000)} min reading</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="blog-content">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code(props) {
|
||||||
|
const { children, className, ...rest } = props;
|
||||||
|
const match = /language-(\w+)/.exec(className || "");
|
||||||
|
const language = match ? match[1] : "text";
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return (
|
||||||
|
<code {...rest} className="inline-code">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="code-container">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
copyCode(String(children).replace(/\n$/, ""))
|
||||||
|
}
|
||||||
|
className="copy-button"
|
||||||
|
title="Copy code"
|
||||||
|
>
|
||||||
|
<span>Copy</span>
|
||||||
|
</button>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={theme}
|
||||||
|
language={language}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{
|
||||||
|
background: "rgb(12, 12, 22)",
|
||||||
|
margin: 0,
|
||||||
|
padding: "1.75rem 1.5rem 1.5rem",
|
||||||
|
borderRadius: "0 0 12px 12px",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.65",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, "")}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong className="text-white font-bold">{children}</strong>
|
||||||
|
),
|
||||||
|
em: ({ children }) => (
|
||||||
|
<em className="text-white italic">{children}</em>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.blog-content {
|
||||||
|
color: #ffffff !important;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content p {
|
||||||
|
color: #ffffff !important;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content strong {
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content ul, .blog-content ol {
|
||||||
|
color: #ffffff !important;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content li {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline код */
|
||||||
|
.inline-code {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: rgba(255,255,255,0.1) !important;
|
||||||
|
padding: 0.2em 0.4em !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
font-size: 0.875em !important;
|
||||||
|
font-family: 'Commit_Mono', monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Контейнер кода */
|
||||||
|
.code-container {
|
||||||
|
position: relative;
|
||||||
|
margin: 2em 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(145deg, rgb(8,8,18) 0%, rgb(18,18,32) 100%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимированная кнопка */
|
||||||
|
.copy-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 20;
|
||||||
|
background: linear-gradient(135deg, rgba(117,43,255,0.25) 0%, rgba(117,43,255,0.15) 100%);
|
||||||
|
border: 1px solid rgba(117,43,255,0.5);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
font-family: 'Commit_Mono', monospace;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 4px 16px rgba(117,43,255,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background: linear-gradient(135deg, hsl(270,73%,65%) 0%, hsl(270,73%,55%) 100%);
|
||||||
|
transform: translateY(-3px) scale(1.03);
|
||||||
|
box-shadow: 0 12px 30px rgba(117,43,255,0.45);
|
||||||
|
border-color: hsl(270,73%,75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:active {
|
||||||
|
transform: translateY(-1px) scale(0.98);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Заголовки */
|
||||||
|
.blog-content h1, .blog-content h2, .blog-content h3 {
|
||||||
|
color: hsl(270, 73%, 63%) !important;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content a {
|
||||||
|
color: hsl(270, 73%, 63%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.code-container {
|
||||||
|
margin: 1.5em -1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.copy-button {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogPost;
|
||||||
323
frontend/src/pages/Upload.tsx
Normal file
323
frontend/src/pages/Upload.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
// frontend/src/pages/Upload.tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
interface UploadedFile {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
uploaded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Upload() {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setSelectedFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
setSelectedFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!selectedFile) {
|
||||||
|
alert("Please select a file first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", selectedFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const newFile: UploadedFile = {
|
||||||
|
url: data.data?.url || data.url,
|
||||||
|
filename: selectedFile.name,
|
||||||
|
size: selectedFile.size,
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setUploadedFiles([newFile, ...uploadedFiles]);
|
||||||
|
setSelectedFile(null);
|
||||||
|
alert("File uploaded successfully!");
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
alert("Upload failed: " + error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Upload error:", error);
|
||||||
|
alert("Network error");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
alert("Copied to clipboard!");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white font-['Commit_Mono',monospace]">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-2 sm:px-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="pt-6 pb-4 border-b border-gray-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold text-[hsl(270,73%,63%)]">
|
||||||
|
File Upload
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 text-xs sm:text-sm mt-1">
|
||||||
|
Upload images and files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 sm:gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/admin")}
|
||||||
|
className="px-3 py-1.5 sm:px-4 sm:py-2 border border-gray-700 rounded hover:border-[hsl(270,73%,63%)] transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 sm:px-4 sm:py-2 border border-gray-700 rounded hover:border-red-500 hover:text-red-500 transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Upload Section */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Upload New File</h2>
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-all ${
|
||||||
|
dragActive
|
||||||
|
? "border-[hsl(270,73%,63%)] bg-[hsl(270,73%,63%)]/10"
|
||||||
|
: "border-gray-700 hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-12 w-12 text-gray-600"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
{selectedFile ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-[hsl(270,73%,63%)]">
|
||||||
|
{selectedFile.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
{formatFileSize(selectedFile.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-400 mb-2">
|
||||||
|
Drag and drop a file here, or click to select
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Supported: Images, Documents, Archives
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
id="file-input"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="file-input"
|
||||||
|
className="inline-block px-4 py-2 border border-gray-700 rounded hover:border-gray-600 transition-colors cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
Select File
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="flex-1 px-6 py-3 bg-[hsl(270,73%,63%)] text-white rounded hover:bg-[hsl(270,73%,70%)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||||
|
>
|
||||||
|
{isUploading ? "Uploading..." : "Upload File"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFile(null)}
|
||||||
|
className="px-4 py-3 border border-gray-700 rounded hover:border-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uploaded Files Section */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
Uploaded Files ({uploadedFiles.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3 max-h-[600px] overflow-y-auto custom-scrollbar">
|
||||||
|
{uploadedFiles.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-600">
|
||||||
|
<p>No files uploaded yet</p>
|
||||||
|
<p className="text-sm mt-2">Upload files to see them here</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
uploadedFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border border-gray-800 rounded-lg p-4 hover:border-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">
|
||||||
|
{file.filename}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{formatFileSize(file.size)} •{" "}
|
||||||
|
{new Date(file.uploaded_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview if image */}
|
||||||
|
{file.filename.match(/\.(jpg|jpeg|png|gif|webp)$/i) && (
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.filename}
|
||||||
|
className="w-full h-32 object-cover rounded mt-3 mb-3 border border-gray-800"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* URL */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={file.url}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 bg-gray-900 border border-gray-800 rounded px-3 py-2 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(file.url)}
|
||||||
|
className="px-3 py-2 border border-gray-700 rounded hover:border-gray-600 transition-colors text-xs"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Markdown */}
|
||||||
|
{file.filename.match(/\.(jpg|jpeg|png|gif|webp)$/i) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={``}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 bg-gray-900 border border-gray-800 rounded px-3 py-2 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(
|
||||||
|
``,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="px-3 py-2 border border-gray-700 rounded hover:border-gray-600 transition-colors text-xs"
|
||||||
|
>
|
||||||
|
MD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Upload;
|
||||||
Reference in New Issue
Block a user