Compare commits

..

15 Commits

Author SHA1 Message Date
d3m0k1d
9094f5e278 feat: update dockerfiles and add .dockerignore 2026-02-28 23:05:29 +03:00
d3m0k1d
bb987b1f8b feat: add logic for repository 2026-02-18 13:43:00 +03:00
d3m0k1d
3fcaefba1b feat: 2026-02-18 13:42:42 +03:00
d3m0k1d
5a375d67c7 feat: fix lint and new nill functions 2026-02-18 13:30:16 +03:00
d3m0k1d
81446e56f5 feat: start create logic for repository 2026-02-18 13:28:27 +03:00
d3m0k1d
e14450f373 fix bug with font on light theme 2026-02-17 16:30:10 +03:00
d3m0k1d
0eca2b1e68 feat: start develop a comment logic 2026-02-15 19:06:17 +03: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
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
14 changed files with 505 additions and 140 deletions

6
backend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
docker-compose.yml
data/
Makefile
.env
docs/

View File

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

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

View File

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

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

View File

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

View File

@@ -70,9 +70,10 @@ func (p *postRepository) GetByID(ctx context.Context, id int) (storage.PostReq,
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
@@ -94,7 +95,10 @@ func (p *postRepository) Update(ctx context.Context, id int, post storage.Post)
updates = append(updates, "title = ?") updates = append(updates, "title = ?")
args = append(args, post.Title) args = append(args, post.Title)
} }
if post.Tags != "" {
updates = append(updates, "tags = ?")
args = append(args, post.Tags)
}
if post.Content != "" { if post.Content != "" {
updates = append(updates, "content = ?") updates = append(updates, "content = ?")
args = append(args, post.Content) args = append(args, post.Content)
@@ -145,7 +149,7 @@ func (p *postRepository) IsExist(ctx context.Context, id int) bool {
func (p *postRepository) GetAllAdmin(ctx context.Context) ([]storage.PostReq, error) { func (p *postRepository) GetAllAdmin(ctx context.Context) ([]storage.PostReq, error) {
result := []storage.PostReq{} result := []storage.PostReq{}
rows, err := p.db.Query("SELECT id, title, content FROM posts") rows, err := p.db.Query("SELECT id, title, content, tags, CREATED_AT FROM posts")
if err != nil { if err != nil {
p.logger.Error(err.Error()) p.logger.Error(err.Error())
return nil, err return nil, err
@@ -154,7 +158,9 @@ func (p *postRepository) GetAllAdmin(ctx context.Context) ([]storage.PostReq, er
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 tags string
var createdAt string
err := rows.Scan(&id, &title, &content, &tags, &createdAt)
if err != nil { if err != nil {
p.logger.Error("error scan: " + err.Error()) p.logger.Error("error scan: " + err.Error())
} }
@@ -162,6 +168,8 @@ func (p *postRepository) GetAllAdmin(ctx context.Context) ([]storage.PostReq, er
ID: id, ID: id,
Title: title, Title: title,
Content: content, Content: content,
Tags: tags,
CreatedAt: createdAt,
}) })
} }
return result, nil return result, nil

View File

@@ -7,7 +7,8 @@ CREATE TABLE IF NOT EXISTS posts(
published BOOLEAN DEFAULT 0, 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 updated_at DATETIME,
tags TEXT
); );
CREATE TABLE IF NOT EXISTS users( CREATE TABLE IF NOT EXISTS users(
@@ -17,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)
);
` `

View File

@@ -1,11 +1,13 @@
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"` 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 {
@@ -13,14 +15,17 @@ type PostReq struct {
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"` 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"`
Published bool `db:"published" json:"published"` Published bool `db:"published" json:"published"`
Content string `db:"content" json:"content"` 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"`
@@ -35,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
View File

@@ -0,0 +1,4 @@
Makefile
dist
node_modules

View File

@@ -9,10 +9,12 @@
"version": "0.0.0", "version": "0.0.0",
"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-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", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
@@ -22,6 +24,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",
@@ -267,6 +270,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1684,6 +1696,12 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -1703,6 +1721,16 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-syntax-highlighter": {
"version": "15.5.13",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2711,6 +2739,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2779,6 +2820,14 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2996,6 +3045,21 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -3527,6 +3591,20 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4628,6 +4706,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/property-information": { "node_modules/property-information": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -4744,6 +4831,42 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/react-syntax-highlighter": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz",
"integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0"
},
"engines": {
"node": ">= 16.20.2"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/refractor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/prismjs": "^1.0.0",
"hastscript": "^9.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/rehype-raw": { "node_modules/rehype-raw": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",

View File

@@ -11,10 +11,12 @@
}, },
"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-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", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
@@ -24,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,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));
} }

View File

@@ -1,9 +1,10 @@
// frontend/src/pages/BlogPost.tsx // frontend/src/pages/BlogPost.tsx
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark as theme } from "react-syntax-highlighter/dist/esm/styles/prism";
interface Post { interface Post {
id: number; id: number;
@@ -50,6 +51,10 @@ function BlogPost() {
}); });
}; };
const copyCode = useCallback((code: string) => {
navigator.clipboard.writeText(code);
}, []);
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center"> <div className="min-h-screen bg-black flex items-center justify-center">
@@ -73,7 +78,6 @@ function BlogPost() {
return ( return (
<div className="min-h-screen bg-black text-white font-['Commit_Mono',monospace]"> <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"> <div className="max-w-[900px] mx-auto px-4 sm:px-6 py-12 sm:py-16">
{/* Back Button */}
<button <button
onClick={() => navigate("/blog")} 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" className="flex items-center gap-2 text-gray-400 hover:text-[hsl(270,73%,63%)] transition-colors mb-8 text-sm sm:text-base"
@@ -94,7 +98,6 @@ function BlogPost() {
Back to blog page Back to blog page
</button> </button>
{/* Article Header */}
<article> <article>
<header className="mb-8 sm:mb-12"> <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"> <h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[hsl(270,73%,63%)] mb-4 leading-tight">
@@ -109,11 +112,59 @@ function BlogPost() {
</div> </div>
</header> </header>
{/* Article Content */} <div className="blog-content">
<div className="prose prose-invert prose-lg max-w-none blog-content">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]} 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} {post.content}
</ReactMarkdown> </ReactMarkdown>
@@ -123,155 +174,107 @@ function BlogPost() {
<style>{` <style>{`
.blog-content { .blog-content {
color: #ffffff; color: #ffffff !important;
line-height: 1.8; line-height: 1.8;
} }
/* Заголовки - фиолетовые */
.blog-content h1,
.blog-content h2,
.blog-content h3,
.blog-content h4,
.blog-content h5,
.blog-content h6 {
color: hsl(270, 73%, 63%);
font-weight: 700;
margin-top: 2em;
margin-bottom: 0.75em;
line-height: 1.3;
}
.blog-content h1 {
font-size: 2.25rem;
}
.blog-content h2 {
font-size: 1.875rem;
}
.blog-content h3 {
font-size: 1.5rem;
}
.blog-content h4 {
font-size: 1.25rem;
}
/* Параграфы - белые */
.blog-content p { .blog-content p {
color: #ffffff; color: #ffffff !important;
margin-bottom: 1.5em; margin-bottom: 1.5em;
} }
/* Ссылки */ .blog-content strong {
.blog-content a { color: #ffffff !important;
color: hsl(270, 73%, 63%); font-weight: 700 !important;
text-decoration: underline;
transition: color 0.2s;
} }
.blog-content a:hover { .blog-content ul, .blog-content ol {
color: hsl(270, 73%, 70%); color: #ffffff !important;
}
/* Списки */
.blog-content ul,
.blog-content ol {
color: #ffffff;
margin: 1.5em 0;
padding-left: 1.5em; padding-left: 1.5em;
} }
.blog-content li { .blog-content li {
margin-bottom: 0.5em; color: #ffffff !important;
} }
/* Код */ /* Inline код */
.blog-content code { .inline-code {
background: #1a1a1a; color: #ffffff !important;
color: hsl(270, 73%, 63%); background: rgba(255,255,255,0.1) !important;
padding: 0.2em 0.4em; padding: 0.2em 0.4em !important;
border-radius: 4px; border-radius: 6px !important;
font-size: 0.9em; font-size: 0.875em !important;
font-family: 'Commit Mono', monospace; font-family: 'Commit_Mono', monospace !important;
} }
.blog-content pre { /* Контейнер кода */
background: #1a1a1a; .code-container {
border: 1px solid #333; position: relative;
border-radius: 8px;
padding: 1.5em;
overflow-x: auto;
margin: 1.5em 0;
}
.blog-content pre code {
background: transparent;
padding: 0;
color: #ffffff;
}
/* Цитаты */
.blog-content blockquote {
border-left: 4px solid hsl(270, 73%, 63%);
padding-left: 1.5em;
margin: 1.5em 0;
color: #cccccc;
font-style: italic;
}
/* Картинки */
.blog-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 2em 0; margin: 2em 0;
border: 1px solid #333; 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);
} }
/* Таблицы */ /* Анимированная кнопка */
.blog-content table { .copy-button {
width: 100%; position: absolute;
border-collapse: collapse; top: 14px;
margin: 1.5em 0; right: 14px;
} z-index: 20;
background: linear-gradient(135deg, rgba(117,43,255,0.25) 0%, rgba(117,43,255,0.15) 100%);
.blog-content th, border: 1px solid rgba(117,43,255,0.5);
.blog-content td {
border: 1px solid #333;
padding: 0.75em;
text-align: left;
}
.blog-content th {
background: #1a1a1a;
color: hsl(270, 73%, 63%);
font-weight: 600;
}
.blog-content td {
color: #ffffff; 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 {
.blog-content hr { background: linear-gradient(135deg, hsl(270,73%,65%) 0%, hsl(270,73%,55%) 100%);
border: none; transform: translateY(-3px) scale(1.03);
border-top: 1px solid #333; box-shadow: 0 12px 30px rgba(117,43,255,0.45);
margin: 2em 0; border-color: hsl(270,73%,75%);
} }
/* Strong/Bold */ .copy-button:active {
.blog-content strong { transform: translateY(-1px) scale(0.98);
color: hsl(270, 73%, 63%); transition: all 0.1s;
font-weight: 700; }
/* Заголовки */
.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 */ /* Responsive */
@media (max-width: 640px) { @media (max-width: 640px) {
.blog-content h1 { font-size: 1.875rem; } .code-container {
.blog-content h2 { font-size: 1.5rem; } margin: 1.5em -1rem;
.blog-content h3 { font-size: 1.25rem; } border-radius: 0;
.blog-content h4 { font-size: 1.125rem; } border-left: none;
border-right: none;
}
.copy-button {
top: 10px;
right: 10px;
padding: 6px 12px;
font-size: 12px;
}
} }
`}</style> `}</style>
</div> </div>