Compare commits

...

3 commits

Author SHA1 Message Date
f843c23e57
Merge branch 'front' into dev
All checks were successful
Build and Deploy Go App / build (push) Successful in 5m57s
Build and Deploy Go App / deploy (push) Successful in 36s
2025-11-27 16:28:16 +03:00
37cdc32d5d
fix: fix GetUserTitleByID 2025-11-27 16:28:09 +03:00
8a3e14a5e5
feat: TitleStatusControls 2025-11-27 16:26:03 +03:00
9 changed files with 187 additions and 174 deletions

View file

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

View file

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

View file

@ -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;
WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint;

View file

@ -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`,
},
});
}
}

View file

@ -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',

View file

@ -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>
);
}

View file

@ -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()}

View file

@ -63,7 +63,7 @@ export default function UserPage({ userId }: UserPageProps) {
: "";
try {
const result = await DefaultService.getUsersTitles(
const result = await DefaultService.getUserTitles(
id,
cursorStr,
sort,

View file

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