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/components/cards/UserTitleCardHorizontal.tsx b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx
new file mode 100644
index 0000000..ad7d5df
--- /dev/null
+++ b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx
@@ -0,0 +1,22 @@
+import type { UserTitle } from "../../api";
+
+export function UserTitleCardHorizontal({ title }: { title: UserTitle }) {
+ return (
+
+ {title.title?.poster?.image_path && (
+

+ )}
+
+
{title.title?.title_names["en"]}
+
{title.title?.release_year} · {title.title?.release_season} · Rating: {title.title?.rating}
+
Status: {title.title?.title_status}
+
+
+ );
+}
diff --git a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx
new file mode 100644
index 0000000..edcf1d5
--- /dev/null
+++ b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx
@@ -0,0 +1,22 @@
+import type { UserTitle } from "../../api";
+
+export function UserTitleCardSquare({ title }: { title: UserTitle }) {
+ return (
+
+ {title.title?.poster?.image_path && (
+

+ )}
+
+
{title.title?.title_names["en"]}
+ {title.status}
+ {title.title?.release_year} • {title.title?.rating}
+
+
+ );
+}
diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx
index dcd6965..89ee88c 100644
--- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx
+++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx
@@ -18,17 +18,19 @@ export const LoginPage: React.FC = () => {
try {
if (isLogin) {
const res = await AuthService.postAuthSignIn({ nickname, pass: password });
- if (res.success) {
- // TODO: сохранить JWT в localStorage/cookie
- console.log("Logged in user id:", res.user_id);
- navigate("/"); // редирект после успешного входа
+ if (res.user_id && res.user_name) {
+ // Сохраняем user_id и username в localStorage
+ localStorage.setItem("userId", res.user_id);
+ localStorage.setItem("username", res.user_name);
+
+ navigate("/profile"); // редирект на профиль
} else {
setError(res.error || "Login failed");
}
} else {
+ // SignUp оставляем без сохранения данных
const res = await AuthService.postAuthSignUp({ nickname, pass: password });
- if (res.success) {
- console.log("User signed up with id:", res.user_id);
+ if (res.user_id) {
setIsLogin(true); // переключаемся на login после регистрации
} else {
setError(res.error || "Sign up failed");
diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.module.css b/modules/frontend/src/pages/TitlePage/TitlePage.module.css
deleted file mode 100644
index e69de29..0000000
diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx
index 7fe9de7..01f9c49 100644
--- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx
+++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx
@@ -1,64 +1,108 @@
-// import React, { 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 { useEffect, useState } from "react";
+import { useParams, Link } from "react-router-dom";
+import { DefaultService } from "../../api/services/DefaultService";
+import type { Title } from "../../api";
+import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls";
-// const UserPage: React.FC = () => {
-// const { id } = useParams<{ id: string }>();
-// const [user, setUser] = useState(null);
-// const [loading, setLoading] = useState(true);
-// const [error, setError] = useState(null);
+export default function TitlePage() {
+ const params = useParams();
+ const titleId = Number(params.id);
-// useEffect(() => {
-// if (!id) return;
+ const [title, setTitle] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
-// const getTitleInfo = async () => {
-// try {
-// const userInfo = await DefaultService.getTitle(id, "all");
-// setUser(userInfo);
-// } catch (err) {
-// console.error(err);
-// setError("Failed to fetch user info.");
-// } finally {
-// setLoading(false);
-// }
-// };
-// getTitleInfo();
-// }, [id]);
+ // ---------------------------
+ // LOAD TITLE INFO
+ // ---------------------------
+ useEffect(() => {
+ const fetchTitle = async () => {
+ setLoading(true);
+ try {
+ const data = await DefaultService.getTitle(titleId, "all");
+ setTitle(data);
+ setError(null);
+ } catch (err: any) {
+ console.error(err);
+ setError(err?.message || "Failed to fetch title");
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchTitle();
+ }, [titleId]);
-// if (loading) return Loading...
;
-// if (error) return {error}
;
-// if (!user) return User not found.
;
+ const getTagsString = () =>
+ title?.tags?.map(tag => tag.en).filter(Boolean).join(", ");
-// return (
-//
-//
-//
-// {user.avatar_id ? (
-//

-// ) : (
-//
-// {user.disp_name?.[0] || "U"}
-//
-// )}
-//
+ if (loading) return
Loading title...
;
+ if (error) return
{error}
;
+ if (!title) return null;
-//
-//
{user.disp_name || user.nickname}
-//
@{user.nickname}
-// {user.user_desc &&
{user.user_desc}
}
-//
-// Joined: {new Date(user.creation_date).toLocaleDateString()}
-//
-//
-//
-//
-// );
-// };
+ return (
+
+
+ {/* Poster + status buttons */}
+
+
![{title.title_names?.en?.[0]]({title.poster?.image_path)
-// export default UserPage;
+ {/* Status buttons */}
+
+
+
+ {/* Title info */}
+
+
+ {title.title_names?.en?.[0] || "Untitled"}
+
+
+ {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/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css
deleted file mode 100644
index f1d8c73..0000000
--- a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css
+++ /dev/null
@@ -1 +0,0 @@
-@import "tailwindcss";
diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx
index 0fec3c8..c9911b9 100644
--- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx
+++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx
@@ -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"
- ?
- :
- }
+ renderItem={(title, layout) => (
+
+ {layout === "square" ? : }
+
+ )}
/>
{!cursor && nextPage.length == 0 && (
diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css
deleted file mode 100644
index 7f350c8..0000000
--- a/modules/frontend/src/pages/UserPage/UserPage.module.css
+++ /dev/null
@@ -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;
-}
diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx
index 52c5574..7cc0db5 100644
--- a/modules/frontend/src/pages/UserPage/UserPage.tsx
+++ b/modules/frontend/src/pages/UserPage/UserPage.tsx
@@ -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(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+const PAGE_SIZE = 10;
- useEffect(() => {
- if (!id) return;
-
- const getUserInfo = async () => {
- try {
- const userInfo = await DefaultService.getUsers(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 Loading...
;
- if (error) return {error}
;
- if (!user) return User not found.
;
-
- return (
-
-
-
- {user.avatar_id ? (
-

- ) : (
-
- {user.disp_name?.[0] || "U"}
-
- )}
-
-
-
-
{user.disp_name || user.nickname}
-
@{user.nickname}
- {/*
- Joined: {new Date(user.creation_date).toLocaleDateString()}
-
*/}
-
-
-
- {user.user_desc &&
{user.user_desc}
}
-
-
-
- );
+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(null);
+ const [loadingUser, setLoadingUser] = useState(true);
+ const [errorUser, setErrorUser] = useState(null);
+
+ // Для списка тайтлов
+ const [titles, setTitles] = useState([]);
+ const [nextPage, setNextPage] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [loadingTitles, setLoadingTitles] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [search, setSearch] = useState("");
+ const [sort, setSort] = useState("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 (
+
+
+ {/* --- Карточка пользователя --- */}
+ {loadingUser &&
Loading user...
}
+ {errorUser &&
{errorUser}
}
+ {user && (
+
+

+
{user.disp_name || user.nickname}
+ {user.mail &&
{user.mail}
}
+ {user.user_desc &&
{user.user_desc}
}
+ {user.creation_date &&
Registered: {new Date(user.creation_date).toLocaleDateString()}
}
+
+ )}
+
+ {/* --- Панель поиска, сортировки и лейаута --- */}
+
+
+
+
+
+
+ {/* --- Список тайтлов --- */}
+ {loadingTitles &&
Loading titles...
}
+ {!loadingTitles && titles.length === 0 &&
No titles found.
}
+
+ {titles.length > 0 && (
+ <>
+
+ items={titles}
+ layout={layout}
+ hasMore={!!cursor || nextPage.length > 1}
+ loadingMore={loadingMore}
+ onLoadMore={handleLoadMore}
+ renderItem={(title, layout) => (
+
+ {layout === "square" ? : }
+
+ )}
+ />
+
+ {!cursor && nextPage.length === 0 && (
+
+ Результатов больше нет, было найдено {titles.length} тайтлов.
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql
index e6ed628..3499fe2 100644
--- a/sql/migrations/000001_init.up.sql
+++ b/sql/migrations/000001_init.up.sql
@@ -1,6 +1,3 @@
--- TODO:
--- maybe jsonb constraints
--- clean unused images
CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress');
CREATE TYPE storage_type_t AS ENUM ('local', 's3');
CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned');
@@ -24,37 +21,24 @@ CREATE TABLE images (
image_path text UNIQUE NOT NULL
);
-CREATE TABLE reviews (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
- data text NOT NULL,
- rating int CHECK (rating >= 0 AND rating <= 10),
- user_id bigint REFERENCES users (id),
- title_id bigint REFERENCES titles (id),
- created_at timestamptz DEFAULT NOW()
-);
-
-CREATE TABLE review_images (
- PRIMARY KEY (review_id, image_id),
- review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
- image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE
-);
-
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
- avatar_id bigint REFERENCES images (id),
+ avatar_id bigint REFERENCES images (id) ON DELETE SET NULL,
passhash text NOT NULL,
mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'),
nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'),
disp_name text,
user_desc text,
- creation_date timestamptz NOT NULL,
+ creation_date timestamptz NOT NULL DEFAULT NOW(),
last_login timestamptz
);
+
+
CREATE TABLE studios (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
studio_name text NOT NULL UNIQUE,
- illust_id bigint REFERENCES images (id),
+ illust_id bigint REFERENCES images (id) ON DELETE SET NULL,
studio_desc text
);
@@ -64,7 +48,7 @@ CREATE TABLE titles (
-- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]}
title_names jsonb NOT NULL,
studio_id bigint NOT NULL REFERENCES studios (id),
- poster_id bigint REFERENCES images (id),
+ poster_id bigint REFERENCES images (id) ON DELETE SET NULL,
title_status title_status_t NOT NULL,
rating float CHECK (rating >= 0 AND rating <= 10),
rating_count int CHECK (rating_count >= 0),
@@ -80,21 +64,36 @@ CREATE TABLE titles (
AND episodes_aired <= episodes_all))
);
+CREATE TABLE reviews (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ data text NOT NULL,
+ rating int CHECK (rating >= 0 AND rating <= 10),
+ user_id bigint REFERENCES users (id) ON DELETE SET NULL,
+ title_id bigint REFERENCES titles (id) ON DELETE CASCADE,
+ created_at timestamptz DEFAULT NOW()
+);
+
+CREATE TABLE review_images (
+ PRIMARY KEY (review_id, image_id),
+ review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
+ image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE
+);
+
CREATE TABLE usertitles (
PRIMARY KEY (user_id, title_id),
- user_id bigint NOT NULL REFERENCES users (id),
- title_id bigint NOT NULL REFERENCES titles (id),
+ user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE,
status usertitle_status_t NOT NULL,
rate int CHECK (rate > 0 AND rate <= 10),
- review_id bigint REFERENCES reviews (id),
- ctime timestamptz
+ review_id bigint REFERENCES reviews (id) ON DELETE SET NULL,
+ ctime timestamptz NOT NULL DEFAULT now()
-- // TODO: series status
);
CREATE TABLE title_tags (
PRIMARY KEY (title_id, tag_id),
- title_id bigint NOT NULL REFERENCES titles (id),
- tag_id bigint NOT NULL REFERENCES tags (id)
+ title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE,
+ tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE
);
CREATE TABLE signals (
@@ -105,17 +104,17 @@ CREATE TABLE signals (
pending boolean NOT NULL
);
+CREATE TABLE external_services (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ name text UNIQUE NOT NULL
+);
+
CREATE TABLE external_ids (
user_id bigint NOT NULL REFERENCES users (id),
service_id bigint REFERENCES external_services (id),
external_id text NOT NULL
);
-CREATE TABLE external_services (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
- name text UNIQUE NOT NULL
-);
-
-- Functions
CREATE OR REPLACE FUNCTION update_title_rating()
RETURNS TRIGGER AS $$
@@ -169,4 +168,17 @@ EXECUTE FUNCTION update_title_rating();
CREATE TRIGGER trg_notify_new_signal
AFTER INSERT ON signals
FOR EACH ROW
-EXECUTE FUNCTION notify_new_signal();
\ No newline at end of file
+EXECUTE FUNCTION notify_new_signal();
+
+CREATE OR REPLACE FUNCTION set_ctime()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.ctime = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER set_ctime_on_update
+BEFORE UPDATE ON usertitles
+FOR EACH ROW
+EXECUTE FUNCTION set_ctime();
\ No newline at end of file
diff --git a/sql/models.go b/sql/models.go
index 93cecca..842d58c 100644
--- a/sql/models.go
+++ b/sql/models.go
@@ -6,6 +6,7 @@ package sqlc
import (
"database/sql/driver"
+ "encoding/json"
"fmt"
"time"
@@ -223,11 +224,11 @@ type ReviewImage struct {
}
type Signal struct {
- ID int64 `json:"id"`
- TitleID *int64 `json:"title_id"`
- RawData []byte `json:"raw_data"`
- ProviderID int64 `json:"provider_id"`
- Pending bool `json:"pending"`
+ ID int64 `json:"id"`
+ TitleID *int64 `json:"title_id"`
+ RawData json.RawMessage `json:"raw_data"`
+ ProviderID int64 `json:"provider_id"`
+ Pending bool `json:"pending"`
}
type Studio struct {
@@ -238,13 +239,13 @@ type Studio struct {
}
type Tag struct {
- ID int64 `json:"id"`
- TagNames []byte `json:"tag_names"`
+ ID int64 `json:"id"`
+ TagNames json.RawMessage `json:"tag_names"`
}
type Title struct {
ID int64 `json:"id"`
- TitleNames []byte `json:"title_names"`
+ TitleNames json.RawMessage `json:"title_names"`
StudioID int64 `json:"studio_id"`
PosterID *int64 `json:"poster_id"`
TitleStatus TitleStatusT `json:"title_status"`
@@ -276,10 +277,10 @@ type User struct {
}
type Usertitle 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 pgtype.Timestamptz `json:"ctime"`
+ 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"`
}
diff --git a/sql/queries.sql.go b/sql/queries.sql.go
index 5a1d13c..1cca986 100644
--- a/sql/queries.sql.go
+++ b/sql/queries.sql.go
@@ -7,9 +7,8 @@ package sqlc
import (
"context"
+ "encoding/json"
"time"
-
- "github.com/jackc/pgx/v5/pgtype"
)
const createImage = `-- name: CreateImage :one
@@ -30,6 +29,51 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image
return i, err
}
+const createNewUser = `-- name: CreateNewUser :one
+INSERT
+INTO users (passhash, nickname)
+VALUES ($1, $2)
+RETURNING id
+`
+
+type CreateNewUserParams struct {
+ Passhash string `json:"passhash"`
+ Nickname string `json:"nickname"`
+}
+
+func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) {
+ row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname)
+ var id int64
+ err := row.Scan(&id)
+ return id, err
+}
+
+const deleteUserTitle = `-- name: DeleteUserTitle :one
+DELETE FROM usertitles
+WHERE user_id = $1
+ AND title_id = $2
+RETURNING user_id, title_id, status, rate, review_id, ctime
+`
+
+type DeleteUserTitleParams struct {
+ UserID int64 `json:"user_id"`
+ TitleID int64 `json:"title_id"`
+}
+
+func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams) (Usertitle, error) {
+ row := q.db.QueryRow(ctx, deleteUserTitle, arg.UserID, arg.TitleID)
+ var i Usertitle
+ err := row.Scan(
+ &i.UserID,
+ &i.TitleID,
+ &i.Status,
+ &i.Rate,
+ &i.ReviewID,
+ &i.Ctime,
+ )
+ return i, err
+}
+
const getImageByID = `-- name: GetImageByID :one
SELECT id, storage_type, image_path
FROM images
@@ -45,40 +89,12 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro
const getReviewByID = `-- name: GetReviewByID :one
-
-
SELECT id, data, rating, user_id, title_id, created_at
FROM reviews
WHERE review_id = $1::bigint
`
// 100 is default limit
-// -- name: ListTitles :many
-// SELECT title_id, title_names, studio_id, poster_id, signal_ids,
-//
-// title_status, rating, rating_count, release_year, release_season,
-// season, episodes_aired, episodes_all, episodes_len
-//
-// FROM titles
-// ORDER BY title_id
-// LIMIT $1 OFFSET $2;
-// -- name: UpdateTitle :one
-// UPDATE titles
-// SET
-//
-// title_names = COALESCE(sqlc.narg('title_names'), title_names),
-// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id),
-// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id),
-// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids),
-// title_status = COALESCE(sqlc.narg('title_status'), title_status),
-// release_year = COALESCE(sqlc.narg('release_year'), release_year),
-// release_season = COALESCE(sqlc.narg('release_season'), release_season),
-// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired),
-// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all),
-// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len)
-//
-// WHERE title_id = sqlc.arg('title_id')
-// RETURNING *;
func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) {
row := q.db.QueryRow(ctx, getReviewByID, reviewID)
var i Review
@@ -112,41 +128,60 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er
}
const getTitleByID = `-- name: GetTitleByID :one
+SELECT
+ 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 titles as t
+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)
-
-
-SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len
-FROM titles
-WHERE id = $1::bigint
+WHERE t.id = $1::bigint
+GROUP BY
+ t.id, i.id, s.id, si.id
`
-// -- name: ListUsers :many
-// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date
-// FROM users
-// ORDER BY user_id
-// LIMIT $1 OFFSET $2;
-// -- name: CreateUser :one
-// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date)
-// VALUES ($1, $2, $3, $4, $5, $6, $7)
-// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date;
-// -- name: UpdateUser :one
-// UPDATE users
-// SET
-//
-// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id),
-// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name),
-// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc),
-// passhash = COALESCE(sqlc.narg('passhash'), passhash)
-//
-// WHERE user_id = sqlc.arg('user_id')
-// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date;
-// -- name: DeleteUser :exec
-// DELETE FROM users
-// WHERE user_id = $1;
-func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error) {
+type GetTitleByIDRow struct {
+ ID int64 `json:"id"`
+ TitleNames json.RawMessage `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"`
+}
+
+// sqlc.struct: TitlesFull
+func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByIDRow, error) {
row := q.db.QueryRow(ctx, getTitleByID, titleID)
- var i Title
+ var i GetTitleByIDRow
err := row.Scan(
&i.ID,
&i.TitleNames,
@@ -161,6 +196,14 @@ func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error
&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
}
@@ -173,15 +216,15 @@ JOIN title_tags as t ON(t.tag_id = g.id)
WHERE t.title_id = $1::bigint
`
-func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, error) {
+func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMessage, error) {
rows, err := q.db.Query(ctx, getTitleTags, titleID)
if err != nil {
return nil, err
}
defer rows.Close()
- var items [][]byte
+ items := []json.RawMessage{}
for rows.Next() {
- var tag_names []byte
+ var tag_names json.RawMessage
if err := rows.Scan(&tag_names); err != nil {
return nil, err
}
@@ -194,19 +237,31 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, er
}
const getUserByID = `-- name: GetUserByID :one
-SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date
-FROM users
-WHERE id = $1
+SELECT
+ t.id as id,
+ t.avatar_id as avatar_id,
+ t.mail as mail,
+ t.nickname as nickname,
+ t.disp_name as disp_name,
+ t.user_desc as user_desc,
+ t.creation_date as creation_date,
+ i.storage_type as storage_type,
+ i.image_path as image_path
+FROM users as t
+LEFT JOIN images as i ON (t.avatar_id = i.id)
+WHERE t.id = $1::bigint
`
type GetUserByIDRow struct {
- ID int64 `json:"id"`
- AvatarID *int64 `json:"avatar_id"`
- Mail *string `json:"mail"`
- Nickname string `json:"nickname"`
- DispName *string `json:"disp_name"`
- UserDesc *string `json:"user_desc"`
- CreationDate time.Time `json:"creation_date"`
+ ID int64 `json:"id"`
+ AvatarID *int64 `json:"avatar_id"`
+ Mail *string `json:"mail"`
+ Nickname string `json:"nickname"`
+ DispName *string `json:"disp_name"`
+ UserDesc *string `json:"user_desc"`
+ CreationDate time.Time `json:"creation_date"`
+ StorageType *StorageTypeT `json:"storage_type"`
+ ImagePath *string `json:"image_path"`
}
func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) {
@@ -220,6 +275,57 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er
&i.DispName,
&i.UserDesc,
&i.CreationDate,
+ &i.StorageType,
+ &i.ImagePath,
+ )
+ return i, err
+}
+
+const getUserByNickname = `-- name: GetUserByNickname :one
+SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login
+FROM users
+WHERE nickname = $1
+`
+
+func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) {
+ row := q.db.QueryRow(ctx, getUserByNickname, nickname)
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.AvatarID,
+ &i.Passhash,
+ &i.Mail,
+ &i.Nickname,
+ &i.DispName,
+ &i.UserDesc,
+ &i.CreationDate,
+ &i.LastLogin,
+ )
+ return i, err
+}
+
+const getUserTitleByID = `-- name: GetUserTitleByID :one
+SELECT
+ ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime
+FROM usertitles as ut
+WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint
+`
+
+type GetUserTitleByIDParams struct {
+ TitleID int64 `json:"title_id"`
+ UserID int64 `json:"user_id"`
+}
+
+func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (Usertitle, error) {
+ row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID)
+ var i Usertitle
+ err := row.Scan(
+ &i.UserID,
+ &i.TitleID,
+ &i.Status,
+ &i.Rate,
+ &i.ReviewID,
+ &i.Ctime,
)
return i, err
}
@@ -258,7 +364,7 @@ VALUES (
RETURNING id, tag_names
`
-func (q *Queries) InsertTag(ctx context.Context, tagNames []byte) (Tag, error) {
+func (q *Queries) InsertTag(ctx context.Context, tagNames json.RawMessage) (Tag, error) {
row := q.db.QueryRow(ctx, insertTag, tagNames)
var i Tag
err := row.Scan(&i.ID, &i.TagNames)
@@ -285,94 +391,229 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams
return i, err
}
+const insertUserTitle = `-- name: InsertUserTitle :one
+INSERT INTO usertitles (user_id, title_id, status, rate, review_id)
+VALUES (
+ $1::bigint,
+ $2::bigint,
+ $3::usertitle_status_t,
+ $4::int,
+ $5::bigint
+)
+RETURNING user_id, title_id, status, rate, review_id, ctime
+`
+
+type InsertUserTitleParams struct {
+ UserID int64 `json:"user_id"`
+ TitleID int64 `json:"title_id"`
+ Status UsertitleStatusT `json:"status"`
+ Rate *int32 `json:"rate"`
+ ReviewID *int64 `json:"review_id"`
+}
+
+func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) {
+ row := q.db.QueryRow(ctx, insertUserTitle,
+ arg.UserID,
+ arg.TitleID,
+ arg.Status,
+ arg.Rate,
+ arg.ReviewID,
+ )
+ var i Usertitle
+ err := row.Scan(
+ &i.UserID,
+ &i.TitleID,
+ &i.Status,
+ &i.Rate,
+ &i.ReviewID,
+ &i.Ctime,
+ )
+ return i, err
+}
+
const searchTitles = `-- name: SearchTitles :many
SELECT
- id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len
-FROM titles
-WHERE
- CASE
- WHEN $1::text IS NOT NULL THEN
- (
- SELECT bool_and(
- EXISTS (
- SELECT 1
- FROM jsonb_each_text(title_names) AS t(key, val)
- WHERE val ILIKE pattern
- )
- )
- FROM unnest(
- ARRAY(
- SELECT '%' || trim(w) || '%'
- FROM unnest(string_to_array($1::text, ' ')) AS w
- WHERE trim(w) <> ''
- )
- ) AS pattern
- )
- ELSE true
+ t.id as id,
+ t.title_names as title_names,
+ t.poster_id as poster_id,
+ t.title_status as title_status,
+ t.rating as rating,
+ t.rating_count as rating_count,
+ t.release_year as release_year,
+ t.release_season as release_season,
+ t.season as season,
+ t.episodes_aired as episodes_aired,
+ t.episodes_all as episodes_all,
+ 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
+
+FROM titles as t
+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)
+
+WHERE
+ CASE
+ WHEN $1::boolean THEN
+ -- forward: greater than cursor (next page)
+ CASE $2::text
+ WHEN 'year' THEN
+ ($3::int IS NULL) OR
+ (t.release_year > $3::int) OR
+ (t.release_year = $3::int AND t.id > $4::bigint)
+
+ WHEN 'rating' THEN
+ ($5::float IS NULL) OR
+ (t.rating > $5::float) OR
+ (t.rating = $5::float AND t.id > $4::bigint)
+
+ WHEN 'id' THEN
+ ($4::bigint IS NULL) OR
+ (t.id > $4::bigint)
+
+ ELSE true -- fallback
+ END
+
+ ELSE
+ -- backward: less than cursor (prev page)
+ CASE $2::text
+ WHEN 'year' THEN
+ ($3::int IS NULL) OR
+ (t.release_year < $3::int) OR
+ (t.release_year = $3::int AND t.id < $4::bigint)
+
+ WHEN 'rating' THEN
+ ($5::float IS NULL) OR
+ (t.rating < $5::float) OR
+ (t.rating = $5::float AND t.id < $4::bigint)
+
+ WHEN 'id' THEN
+ ($4::bigint IS NULL) OR
+ (t.id < $4::bigint)
+
+ ELSE true
+ END
END
- AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t)
- AND ($3::float IS NULL OR rating >= $3::float)
- AND ($4::int IS NULL OR release_year = $4::int)
- AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t)
-ORDER BY
- -- Основной ключ: выбранное поле
- CASE
- WHEN $6::boolean AND $7::text = 'id' THEN id
- WHEN $6::boolean AND $7::text = 'year' THEN release_year
- WHEN $6::boolean AND $7::text = 'rating' THEN rating
- -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
- END ASC,
- CASE
- WHEN NOT $6::boolean AND $7::text = 'id' THEN id
- WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year
- WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating
- -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
- END DESC,
+ AND (
+ CASE
+ WHEN $6::text IS NOT NULL THEN
+ (
+ SELECT bool_and(
+ EXISTS (
+ SELECT 1
+ FROM jsonb_each_text(t.title_names) AS t(key, val)
+ WHERE val ILIKE pattern
+ )
+ )
+ FROM unnest(
+ ARRAY(
+ SELECT '%' || trim(w) || '%'
+ FROM unnest(string_to_array($6::text, ' ')) AS w
+ WHERE trim(w) <> ''
+ )
+ ) AS pattern
+ )
+ ELSE true
+ END
+ )
- -- Вторичный ключ: id — только если НЕ сортируем по id
- CASE
- WHEN $7::text != 'id' AND $6::boolean THEN id
- END ASC,
- CASE
- WHEN $7::text != 'id' AND NOT $6::boolean THEN id
- END DESC
-LIMIT COALESCE($8::int, 100)
+ AND (
+ $7::title_status_t[] IS NULL
+ OR array_length($7::title_status_t[], 1) IS NULL
+ OR array_length($7::title_status_t[], 1) = 0
+ OR t.title_status = ANY($7::title_status_t[])
+ )
+ AND ($8::float IS NULL OR t.rating >= $8::float)
+ AND ($9::int IS NULL OR t.release_year = $9::int)
+ AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t)
+
+GROUP BY
+ t.id, i.id, s.id
+
+ORDER BY
+ CASE WHEN $1::boolean THEN
+ CASE
+ WHEN $2::text = 'id' THEN t.id
+ WHEN $2::text = 'year' THEN t.release_year
+ WHEN $2::text = 'rating' THEN t.rating
+ END
+ END ASC,
+ CASE WHEN NOT $1::boolean THEN
+ CASE
+ WHEN $2::text = 'id' THEN t.id
+ WHEN $2::text = 'year' THEN t.release_year
+ WHEN $2::text = 'rating' THEN t.rating
+ END
+ END DESC,
+
+ CASE WHEN $2::text <> 'id' THEN t.id END ASC
+
+LIMIT COALESCE($11::int, 100)
`
type SearchTitlesParams struct {
+ Forward bool `json:"forward"`
+ SortBy string `json:"sort_by"`
+ CursorYear *int32 `json:"cursor_year"`
+ CursorID *int64 `json:"cursor_id"`
+ CursorRating *float64 `json:"cursor_rating"`
Word *string `json:"word"`
- Status *TitleStatusT `json:"status"`
+ TitleStatuses []TitleStatusT `json:"title_statuses"`
Rating *float64 `json:"rating"`
ReleaseYear *int32 `json:"release_year"`
ReleaseSeason *ReleaseSeasonT `json:"release_season"`
- Forward bool `json:"forward"`
- SortBy string `json:"sort_by"`
Limit *int32 `json:"limit"`
}
-func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) {
+type SearchTitlesRow struct {
+ ID int64 `json:"id"`
+ TitleNames json.RawMessage `json:"title_names"`
+ 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"`
+ TitleStorageType *StorageTypeT `json:"title_storage_type"`
+ TitleImagePath *string `json:"title_image_path"`
+ TagNames json.RawMessage `json:"tag_names"`
+ StudioName *string `json:"studio_name"`
+}
+
+func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) {
rows, err := q.db.Query(ctx, searchTitles,
+ arg.Forward,
+ arg.SortBy,
+ arg.CursorYear,
+ arg.CursorID,
+ arg.CursorRating,
arg.Word,
- arg.Status,
+ arg.TitleStatuses,
arg.Rating,
arg.ReleaseYear,
arg.ReleaseSeason,
- arg.Forward,
- arg.SortBy,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
- var items []Title
+ items := []SearchTitlesRow{}
for rows.Next() {
- var i Title
+ var i SearchTitlesRow
if err := rows.Scan(
&i.ID,
&i.TitleNames,
- &i.StudioID,
&i.PosterID,
&i.TitleStatus,
&i.Rating,
@@ -382,7 +623,10 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T
&i.Season,
&i.EpisodesAired,
&i.EpisodesAll,
- &i.EpisodesLen,
+ &i.TitleStorageType,
+ &i.TitleImagePath,
+ &i.TagNames,
+ &i.StudioName,
); err != nil {
return nil, err
}
@@ -396,102 +640,217 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T
const searchUserTitles = `-- name: SearchUserTitles :many
-SELECT
- user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len
-FROM usertitles as u
-JOIN titles as t ON (u.title_id = t.id)
-WHERE
- CASE
- WHEN $1::text IS NOT NULL THEN
- (
- SELECT bool_and(
- EXISTS (
- SELECT 1
- FROM jsonb_each_text(t.title_names) AS t(key, val)
- WHERE val ILIKE pattern
- )
- )
- FROM unnest(
- ARRAY(
- SELECT '%' || trim(w) || '%'
- FROM unnest(string_to_array($1::text, ' ')) AS w
- WHERE trim(w) <> ''
- )
- ) AS pattern
- )
- ELSE true
+SELECT
+ t.id as id,
+ t.title_names as title_names,
+ t.poster_id as poster_id,
+ t.title_status as title_status,
+ t.rating as rating,
+ t.rating_count as rating_count,
+ t.release_year as release_year,
+ t.release_season as release_season,
+ t.season as season,
+ t.episodes_aired as episodes_aired,
+ t.episodes_all as episodes_all,
+ u.user_id as user_id,
+ u.status as usertitle_status,
+ u.rate as user_rate,
+ u.review_id as review_id,
+ u.ctime as user_ctime,
+ 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
+
+FROM usertitles as u
+JOIN titles as t ON (u.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)
+
+WHERE
+ u.user_id = $1::bigint
+ AND
+ CASE
+ WHEN $2::boolean THEN
+ -- forward: greater than cursor (next page)
+ CASE $3::text
+ WHEN 'year' THEN
+ ($4::int IS NULL) OR
+ (t.release_year > $4::int) OR
+ (t.release_year = $4::int AND t.id > $5::bigint)
+
+ WHEN 'rating' THEN
+ ($6::float IS NULL) OR
+ (t.rating > $6::float) OR
+ (t.rating = $6::float AND t.id > $5::bigint)
+
+ WHEN 'id' THEN
+ ($5::bigint IS NULL) OR
+ (t.id > $5::bigint)
+
+ ELSE true -- fallback
+ END
+
+ ELSE
+ -- backward: less than cursor (prev page)
+ CASE $3::text
+ WHEN 'year' THEN
+ ($4::int IS NULL) OR
+ (t.release_year < $4::int) OR
+ (t.release_year = $4::int AND t.id < $5::bigint)
+
+ WHEN 'rating' THEN
+ ($6::float IS NULL) OR
+ (t.rating < $6::float) OR
+ (t.rating = $6::float AND t.id < $5::bigint)
+
+ WHEN 'id' THEN
+ ($5::bigint IS NULL) OR
+ (t.id < $5::bigint)
+
+ ELSE true
+ END
END
- AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t)
- AND ($3::float IS NULL OR t.rating >= $3::float)
- AND ($4::int IS NULL OR t.release_year = $4::int)
- AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t)
- AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t)
+ AND (
+ CASE
+ WHEN $7::text IS NOT NULL THEN
+ (
+ SELECT bool_and(
+ EXISTS (
+ SELECT 1
+ FROM jsonb_each_text(t.title_names) AS t(key, val)
+ WHERE val ILIKE pattern
+ )
+ )
+ FROM unnest(
+ ARRAY(
+ SELECT '%' || trim(w) || '%'
+ FROM unnest(string_to_array($7::text, ' ')) AS w
+ WHERE trim(w) <> ''
+ )
+ ) AS pattern
+ )
+ ELSE true
+ END
+ )
-LIMIT COALESCE($7::int, 100)
+ AND (
+ $8::title_status_t[] IS NULL
+ OR array_length($8::title_status_t[], 1) IS NULL
+ OR array_length($8::title_status_t[], 1) = 0
+ OR t.title_status = ANY($8::title_status_t[])
+ )
+ AND (
+ $9::usertitle_status_t[] IS NULL
+ OR array_length($9::usertitle_status_t[], 1) IS NULL
+ OR array_length($9::usertitle_status_t[], 1) = 0
+ OR u.status = ANY($9::usertitle_status_t[])
+ )
+ AND ($10::int IS NULL OR u.rate >= $10::int)
+ AND ($11::float IS NULL OR t.rating >= $11::float)
+ AND ($12::int IS NULL OR t.release_year = $12::int)
+ AND ($13::release_season_t IS NULL OR t.release_season = $13::release_season_t)
+
+GROUP BY
+ t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id
+
+ORDER BY
+ CASE WHEN $2::boolean THEN
+ CASE
+ WHEN $3::text = 'id' THEN t.id
+ WHEN $3::text = 'year' THEN t.release_year
+ WHEN $3::text = 'rating' THEN t.rating
+ WHEN $3::text = 'rate' THEN u.rate
+ END
+ END ASC,
+ CASE WHEN NOT $2::boolean THEN
+ CASE
+ WHEN $3::text = 'id' THEN t.id
+ WHEN $3::text = 'year' THEN t.release_year
+ WHEN $3::text = 'rating' THEN t.rating
+ WHEN $3::text = 'rate' THEN u.rate
+ END
+ END DESC,
+
+ CASE WHEN $3::text <> 'id' THEN t.id END ASC
+
+LIMIT COALESCE($14::int, 100)
`
type SearchUserTitlesParams struct {
- Word *string `json:"word"`
- Status *TitleStatusT `json:"status"`
- Rating *float64 `json:"rating"`
- ReleaseYear *int32 `json:"release_year"`
- ReleaseSeason *ReleaseSeasonT `json:"release_season"`
- UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"`
- Limit *int32 `json:"limit"`
+ UserID int64 `json:"user_id"`
+ Forward bool `json:"forward"`
+ SortBy string `json:"sort_by"`
+ CursorYear *int32 `json:"cursor_year"`
+ CursorID *int64 `json:"cursor_id"`
+ CursorRating *float64 `json:"cursor_rating"`
+ Word *string `json:"word"`
+ TitleStatuses []TitleStatusT `json:"title_statuses"`
+ UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"`
+ Rate *int32 `json:"rate"`
+ Rating *float64 `json:"rating"`
+ ReleaseYear *int32 `json:"release_year"`
+ ReleaseSeason *ReleaseSeasonT `json:"release_season"`
+ Limit *int32 `json:"limit"`
}
type SearchUserTitlesRow 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 pgtype.Timestamptz `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"`
+ ID int64 `json:"id"`
+ TitleNames json.RawMessage `json:"title_names"`
+ 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"`
+ UserID int64 `json:"user_id"`
+ UsertitleStatus UsertitleStatusT `json:"usertitle_status"`
+ UserRate *int32 `json:"user_rate"`
+ ReviewID *int64 `json:"review_id"`
+ UserCtime time.Time `json:"user_ctime"`
+ TitleStorageType *StorageTypeT `json:"title_storage_type"`
+ TitleImagePath *string `json:"title_image_path"`
+ TagNames json.RawMessage `json:"tag_names"`
+ StudioName *string `json:"studio_name"`
}
// 100 is default limit
-// OFFSET sqlc.narg('offset')::int;
func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) {
rows, err := q.db.Query(ctx, searchUserTitles,
+ arg.UserID,
+ arg.Forward,
+ arg.SortBy,
+ arg.CursorYear,
+ arg.CursorID,
+ arg.CursorRating,
arg.Word,
- arg.Status,
+ arg.TitleStatuses,
+ arg.UsertitleStatuses,
+ arg.Rate,
arg.Rating,
arg.ReleaseYear,
arg.ReleaseSeason,
- arg.UsertitleStatus,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
- var items []SearchUserTitlesRow
+ items := []SearchUserTitlesRow{}
for rows.Next() {
var i SearchUserTitlesRow
if err := rows.Scan(
- &i.UserID,
- &i.TitleID,
- &i.Status,
- &i.Rate,
- &i.ReviewID,
- &i.Ctime,
&i.ID,
&i.TitleNames,
- &i.StudioID,
&i.PosterID,
&i.TitleStatus,
&i.Rating,
@@ -501,7 +860,15 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara
&i.Season,
&i.EpisodesAired,
&i.EpisodesAll,
- &i.EpisodesLen,
+ &i.UserID,
+ &i.UsertitleStatus,
+ &i.UserRate,
+ &i.ReviewID,
+ &i.UserCtime,
+ &i.TitleStorageType,
+ &i.TitleImagePath,
+ &i.TagNames,
+ &i.StudioName,
); err != nil {
return nil, err
}
@@ -512,3 +879,91 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara
}
return items, nil
}
+
+const updateUser = `-- name: UpdateUser :one
+UPDATE users
+SET
+ avatar_id = COALESCE($1, avatar_id),
+ disp_name = COALESCE($2, disp_name),
+ user_desc = COALESCE($3, user_desc),
+ mail = COALESCE($4, mail)
+WHERE id = $5
+RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail
+`
+
+type UpdateUserParams struct {
+ AvatarID *int64 `json:"avatar_id"`
+ DispName *string `json:"disp_name"`
+ UserDesc *string `json:"user_desc"`
+ Mail *string `json:"mail"`
+ UserID int64 `json:"user_id"`
+}
+
+type UpdateUserRow struct {
+ ID int64 `json:"id"`
+ AvatarID *int64 `json:"avatar_id"`
+ Nickname string `json:"nickname"`
+ DispName *string `json:"disp_name"`
+ UserDesc *string `json:"user_desc"`
+ CreationDate time.Time `json:"creation_date"`
+ Mail *string `json:"mail"`
+}
+
+func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) {
+ row := q.db.QueryRow(ctx, updateUser,
+ arg.AvatarID,
+ arg.DispName,
+ arg.UserDesc,
+ arg.Mail,
+ arg.UserID,
+ )
+ var i UpdateUserRow
+ err := row.Scan(
+ &i.ID,
+ &i.AvatarID,
+ &i.Nickname,
+ &i.DispName,
+ &i.UserDesc,
+ &i.CreationDate,
+ &i.Mail,
+ )
+ return i, err
+}
+
+const updateUserTitle = `-- name: UpdateUserTitle :one
+UPDATE usertitles
+SET
+ status = COALESCE($1::usertitle_status_t, status),
+ rate = COALESCE($2::int, rate)
+WHERE
+ user_id = $3
+ AND title_id = $4
+RETURNING user_id, title_id, status, rate, review_id, ctime
+`
+
+type UpdateUserTitleParams struct {
+ Status *UsertitleStatusT `json:"status"`
+ Rate *int32 `json:"rate"`
+ UserID int64 `json:"user_id"`
+ TitleID int64 `json:"title_id"`
+}
+
+// Fails with sql.ErrNoRows if (user_id, title_id) not found
+func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams) (Usertitle, error) {
+ row := q.db.QueryRow(ctx, updateUserTitle,
+ arg.Status,
+ arg.Rate,
+ arg.UserID,
+ arg.TitleID,
+ )
+ var i Usertitle
+ err := row.Scan(
+ &i.UserID,
+ &i.TitleID,
+ &i.Status,
+ &i.Rate,
+ &i.ReviewID,
+ &i.Ctime,
+ )
+ return i, err
+}
diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml
index f74d2ad..904abaf 100644
--- a/sql/sqlc.yaml
+++ b/sql/sqlc.yaml
@@ -3,6 +3,7 @@ sql:
- engine: "postgresql"
queries:
- "../modules/backend/queries.sql"
+ - "../modules/auth/queries.sql"
schema: "migrations"
gen:
go:
@@ -12,7 +13,20 @@ sql:
sql_driver: "github.com/jackc/pgx/v5"
emit_json_tags: true
emit_pointers_for_null_types: true
+ emit_empty_slices: true #slices returned by :many queries will be empty instead of nil
overrides:
+ - db_type: "usertitle_status_t"
+ nullable: true
+ go_type:
+ type: "UsertitleStatusT"
+ pointer: true
+ - db_type: "storage_type_t"
+ nullable: true
+ go_type:
+ type: "StorageTypeT"
+ pointer: true
+ - db_type: "jsonb"
+ go_type: "encoding/json.RawMessage"
- db_type: "uuid"
nullable: false
go_type: