From 8a3e14a5e5c0495be790ab1dbcd4832fd0f41fb0 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 16:26:03 +0300 Subject: [PATCH 1/2] feat: TitleStatusControls --- .../src/api/services/DefaultService.ts | 62 +++++++++++- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../TitleStatusControls.tsx | 88 +++++++++++++++++ .../src/pages/TitlePage/TitlePage.tsx | 94 ++++++------------- .../frontend/src/pages/UserPage/UserPage.tsx | 2 +- 5 files changed, 179 insertions(+), 69 deletions(-) create mode 100644 modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 5070fae..218b461 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -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 { + 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 { 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 { + 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`, + }, + }); + } } diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 79aa305..2d0edf8 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -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', diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx new file mode 100644 index 0000000..0c9c741 --- /dev/null +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -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: , label: "Planned" }, + { status: "finished", icon: , label: "Finished" }, + { status: "in-progress", icon: , label: "In Progress" }, + { status: "dropped", icon: , label: "Dropped" }, +]; + +export function TitleStatusControls({ titleId }: { titleId: number }) { + const [currentStatus, setCurrentStatus] = useState(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 ( +
+ {STATUS_BUTTONS.map(btn => ( + + ))} +
+ ); +} diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 5ea0e3d..01f9c49 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -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: , label: "Planned" }, - { status: "finished", icon: , label: "Finished" }, - { status: "in-progress", icon: , label: "In Progress" }, - { status: "dropped", icon: , 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(null); - const [userStatus, setUserStatus] = useState(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 (
- {/* Постер */} + {/* Poster + status buttons */}
- {/* Статус кнопки с иконками */} -
- {STATUS_BUTTONS.map(btn => ( - - ))} -
+ {/* Status buttons */} +
- {/* Информация о тайтле */} + {/* Title info */}

{title.title_names?.en?.[0] || "Untitled"}

- {title.studio &&

Studio: {title.studio.name}

} + + {title.studio && ( +

+ Studio:{" "} + {title.studio.id ? ( + + {title.studio.name} + + ) : ( + title.studio.name + )} +

+ )} + {title.title_status &&

Status: {title.title_status}

} + {title.rating !== undefined && (

Rating: {title.rating} ({title.rating_count} votes)

)} + {title.release_year && (

Released: {title.release_year} {title.release_season || ""}

)} + {title.episodes_aired !== undefined && (

Episodes: {title.episodes_aired}/{title.episodes_all}

)} + {title.tags && title.tags.length > 0 && (

Tags: {getTagsString()} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 494ba99..7cc0db5 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -63,7 +63,7 @@ export default function UserPage({ userId }: UserPageProps) { : ""; try { - const result = await DefaultService.getUsersTitles( + const result = await DefaultService.getUserTitles( id, cursorStr, sort, From 37cdc32d5da55d620cc82eb2caf3b6de28dcab57 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 16:28:09 +0300 Subject: [PATCH 2/2] fix: fix GetUserTitleByID --- modules/backend/handlers/users.go | 2 +- modules/backend/main.go | 2 +- modules/backend/queries.sql | 27 +--------- sql/queries.sql.go | 82 ++----------------------------- 4 files changed, 8 insertions(+), 105 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 8723d16..d6faade 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -479,7 +479,7 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, - TitleId: *user_title.ID, + TitleId: user_title.TitleID, UserId: user_title.UserID, } diff --git a/modules/backend/main.go b/modules/backend/main.go index 3ac6603..8f58ffe 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -48,7 +48,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, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 1a90cde..ff41cb1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -398,29 +398,6 @@ RETURNING *; -- name: GetUserTitleByID :one SELECT - ut.*, - t.*, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + ut.* FROM usertitles as ut -LEFT JOIN users as u ON (ut.user_id = u.id) -LEFT JOIN titles as t ON (ut.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) - -WHERE t.id = sqlc.arg('title_id')::bigint AND u.id = sqlc.arg('user_id')::bigint -GROUP BY - t.id, i.id, s.id, si.id; \ No newline at end of file +WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index ddf6f6b..1cca986 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -306,32 +306,9 @@ func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, const getUserTitleByID = `-- name: GetUserTitleByID :one SELECT - ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime, - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime FROM usertitles as ut -LEFT JOIN users as u ON (ut.user_id = u.id) -LEFT JOIN titles as t ON (ut.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) - -WHERE t.id = $1::bigint AND u.id = $2::bigint -GROUP BY - t.id, i.id, s.id, si.id +WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint ` type GetUserTitleByIDParams struct { @@ -339,39 +316,9 @@ type GetUserTitleByIDParams struct { UserID int64 `json:"user_id"` } -type GetUserTitleByIDRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime time.Time `json:"ctime"` - ID *int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID *int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus *TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - TitleStorageType *StorageTypeT `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType *StorageTypeT `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` -} - -func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (GetUserTitleByIDRow, error) { +func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (Usertitle, error) { row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) - var i GetUserTitleByIDRow + var i Usertitle err := row.Scan( &i.UserID, &i.TitleID, @@ -379,27 +326,6 @@ func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDPara &i.Rate, &i.ReviewID, &i.Ctime, - &i.ID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - &i.TitleStorageType, - &i.TitleImagePath, - &i.TagNames, - &i.StudioName, - &i.StudioIllustID, - &i.StudioDesc, - &i.StudioStorageType, - &i.StudioImagePath, ) return i, err }