feat: full redy blog and admin panel

This commit is contained in:
d3m0k1d
2026-02-15 16:34:14 +03:00
parent 8d6225f136
commit 7f8d8373a9
18 changed files with 3236 additions and 39 deletions

View File

@@ -30,7 +30,7 @@ docker:
docker build -t backend . docker build -t backend .
docker-run: docker-run:
docker run --rm -p 8080:8080 --env-file .env -v /opt/d3m0k1d.ru/data:/data backend:latest 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 .

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",
@@ -565,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",
@@ -554,6 +615,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"created_at": {
"type": "string"
},
"id": { "id": {
"type": "integer" "type": "integer"
}, },

View File

@@ -43,6 +43,8 @@ definitions:
properties: properties:
content: content:
type: string type: string
created_at:
type: string
id: id:
type: integer type: integer
title: title:
@@ -51,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
@@ -123,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:

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"
@@ -19,12 +18,14 @@ 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
@@ -31,7 +31,7 @@ func NewPostHandlers(repo repositories.PostRepository) *PostHandlers {
// @Failure 400 {object} models.ErrorResponse "Invalid ID format" // @Failure 400 {object} models.ErrorResponse "Invalid ID format"
// @Router /posts [get] // @Router /posts [get]
func (h *PostHandlers) GetPosts(c *gin.Context) { func (h *PostHandlers) GetPosts(c *gin.Context) {
var result []storage.PostReq result := []storage.PostReq{}
result, err := h.repo.GetAll(c.Request.Context()) result, err := h.repo.GetAll(c.Request.Context())
if err != nil { if err != nil {
h.logger.Error("error request: " + err.Error()) h.logger.Error("error request: " + err.Error())
@@ -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) {
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

@@ -14,6 +14,10 @@ func Register(router *gin.Engine, db *sql.DB) {
handler_static := NewStaticHandlers() 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.Static("/uploads", "/data/uploads")
v1.POST("/upload", auth.JWTMiddleware(), auth.RequireAdmin(), handler_static.PostStatic) v1.POST("/upload", auth.JWTMiddleware(), auth.RequireAdmin(), handler_static.PostStatic)
v1.GET("/upload/:file", handler_static.GetStatic) v1.GET("/upload/:file", handler_static.GetStatic)

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

@@ -22,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 WHERE published = 1") 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
@@ -31,7 +31,8 @@ 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())
} }
@@ -39,6 +40,7 @@ func (p *postRepository) GetAll(ctx context.Context) ([]storage.PostReq, error)
ID: id, ID: id,
Title: title, Title: title,
Content: content, Content: content,
CreatedAt: createdAt,
}) })
} }
return result, nil return result, nil
@@ -46,10 +48,11 @@ 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 = ? AND published = 1", 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())
} }
@@ -57,6 +60,7 @@ func (p *postRepository) GetByID(ctx context.Context, id int) (storage.PostReq,
ID: id, ID: id,
Title: title, Title: title,
Content: content, Content: content,
CreatedAt: createdAt,
} }
return result, nil return result, nil
} }
@@ -135,3 +139,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

@@ -6,7 +6,8 @@ CREATE TABLE IF NOT EXISTS posts(
title TEXT NOT NULL, title TEXT NOT NULL,
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
); );
CREATE TABLE IF NOT EXISTS users( CREATE TABLE IF NOT EXISTS users(

View File

@@ -12,6 +12,7 @@ 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 {

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,10 @@
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"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",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"devDependencies": { "devDependencies": {

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

@@ -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,281 @@
// frontend/src/pages/BlogPost.tsx
import { useState, useEffect } from "react";
import { useParams, 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;
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",
});
};
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">
{/* Back Button */}
<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 */}
<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>
{/* Article Content */}
<div className="prose prose-invert prose-lg max-w-none blog-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
>
{post.content}
</ReactMarkdown>
</div>
</article>
</div>
<style>{`
.blog-content {
color: #ffffff;
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 {
color: #ffffff;
margin-bottom: 1.5em;
}
/* Ссылки */
.blog-content a {
color: hsl(270, 73%, 63%);
text-decoration: underline;
transition: color 0.2s;
}
.blog-content a:hover {
color: hsl(270, 73%, 70%);
}
/* Списки */
.blog-content ul,
.blog-content ol {
color: #ffffff;
margin: 1.5em 0;
padding-left: 1.5em;
}
.blog-content li {
margin-bottom: 0.5em;
}
/* Код */
.blog-content code {
background: #1a1a1a;
color: hsl(270, 73%, 63%);
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: 'Commit Mono', monospace;
}
.blog-content pre {
background: #1a1a1a;
border: 1px solid #333;
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;
border: 1px solid #333;
}
/* Таблицы */
.blog-content table {
width: 100%;
border-collapse: collapse;
margin: 1.5em 0;
}
.blog-content th,
.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;
}
/* Горизонтальная линия */
.blog-content hr {
border: none;
border-top: 1px solid #333;
margin: 2em 0;
}
/* Strong/Bold */
.blog-content strong {
color: hsl(270, 73%, 63%);
font-weight: 700;
}
/* Responsive */
@media (max-width: 640px) {
.blog-content h1 { font-size: 1.875rem; }
.blog-content h2 { font-size: 1.5rem; }
.blog-content h3 { font-size: 1.25rem; }
.blog-content h4 { font-size: 1.125rem; }
}
`}</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;