Merge branch 'dev' into dev-ars
This commit is contained in:
commit
1308e265a6
30 changed files with 1224 additions and 896 deletions
|
|
@ -3,22 +3,21 @@ package handlers
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
auth "nyanimedb/auth"
|
||||
sqlc "nyanimedb/sql"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var accessSecret = []byte("my_access_secret_key")
|
||||
var refreshSecret = []byte("my_refresh_secret_key")
|
||||
|
||||
var UserDb = make(map[string]string) // TEMP: stores passwords
|
||||
|
||||
type Server struct {
|
||||
db *sqlc.Queries
|
||||
}
|
||||
|
|
@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) {
|
|||
return int32(i), err
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
params := &argon2id.Params{
|
||||
Memory: 64 * 1024,
|
||||
Iterations: 3,
|
||||
Parallelism: 2,
|
||||
SaltLength: 16,
|
||||
KeyLength: 32,
|
||||
}
|
||||
|
||||
return argon2id.CreateHash(password, params)
|
||||
}
|
||||
|
||||
func CheckPassword(password, hash string) (bool, error) {
|
||||
return argon2id.ComparePasswordAndHash(password, hash)
|
||||
}
|
||||
|
||||
func generateTokens(userID string) (accessToken string, refreshToken string, err error) {
|
||||
accessClaims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
|
|
@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err
|
|||
}
|
||||
|
||||
func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) {
|
||||
err := ""
|
||||
success := true
|
||||
UserDb[req.Body.Nickname] = req.Body.Pass
|
||||
passhash, err := HashPassword(req.Body.Pass)
|
||||
if err != nil {
|
||||
log.Errorf("failed to hash password: %v", err)
|
||||
// TODO: return 500
|
||||
}
|
||||
|
||||
user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{
|
||||
Passhash: passhash,
|
||||
Nickname: req.Body.Nickname,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("failed to create user %s: %v", req.Body.Nickname, err)
|
||||
// TODO: check err and retyrn 400/500
|
||||
}
|
||||
|
||||
return auth.PostAuthSignUp200JSONResponse{
|
||||
Error: &err,
|
||||
Success: &success,
|
||||
UserId: &req.Body.Nickname,
|
||||
UserId: user_id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) {
|
||||
// ctx.SetCookie("122")
|
||||
ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context)
|
||||
if !ok {
|
||||
log.Print("failed to get gin context")
|
||||
|
|
@ -77,27 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque
|
|||
return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context")
|
||||
}
|
||||
|
||||
err := ""
|
||||
user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err)
|
||||
// TODO: return 400/500
|
||||
}
|
||||
|
||||
pass, ok := UserDb[req.Body.Nickname]
|
||||
if !ok || pass != req.Body.Pass {
|
||||
e := "invalid credentials"
|
||||
ok, err = CheckPassword(req.Body.Pass, user.Passhash)
|
||||
if err != nil {
|
||||
log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err)
|
||||
// TODO: return 500
|
||||
}
|
||||
if !ok {
|
||||
err_msg := "invalid credentials"
|
||||
return auth.PostAuthSignIn401JSONResponse{
|
||||
Error: &e,
|
||||
Error: &err_msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
accessToken, refreshToken, _ := generateTokens(req.Body.Nickname)
|
||||
accessToken, refreshToken, err := generateTokens(req.Body.Nickname)
|
||||
if err != nil {
|
||||
log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err)
|
||||
// TODO: return 500
|
||||
}
|
||||
|
||||
// TODO: check cookie settings carefully
|
||||
ginCtx.SetSameSite(http.SameSiteStrictMode)
|
||||
ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true)
|
||||
ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true)
|
||||
ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true)
|
||||
ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true)
|
||||
|
||||
// Return access token; refresh token can be returned in response or HttpOnly cookie
|
||||
result := auth.PostAuthSignIn200JSONResponse{
|
||||
Error: &err,
|
||||
UserId: &req.Body.Nickname,
|
||||
UserName: &req.Body.Nickname,
|
||||
UserId: user.ID,
|
||||
UserName: user.Nickname,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
auth "nyanimedb/auth"
|
||||
|
|
@ -9,14 +12,22 @@ import (
|
|||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var AppConfig Config
|
||||
|
||||
func main() {
|
||||
// TODO: env args
|
||||
r := gin.Default()
|
||||
|
||||
var queries *sqlc.Queries = nil
|
||||
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var queries *sqlc.Queries = sqlc.New(pool)
|
||||
|
||||
server := handlers.NewServer(queries)
|
||||
|
||||
|
|
|
|||
11
modules/auth/queries.sql
Normal file
11
modules/auth/queries.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- name: GetUserByNickname :one
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE nickname = sqlc.arg('nickname');
|
||||
|
||||
-- name: CreateNewUser :one
|
||||
INSERT
|
||||
INTO users (passhash, nickname)
|
||||
VALUES (sqlc.arg(passhash), sqlc.arg(nickname))
|
||||
RETURNING id;
|
||||
|
||||
|
|
@ -204,7 +204,7 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o
|
|||
return oapi_usertitle, nil
|
||||
}
|
||||
|
||||
func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) {
|
||||
func (s Server) GetUserTitles(ctx context.Context, request oapi.GetUserTitlesRequestObject) (oapi.GetUserTitlesResponseObject, error) {
|
||||
|
||||
oapi_usertitles := make([]oapi.UserTitle, 0)
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
|
|||
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason)
|
||||
if err != nil {
|
||||
log.Errorf("%v", err)
|
||||
return oapi.GetUsersUserIdTitles400Response{}, err
|
||||
return oapi.GetUserTitles400Response{}, err
|
||||
}
|
||||
|
||||
// var statuses_sort []string
|
||||
|
|
@ -227,19 +227,19 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
|
|||
watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus)
|
||||
if err != nil {
|
||||
log.Errorf("%v", err)
|
||||
return oapi.GetUsersUserIdTitles400Response{}, err
|
||||
return oapi.GetUserTitles400Response{}, err
|
||||
}
|
||||
|
||||
title_statuses, err := TitleStatus2Sqlc(request.Params.Status)
|
||||
if err != nil {
|
||||
log.Errorf("%v", err)
|
||||
return oapi.GetUsersUserIdTitles400Response{}, err
|
||||
return oapi.GetUserTitles400Response{}, err
|
||||
}
|
||||
|
||||
userID, err := parseInt64(request.UserId)
|
||||
if err != nil {
|
||||
log.Errorf("get user titles: %v", err)
|
||||
return oapi.GetUsersUserIdTitles404Response{}, err
|
||||
return oapi.GetUserTitles404Response{}, err
|
||||
}
|
||||
params := sqlc.SearchUserTitlesParams{
|
||||
UserID: userID,
|
||||
|
|
@ -265,7 +265,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
|
|||
err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms)
|
||||
if err != nil {
|
||||
log.Errorf("%v", err)
|
||||
return oapi.GetUsersUserIdTitles400Response{}, nil
|
||||
return oapi.GetUserTitles400Response{}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,10 +273,10 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
|
|||
titles, err := s.db.SearchUserTitles(ctx, params)
|
||||
if err != nil {
|
||||
log.Errorf("%v", err)
|
||||
return oapi.GetUsersUserIdTitles500Response{}, nil
|
||||
return oapi.GetUserTitles500Response{}, nil
|
||||
}
|
||||
if len(titles) == 0 {
|
||||
return oapi.GetUsersUserIdTitles204Response{}, nil
|
||||
return oapi.GetUserTitles204Response{}, nil
|
||||
}
|
||||
|
||||
var new_cursor oapi.CursorObj
|
||||
|
|
@ -286,7 +286,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
|
|||
t, err := s.mapUsertitle(ctx, title)
|
||||
if err != nil {
|
||||
log.Errorf("%v", err)
|
||||
return oapi.GetUsersUserIdTitles500Response{}, nil
|
||||
return oapi.GetUserTitles500Response{}, nil
|
||||
}
|
||||
oapi_usertitles = append(oapi_usertitles, t)
|
||||
|
||||
|
|
@ -303,7 +303,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
|
|||
}
|
||||
}
|
||||
|
||||
return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil
|
||||
return oapi.GetUserTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil
|
||||
}
|
||||
|
||||
func EmailToStringPtr(e *types.Email) *string {
|
||||
|
|
@ -402,7 +402,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque
|
|||
func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) {
|
||||
params := sqlc.DeleteUserTitleParams{
|
||||
UserID: request.UserId,
|
||||
TitleID: request.Params.TitleId,
|
||||
TitleID: request.TitleId,
|
||||
}
|
||||
_, err := s.db.DeleteUserTitle(ctx, params)
|
||||
if err != nil {
|
||||
|
|
@ -427,7 +427,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl
|
|||
Status: status,
|
||||
Rate: request.Body.Rate,
|
||||
UserID: request.UserId,
|
||||
TitleID: request.Body.TitleId,
|
||||
TitleID: request.TitleId,
|
||||
}
|
||||
|
||||
user_title, err := s.db.UpdateUserTitle(ctx, params)
|
||||
|
|
@ -455,3 +455,33 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl
|
|||
|
||||
return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil
|
||||
}
|
||||
|
||||
func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleRequestObject) (oapi.GetUserTitleResponseObject, error) {
|
||||
user_title, err := s.db.GetUserTitleByID(ctx, sqlc.GetUserTitleByIDParams{
|
||||
TitleID: request.TitleId,
|
||||
UserID: request.UserId,
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return oapi.GetUserTitle404Response{}, nil
|
||||
} else {
|
||||
log.Errorf("%v", err)
|
||||
return oapi.GetUserTitle500Response{}, nil
|
||||
}
|
||||
}
|
||||
oapi_status, err := sql2usertitlestatus(user_title.Status)
|
||||
if err != nil {
|
||||
log.Errorf("%v", err)
|
||||
return oapi.GetUserTitle500Response{}, nil
|
||||
}
|
||||
oapi_usertitle := oapi.UserTitleMini{
|
||||
Ctime: &user_title.Ctime,
|
||||
Rate: user_title.Rate,
|
||||
ReviewId: user_title.ReviewID,
|
||||
Status: oapi_status,
|
||||
TitleId: user_title.TitleID,
|
||||
UserId: user_title.UserID,
|
||||
}
|
||||
|
||||
return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func main() {
|
|||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
|
|
|
|||
|
|
@ -394,4 +394,10 @@ RETURNING *;
|
|||
DELETE FROM usertitles
|
||||
WHERE user_id = sqlc.arg('user_id')
|
||||
AND title_id = sqlc.arg('title_id')
|
||||
RETURNING *;
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserTitleByID :one
|
||||
SELECT
|
||||
ut.*
|
||||
FROM usertitles as ut
|
||||
WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint;
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import React from "react";
|
||||
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import UsersIdPage from "./pages/UsersIdPage/UsersIdPage";
|
||||
import UserPage from "./pages/UserPage/UserPage";
|
||||
import TitlesPage from "./pages/TitlesPage/TitlesPage";
|
||||
import TitlePage from "./pages/TitlePage/TitlePage";
|
||||
import { LoginPage } from "./pages/LoginPage/LoginPage";
|
||||
import { Header } from "./components/Header/Header";
|
||||
|
||||
const App: React.FC = () => {
|
||||
// Получаем username из localStorage
|
||||
const username = localStorage.getItem("username") || undefined;
|
||||
const userId = localStorage.getItem("userId");
|
||||
|
||||
|
|
@ -15,17 +14,20 @@ const App: React.FC = () => {
|
|||
<Router>
|
||||
<Header username={username} />
|
||||
<Routes>
|
||||
{/* auth */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<LoginPage />} />
|
||||
|
||||
{/* /profile рендерит UsersIdPage с id из localStorage */}
|
||||
{/*<Route path="/signup" element={<LoginPage />} />*/}
|
||||
|
||||
{/* users */}
|
||||
{/*<Route path="/users" element={<UsersPage />} />*/}
|
||||
<Route path="/users/:id" element={<UserPage />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={userId ? <UsersIdPage userId={userId} /> : <LoginPage />}
|
||||
element={userId ? <UserPage userId={userId} /> : <LoginPage />}
|
||||
/>
|
||||
|
||||
<Route path="/users/:id" element={<UsersIdPage />} />
|
||||
|
||||
{/* titles */}
|
||||
<Route path="/titles" element={<TitlesPage />} />
|
||||
<Route path="/titles/:id" element={<TitlePage />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export type OpenAPIConfig = {
|
|||
};
|
||||
|
||||
export const OpenAPI: OpenAPIConfig = {
|
||||
BASE: 'http://10.1.0.65:8081/api/v1',
|
||||
BASE: '/api/v1',
|
||||
VERSION: '1.0.0',
|
||||
WITH_CREDENTIALS: false,
|
||||
CREDENTIALS: 'include',
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ export class DefaultService {
|
|||
* @returns any List of user titles
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static getUsersTitles(
|
||||
public static getUserTitles(
|
||||
userId: string,
|
||||
cursor?: string,
|
||||
sort?: TitleSort,
|
||||
|
|
@ -278,27 +278,54 @@ export class DefaultService {
|
|||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get user title
|
||||
* @param userId
|
||||
* @param titleId
|
||||
* @returns UserTitleMini User titles
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static getUserTitle(
|
||||
userId: number,
|
||||
titleId: number,
|
||||
): CancelablePromise<UserTitleMini> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/users/{user_id}/titles/{title_id}',
|
||||
path: {
|
||||
'user_id': userId,
|
||||
'title_id': titleId,
|
||||
},
|
||||
errors: {
|
||||
400: `Request params are not correct`,
|
||||
404: `User or title not found`,
|
||||
500: `Unknown server error`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Update a usertitle
|
||||
* User updating title list of watched
|
||||
* @param userId ID of the user to assign the title to
|
||||
* @param userId
|
||||
* @param titleId
|
||||
* @param requestBody
|
||||
* @returns UserTitleMini Title successfully updated
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static updateUserTitle(
|
||||
userId: number,
|
||||
titleId: number,
|
||||
requestBody: {
|
||||
title_id: number;
|
||||
status?: UserTitleStatus;
|
||||
rate?: number;
|
||||
},
|
||||
): CancelablePromise<UserTitleMini> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PATCH',
|
||||
url: '/users/{user_id}/titles',
|
||||
url: '/users/{user_id}/titles/{title_id}',
|
||||
path: {
|
||||
'user_id': userId,
|
||||
'title_id': titleId,
|
||||
},
|
||||
body: requestBody,
|
||||
mediaType: 'application/json',
|
||||
|
|
@ -311,4 +338,31 @@ export class DefaultService {
|
|||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Delete a usertitle
|
||||
* User deleting title from list of watched
|
||||
* @param userId
|
||||
* @param titleId
|
||||
* @returns any Title successfully deleted
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteUserTitle(
|
||||
userId: number,
|
||||
titleId: number,
|
||||
): CancelablePromise<any> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/users/{user_id}/titles/{title_id}',
|
||||
path: {
|
||||
'user_id': userId,
|
||||
'title_id': titleId,
|
||||
},
|
||||
errors: {
|
||||
401: `Unauthorized — missing or invalid auth token`,
|
||||
403: `Forbidden — user not allowed to delete title`,
|
||||
404: `User or Title not found`,
|
||||
500: `Internal server error`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export type OpenAPIConfig = {
|
|||
};
|
||||
|
||||
export const OpenAPI: OpenAPIConfig = {
|
||||
BASE: 'http://10.1.0.65:8081/auth',
|
||||
BASE: '/auth',
|
||||
VERSION: '1.0.0',
|
||||
WITH_CREDENTIALS: false,
|
||||
CREDENTIALS: 'include',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { DefaultService } from "../../api";
|
||||
import type { UserTitleStatus } from "../../api";
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
PlayCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
// Статусы с иконками и подписью
|
||||
const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [
|
||||
{ status: "planned", icon: <ClockIcon className="w-5 h-5" />, label: "Planned" },
|
||||
{ status: "finished", icon: <CheckCircleIcon className="w-5 h-5" />, label: "Finished" },
|
||||
{ status: "in-progress", icon: <PlayCircleIcon className="w-5 h-5" />, label: "In Progress" },
|
||||
{ status: "dropped", icon: <XCircleIcon className="w-5 h-5" />, label: "Dropped" },
|
||||
];
|
||||
|
||||
export function TitleStatusControls({ titleId }: { titleId: number }) {
|
||||
const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const userIdStr = localStorage.getItem("userId");
|
||||
const userId = userIdStr ? Number(userIdStr) : null;
|
||||
|
||||
// --- Load initial status ---
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
DefaultService.getUserTitle(userId, titleId)
|
||||
.then((res) => setCurrentStatus(res.status))
|
||||
.catch(() => setCurrentStatus(null)); // 404 = user title does not exist
|
||||
}, [titleId, userId]);
|
||||
|
||||
// --- Handle click ---
|
||||
const handleStatusClick = async (status: UserTitleStatus) => {
|
||||
if (!userId || loading) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 1) Если кликнули на текущий статус — DELETE
|
||||
if (currentStatus === status) {
|
||||
await DefaultService.deleteUserTitle(userId, titleId);
|
||||
setCurrentStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Если другой статус — POST или PATCH
|
||||
if (!currentStatus) {
|
||||
// ещё нет записи — POST
|
||||
const added = await DefaultService.addUserTitle(userId, {
|
||||
title_id: titleId,
|
||||
status,
|
||||
});
|
||||
setCurrentStatus(added.status);
|
||||
} else {
|
||||
// уже есть запись — PATCH
|
||||
const updated = await DefaultService.updateUserTitle(userId, titleId, { status });
|
||||
setCurrentStatus(updated.status);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 flex-wrap justify-center mt-2">
|
||||
{STATUS_BUTTONS.map(btn => (
|
||||
<button
|
||||
key={btn.status}
|
||||
onClick={() => handleStatusClick(btn.status)}
|
||||
disabled={loading}
|
||||
className={`
|
||||
px-3 py-1 rounded-md border flex items-center gap-1 transition
|
||||
${currentStatus === btn.status
|
||||
? "bg-blue-600 text-white border-blue-700"
|
||||
: "bg-gray-200 text-black border-gray-300 hover:bg-gray-300"}
|
||||
`}
|
||||
title={btn.label}
|
||||
>
|
||||
{btn.icon}
|
||||
<span>{btn.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,8 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { DefaultService } from "../../api/services/DefaultService";
|
||||
import type { Title, UserTitleStatus } from "../../api";
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
PlayCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [
|
||||
{ status: "planned", icon: <ClockIcon className="w-6 h-6" />, label: "Planned" },
|
||||
{ status: "finished", icon: <CheckCircleIcon className="w-6 h-6" />, label: "Finished" },
|
||||
{ status: "in-progress", icon: <PlayCircleIcon className="w-6 h-6" />, label: "In Progress" },
|
||||
{ status: "dropped", icon: <XCircleIcon className="w-6 h-6" />, label: "Dropped" },
|
||||
];
|
||||
import type { Title } from "../../api";
|
||||
import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls";
|
||||
|
||||
export default function TitlePage() {
|
||||
const params = useParams();
|
||||
|
|
@ -24,9 +12,9 @@ export default function TitlePage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [userStatus, setUserStatus] = useState<UserTitleStatus | null>(null);
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
|
||||
// ---------------------------
|
||||
// LOAD TITLE INFO
|
||||
// ---------------------------
|
||||
useEffect(() => {
|
||||
const fetchTitle = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -44,30 +32,6 @@ export default function TitlePage() {
|
|||
fetchTitle();
|
||||
}, [titleId]);
|
||||
|
||||
const handleStatusClick = async (status: UserTitleStatus) => {
|
||||
if (updatingStatus || userStatus === status) return;
|
||||
|
||||
const userId = Number(localStorage.getItem("userId"));
|
||||
if (!userId) {
|
||||
alert("You must be logged in to set status.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingStatus(true);
|
||||
try {
|
||||
await DefaultService.addUserTitle(userId, {
|
||||
title_id: titleId,
|
||||
status,
|
||||
});
|
||||
setUserStatus(status);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err?.message || "Failed to set status");
|
||||
} finally {
|
||||
setUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTagsString = () =>
|
||||
title?.tags?.map(tag => tag.en).filter(Boolean).join(", ");
|
||||
|
||||
|
|
@ -78,7 +42,7 @@ export default function TitlePage() {
|
|||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center">
|
||||
<div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6">
|
||||
{/* Постер */}
|
||||
{/* Poster + status buttons */}
|
||||
<div className="flex flex-col items-center">
|
||||
<img
|
||||
src={title.poster?.image_path || "/default-poster.png"}
|
||||
|
|
@ -86,48 +50,52 @@ export default function TitlePage() {
|
|||
className="w-48 h-72 object-cover rounded-lg mb-4"
|
||||
/>
|
||||
|
||||
{/* Статус кнопки с иконками */}
|
||||
<div className="flex gap-2 mt-2 flex-wrap justify-center">
|
||||
{STATUS_BUTTONS.map(btn => (
|
||||
<button
|
||||
key={btn.status}
|
||||
onClick={() => handleStatusClick(btn.status)}
|
||||
disabled={updatingStatus}
|
||||
className={`p-2 rounded-lg transition flex items-center justify-center ${
|
||||
userStatus === btn.status
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
title={btn.label}
|
||||
>
|
||||
{btn.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Status buttons */}
|
||||
<TitleStatusControls titleId={titleId} />
|
||||
</div>
|
||||
|
||||
{/* Информация о тайтле */}
|
||||
{/* Title info */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
{title.title_names?.en?.[0] || "Untitled"}
|
||||
</h1>
|
||||
{title.studio && <p className="text-gray-700 mb-1">Studio: {title.studio.name}</p>}
|
||||
|
||||
{title.studio && (
|
||||
<p className="text-gray-700 mb-1">
|
||||
Studio:{" "}
|
||||
{title.studio.id ? (
|
||||
<Link
|
||||
to={`/studios/${title.studio.id}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{title.studio.name}
|
||||
</Link>
|
||||
) : (
|
||||
title.studio.name
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>}
|
||||
|
||||
{title.rating !== undefined && (
|
||||
<p className="text-gray-700 mb-1">
|
||||
Rating: {title.rating} ({title.rating_count} votes)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{title.release_year && (
|
||||
<p className="text-gray-700 mb-1">
|
||||
Released: {title.release_year} {title.release_season || ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{title.episodes_aired !== undefined && (
|
||||
<p className="text-gray-700 mb-1">
|
||||
Episodes: {title.episodes_aired}/{title.episodes_all}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{title.tags && title.tags.length > 0 && (
|
||||
<p className="text-gray-700 mb-1">
|
||||
Tags: {getTagsString()}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
|
|
@ -7,6 +7,7 @@ import { TitleCardSquare } from "../../components/cards/TitleCardSquare";
|
|||
import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal";
|
||||
import type { CursorObj, Title, TitleSort } from "../../api";
|
||||
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
|
|
@ -135,11 +136,11 @@ const handleLoadMore = async () => {
|
|||
hasMore={!!cursor || nextPage.length > 1}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
renderItem={(title, layout) =>
|
||||
layout === "square"
|
||||
? <TitleCardSquare title={title} />
|
||||
: <TitleCardHorizontal title={title} />
|
||||
}
|
||||
renderItem={(title, layout) => (
|
||||
<Link to={`/titles/${title.id}`} key={title.id} className="block">
|
||||
{layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} />}
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!cursor && nextPage.length == 0 && (
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
body,
|
||||
html {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background-color: #777;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100vw;
|
||||
padding: 30px 40px;
|
||||
background: #f7f7f7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.avatarWrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
min-width: 120px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
/* margin: 25px auto; */
|
||||
/* padding: 0 20px; */
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.created {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.loader,
|
||||
.error {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
|
@ -1,67 +1,184 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom"; // <-- import
|
||||
// pages/UserPage/UserPage.tsx
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { DefaultService } from "../../api/services/DefaultService";
|
||||
import type { User } from "../../api/models/User";
|
||||
import styles from "./UserPage.module.css";
|
||||
import { SearchBar } from "../../components/SearchBar/SearchBar";
|
||||
import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox";
|
||||
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
|
||||
import { ListView } from "../../components/ListView/ListView";
|
||||
import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare";
|
||||
import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal";
|
||||
import type { User, UserTitle, CursorObj, TitleSort } from "../../api";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const UserPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>(); // <-- get user id from URL
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const getUserInfo = async () => {
|
||||
try {
|
||||
const userInfo = await DefaultService.getUsersId(id, "all"); // <-- use dynamic id
|
||||
setUser(userInfo);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Failed to fetch user info.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
getUserInfo();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className={styles.loader}>Loading...</div>;
|
||||
if (error) return <div className={styles.error}>{error}</div>;
|
||||
if (!user) return <div className={styles.error}>User not found.</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.avatarWrapper}>
|
||||
{user.image?.image_path ? (
|
||||
<img
|
||||
src={`/images/${user.image.image_path}.png`}
|
||||
alt="User Avatar"
|
||||
className={styles.avatarImg}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.avatarPlaceholder}>
|
||||
{user.disp_name?.[0] || "U"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.userInfo}>
|
||||
<h1 className={styles.name}>{user.disp_name || user.nickname}</h1>
|
||||
<p className={styles.nickname}>@{user.nickname}</p>
|
||||
{/* <p className={styles.created}>
|
||||
Joined: {new Date(user.creation_date).toLocaleDateString()}
|
||||
</p> */}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{user.user_desc && <p className={styles.desc}>{user.user_desc}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
type UserPageProps = {
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export default UserPage;
|
||||
export default function UserPage({ userId }: UserPageProps) {
|
||||
const params = useParams();
|
||||
const id = userId || params?.id;
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loadingUser, setLoadingUser] = useState(true);
|
||||
const [errorUser, setErrorUser] = useState<string | null>(null);
|
||||
|
||||
// Для списка тайтлов
|
||||
const [titles, setTitles] = useState<UserTitle[]>([]);
|
||||
const [nextPage, setNextPage] = useState<UserTitle[]>([]);
|
||||
const [cursor, setCursor] = useState<CursorObj | null>(null);
|
||||
const [loadingTitles, setLoadingTitles] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<TitleSort>("id");
|
||||
const [sortForward, setSortForward] = useState(true);
|
||||
const [layout, setLayout] = useState<"square" | "horizontal">("square");
|
||||
|
||||
// --- Получение данных пользователя ---
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
if (!id) return;
|
||||
setLoadingUser(true);
|
||||
try {
|
||||
const result = await DefaultService.getUsersId(id, "all");
|
||||
setUser(result);
|
||||
setErrorUser(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setErrorUser(err?.message || "Failed to fetch user data");
|
||||
} finally {
|
||||
setLoadingUser(false);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
// --- Получение списка тайтлов пользователя ---
|
||||
const fetchPage = async (cursorObj: CursorObj | null) => {
|
||||
if (!id) return { items: [], nextCursor: null };
|
||||
const cursorStr = cursorObj
|
||||
? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
: "";
|
||||
|
||||
try {
|
||||
const result = await DefaultService.getUserTitles(
|
||||
id,
|
||||
cursorStr,
|
||||
sort,
|
||||
sortForward,
|
||||
search.trim() || undefined,
|
||||
undefined, // status фильтр, можно добавить
|
||||
undefined, // watchStatus
|
||||
undefined, // rating
|
||||
undefined, // myRate
|
||||
undefined, // releaseYear
|
||||
undefined, // releaseSeason
|
||||
PAGE_SIZE,
|
||||
"all"
|
||||
);
|
||||
|
||||
if (!result?.data?.length) return { items: [], nextCursor: null };
|
||||
|
||||
return { items: result.data, nextCursor: result.cursor ?? null };
|
||||
} catch (err: any) {
|
||||
if (err.status === 204) return { items: [], nextCursor: null };
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Инициализация: загружаем сразу две страницы
|
||||
useEffect(() => {
|
||||
const initLoad = async () => {
|
||||
setLoadingTitles(true);
|
||||
setTitles([]);
|
||||
setNextPage([]);
|
||||
setCursor(null);
|
||||
|
||||
const firstPage = await fetchPage(null);
|
||||
const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null };
|
||||
|
||||
setTitles(firstPage.items);
|
||||
setNextPage(secondPage.items);
|
||||
setCursor(secondPage.nextCursor);
|
||||
setLoadingTitles(false);
|
||||
};
|
||||
initLoad();
|
||||
}, [id, search, sort, sortForward]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (nextPage.length === 0) {
|
||||
setLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
setLoadingMore(true);
|
||||
|
||||
setTitles(prev => [...prev, ...nextPage]);
|
||||
setNextPage([]);
|
||||
|
||||
if (cursor) {
|
||||
try {
|
||||
const next = await fetchPage(cursor);
|
||||
if (next.items.length > 0) setNextPage(next.items);
|
||||
setCursor(next.nextCursor);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center">
|
||||
|
||||
{/* --- Карточка пользователя --- */}
|
||||
{loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>}
|
||||
{errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>}
|
||||
{user && (
|
||||
<div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8">
|
||||
<img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2>
|
||||
{user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>}
|
||||
{user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>}
|
||||
{user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Панель поиска, сортировки и лейаута --- */}
|
||||
<div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center">
|
||||
<SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} />
|
||||
<LayoutSwitch layout={layout} setLayout={setLayout} />
|
||||
<TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} />
|
||||
</div>
|
||||
|
||||
{/* --- Список тайтлов --- */}
|
||||
{loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>}
|
||||
{!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>}
|
||||
|
||||
{titles.length > 0 && (
|
||||
<>
|
||||
<ListView<UserTitle>
|
||||
items={titles}
|
||||
layout={layout}
|
||||
hasMore={!!cursor || nextPage.length > 1}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
renderItem={(title, layout) => (
|
||||
<Link to={`/titles/${title.title?.id}`} key={title.title?.id} className="block">
|
||||
{layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} />}
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!cursor && nextPage.length === 0 && (
|
||||
<div className="mt-6 font-medium text-black">
|
||||
Результатов больше нет, было найдено {titles.length} тайтлов.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
// pages/UserPage/UserPage.tsx
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { DefaultService } from "../../api/services/DefaultService";
|
||||
import { SearchBar } from "../../components/SearchBar/SearchBar";
|
||||
import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox";
|
||||
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
|
||||
import { ListView } from "../../components/ListView/ListView";
|
||||
import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare";
|
||||
import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal";
|
||||
import type { User, UserTitle, CursorObj, TitleSort } from "../../api";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type UsersIdPageProps = {
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export default function UsersIdPage({ userId }: UsersIdPageProps) {
|
||||
const params = useParams();
|
||||
const id = userId || params?.id;
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loadingUser, setLoadingUser] = useState(true);
|
||||
const [errorUser, setErrorUser] = useState<string | null>(null);
|
||||
|
||||
// Для списка тайтлов
|
||||
const [titles, setTitles] = useState<UserTitle[]>([]);
|
||||
const [nextPage, setNextPage] = useState<UserTitle[]>([]);
|
||||
const [cursor, setCursor] = useState<CursorObj | null>(null);
|
||||
const [loadingTitles, setLoadingTitles] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<TitleSort>("id");
|
||||
const [sortForward, setSortForward] = useState(true);
|
||||
const [layout, setLayout] = useState<"square" | "horizontal">("square");
|
||||
|
||||
// --- Получение данных пользователя ---
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
if (!id) return;
|
||||
setLoadingUser(true);
|
||||
try {
|
||||
const result = await DefaultService.getUsersId(id, "all");
|
||||
setUser(result);
|
||||
setErrorUser(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setErrorUser(err?.message || "Failed to fetch user data");
|
||||
} finally {
|
||||
setLoadingUser(false);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
// --- Получение списка тайтлов пользователя ---
|
||||
const fetchPage = async (cursorObj: CursorObj | null) => {
|
||||
if (!id) return { items: [], nextCursor: null };
|
||||
const cursorStr = cursorObj
|
||||
? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
: "";
|
||||
|
||||
try {
|
||||
const result = await DefaultService.getUsersTitles(
|
||||
id,
|
||||
cursorStr,
|
||||
sort,
|
||||
sortForward,
|
||||
search.trim() || undefined,
|
||||
undefined, // status фильтр, можно добавить
|
||||
undefined, // watchStatus
|
||||
undefined, // rating
|
||||
undefined, // myRate
|
||||
undefined, // releaseYear
|
||||
undefined, // releaseSeason
|
||||
PAGE_SIZE,
|
||||
"all"
|
||||
);
|
||||
|
||||
if (!result?.data?.length) return { items: [], nextCursor: null };
|
||||
|
||||
return { items: result.data, nextCursor: result.cursor ?? null };
|
||||
} catch (err: any) {
|
||||
if (err.status === 204) return { items: [], nextCursor: null };
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Инициализация: загружаем сразу две страницы
|
||||
useEffect(() => {
|
||||
const initLoad = async () => {
|
||||
setLoadingTitles(true);
|
||||
setTitles([]);
|
||||
setNextPage([]);
|
||||
setCursor(null);
|
||||
|
||||
const firstPage = await fetchPage(null);
|
||||
const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null };
|
||||
|
||||
setTitles(firstPage.items);
|
||||
setNextPage(secondPage.items);
|
||||
setCursor(secondPage.nextCursor);
|
||||
setLoadingTitles(false);
|
||||
};
|
||||
initLoad();
|
||||
}, [id, search, sort, sortForward]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (nextPage.length === 0) {
|
||||
setLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
setLoadingMore(true);
|
||||
|
||||
setTitles(prev => [...prev, ...nextPage]);
|
||||
setNextPage([]);
|
||||
|
||||
if (cursor) {
|
||||
try {
|
||||
const next = await fetchPage(cursor);
|
||||
if (next.items.length > 0) setNextPage(next.items);
|
||||
setCursor(next.nextCursor);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
// const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png");
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center">
|
||||
|
||||
{/* --- Карточка пользователя --- */}
|
||||
{loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>}
|
||||
{errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>}
|
||||
{user && (
|
||||
<div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8">
|
||||
<img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2>
|
||||
{user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>}
|
||||
{user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>}
|
||||
{user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Панель поиска, сортировки и лейаута --- */}
|
||||
<div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center">
|
||||
<SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} />
|
||||
<LayoutSwitch layout={layout} setLayout={setLayout} />
|
||||
<TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} />
|
||||
</div>
|
||||
|
||||
{/* --- Список тайтлов --- */}
|
||||
{loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>}
|
||||
{!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>}
|
||||
|
||||
{titles.length > 0 && (
|
||||
<>
|
||||
<ListView<UserTitle>
|
||||
items={titles}
|
||||
layout={layout}
|
||||
hasMore={!!cursor || nextPage.length > 1}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
renderItem={(title, layout) =>
|
||||
layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} />
|
||||
}
|
||||
/>
|
||||
|
||||
{!cursor && nextPage.length === 0 && (
|
||||
<div className="mt-6 font-medium text-black">
|
||||
Результатов больше нет, было найдено {titles.length} тайтлов.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue