Compare commits

..

18 Commits

Author SHA1 Message Date
9dce536a4b Merge pull request 'fix: types' (#17) from develop into master
All checks were successful
Backend deploy / deploy-backend (push) Successful in 59s
Frontend deploy / deploy-frontend (push) Successful in 1m47s
Reviewed-on: #17
2026-02-15 15:02:46 +00:00
2c3e6578e9 Merge branch 'master' into develop
All checks were successful
Backend ci / build (pull_request) Successful in 3m34s
2026-02-15 15:02:36 +00:00
d3m0k1d
69db666a4b fix: types
All checks were successful
Backend ci / build (pull_request) Successful in 3m40s
2026-02-15 18:02:06 +03:00
a4cffbed23 Merge pull request 'fix: styles for markdown' (#16) from develop into master
Some checks failed
Backend deploy / deploy-backend (push) Successful in 54s
Frontend deploy / deploy-frontend (push) Failing after 59s
Reviewed-on: #16
2026-02-15 14:47:40 +00:00
d4a18b0759 Merge branch 'master' into develop
All checks were successful
Backend ci / build (pull_request) Successful in 3m59s
2026-02-15 14:47:26 +00:00
d3m0k1d
b63e6cc3a8 fix: styles for markdown
All checks were successful
Backend ci / build (pull_request) Successful in 4m2s
2026-02-15 17:46:54 +03:00
55833665a8 Merge pull request 'develop' (#15) from develop into master
All checks were successful
Backend deploy / deploy-backend (push) Successful in 5m15s
Frontend deploy / deploy-frontend (push) Successful in 2m3s
Reviewed-on: #15
2026-02-15 13:40:08 +00:00
bc4a443f9f Merge branch 'master' into develop
All checks were successful
Backend ci / build (pull_request) Successful in 3m50s
2026-02-15 13:39:37 +00:00
d3m0k1d
78623924ef fix: linter run
All checks were successful
Backend ci / build (pull_request) Successful in 3m57s
2026-02-15 16:37:55 +03:00
d3m0k1d
7f8d8373a9 feat: full redy blog and admin panel 2026-02-15 16:34:37 +03:00
33a41ad066 Merge pull request 'fix redirect to homepage after auth and add static server for files' (#14) from develop into master
All checks were successful
Backend deploy / deploy-backend (push) Successful in 5m26s
Frontend deploy / deploy-frontend (push) Successful in 1m37s
Reviewed-on: #14
2026-02-15 10:59:33 +00:00
8d6225f136 Merge branch 'master' into develop
Some checks failed
Backend ci / build (pull_request) Failing after 3m31s
2026-02-15 10:33:35 +00:00
d3m0k1d
18b3e318ab feat: update secure on get handler for files
Some checks failed
Backend ci / build (pull_request) Failing after 3m21s
2026-02-15 13:31:28 +03:00
d3m0k1d
482e8571af feat: add static files simple server 2026-02-15 02:15:05 +03:00
d3m0k1d
51f8a125e9 feat: update logic for update query to db for posts 2026-02-15 01:11:59 +03:00
d3m0k1d
a96ef069cc feat: add published at db models and fix repo for this update 2026-02-15 00:51:29 +03:00
d3m0k1d
ea8fa90a31 fix: redirect 2026-02-15 00:22:48 +03:00
bbefe7d28a Merge pull request 'fix: path to api req' (#13) from develop into master
All checks were successful
Backend deploy / deploy-backend (push) Successful in 54s
Frontend deploy / deploy-frontend (push) Successful in 1m37s
Reviewed-on: #13
2026-02-14 21:16:19 +00:00
20 changed files with 3725 additions and 51 deletions

View File

@@ -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

View File

@@ -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"
}, },

View File

@@ -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"
}, },

View File

@@ -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.

View File

@@ -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

View File

@@ -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
@@ -192,3 +192,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)
}

View File

@@ -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)

View 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)
}

View File

@@ -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

View File

@@ -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,17 +48,22 @@ 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
} }
@@ -78,17 +86,29 @@ 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)
return nil 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 +142,27 @@ 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 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
err := rows.Scan(&id, &title, &content)
if err != nil {
p.logger.Error("error scan: " + err.Error())
}
result = append(result, storage.PostReq{
ID: id,
Title: title,
Content: content,
})
}
return result, nil
}

View File

@@ -4,8 +4,10 @@ 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
); );
CREATE TABLE IF NOT EXISTS users( CREATE TABLE IF NOT EXISTS users(

View File

@@ -3,19 +3,22 @@ package storage
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"`
} }
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"`
} }
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"`
} }
type User struct { type User struct {

File diff suppressed because it is too large Load Diff

View File

@@ -11,9 +11,14 @@
}, },
"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": {
@@ -21,6 +26,7 @@
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.14", "@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",

View File

@@ -1,3 +1,4 @@
// 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";
@@ -8,6 +9,10 @@ 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(() => {
@@ -30,8 +35,13 @@ function App() {
</> </>
} }
/> />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:id" element={<BlogPost />} />{" "}
{/* Новый роут */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/admin" element={<Admin />} />
<Route path="/admin/upload" element={<Upload />} />
</Routes> </Routes>
</main> </main>
<Footer /> <Footer />

View File

@@ -18,7 +18,7 @@ export default function AuthCallback() {
await checkAuth(); await checkAuth();
navigate("/", { replace: true }); window.location.href = "/";
} else { } else {
console.error("No token in URL"); console.error("No token in URL");
navigate("/login?error=no_token"); navigate("/login?error=no_token");

View 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}![${selectedText}](image-url)${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"
>
&lt;/&gt;
</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)
![image](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
View 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;

View 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;

View 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={`![${file.filename}](${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.filename}](${file.url})`,
)
}
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;