diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d6faade..8723d16 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.TitleID, + TitleId: *user_title.ID, UserId: user_title.UserID, } diff --git a/modules/backend/main.go b/modules/backend/main.go index 8f58ffe..3ac6603 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", "PATCH"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, 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 ff41cb1..1a90cde 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -398,6 +398,29 @@ RETURNING *; -- name: GetUserTitleByID :one SELECT - ut.* + 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 + FROM usertitles as ut -WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint; \ No newline at end of file +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 diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 218b461..5070fae 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 getUserTitles( + public static getUsersTitles( userId: string, cursor?: string, sort?: TitleSort, @@ -278,54 +278,27 @@ 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 - * @param titleId + * @param userId ID of the user to assign the title to * @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/{title_id}', + url: '/users/{user_id}/titles', path: { 'user_id': userId, - 'title_id': titleId, }, body: requestBody, mediaType: 'application/json', @@ -338,31 +311,4 @@ 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 2d0edf8..79aa305 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: '/auth', + BASE: 'http://10.1.0.65:8081/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 deleted file mode 100644 index 0c9c741..0000000 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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 01f9c49..5ea0e3d 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,8 +1,20 @@ import { useEffect, useState } from "react"; -import { useParams, Link } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { DefaultService } from "../../api/services/DefaultService"; -import type { Title } from "../../api"; -import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls"; +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" }, +]; export default function TitlePage() { const params = useParams(); @@ -12,9 +24,9 @@ export default function TitlePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // --------------------------- - // LOAD TITLE INFO - // --------------------------- + const [userStatus, setUserStatus] = useState(null); + const [updatingStatus, setUpdatingStatus] = useState(false); + useEffect(() => { const fetchTitle = async () => { setLoading(true); @@ -32,6 +44,30 @@ 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(", "); @@ -42,7 +78,7 @@ export default function TitlePage() { return (
- {/* Poster + status buttons */} + {/* Постер */}
- {/* Status buttons */} - + {/* Статус кнопки с иконками */} +
+ {STATUS_BUTTONS.map(btn => ( + + ))} +
- {/* Title info */} + {/* Информация о тайтле */}

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

- - {title.studio && ( -

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

- )} - + {title.studio &&

Studio: {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 7cc0db5..494ba99 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.getUserTitles( + const result = await DefaultService.getUsersTitles( id, cursorStr, sort, diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 1cca986..ddf6f6b 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -306,9 +306,32 @@ 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 + 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 + FROM usertitles as ut -WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint +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 ` type GetUserTitleByIDParams struct { @@ -316,9 +339,39 @@ type GetUserTitleByIDParams struct { UserID int64 `json:"user_id"` } -func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (Usertitle, error) { +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) { row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) - var i Usertitle + var i GetUserTitleByIDRow err := row.Scan( &i.UserID, &i.TitleID, @@ -326,6 +379,27 @@ 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 }