Commit b4f8fe7e authored by Chunchi Che's avatar Chunchi Che

Merge branch 'optimize/online-deck' into 'main'

优化一部分在线卡组功能

See merge request !383
parents 5d3048b6 790d6d90
Pipeline #27796 passed with stages
in 7 minutes and 54 seconds
......@@ -16,6 +16,7 @@
"classnames": "^2.3.2",
"cookies-ts": "^1.0.5",
"eventemitter3": "^5.0.1",
"fuse.js": "^7.0.0",
"google-protobuf": "^3.21.2",
"i18next": "^23.11.4",
"idb-keyval": "^6.2.1",
......@@ -3747,6 +3748,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
"engines": {
"node": ">=10"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
......@@ -9671,6 +9680,11 @@
"dev": true,
"peer": true
},
"fuse.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q=="
},
"gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
......
import { useConfig } from "@/config";
import { MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/sync/single";
interface DeleteReq {
userId: number;
deck: {
deckId: string;
isDelete: boolean;
};
}
export async function deleteDeck(
userID: number,
token: string,
deckID: string,
): Promise<MdproResp<boolean> | undefined> {
const myHeaders = mdproHeaders();
myHeaders.append("Content-Type", "application/json");
myHeaders.append("token", token);
const req: DeleteReq = {
userId: userID,
deck: {
deckId: deckID,
isDelete: true,
},
};
const resp = await fetch(`${mdproServer}/${API_PATH}`, {
method: "POST",
headers: myHeaders,
body: JSON.stringify(req),
redirect: "follow",
});
return await handleHttps(resp, API_PATH);
}
import { useConfig } from "@/config";
import { MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/deck/deckId";
export async function generateDeck(): Promise<MdproResp<string> | undefined> {
const myHeaders = mdproHeaders();
const resp = await fetch(`${mdproServer}/${API_PATH}`, {
method: "GET",
headers: myHeaders,
redirect: "follow",
});
return await handleHttps(resp, API_PATH);
}
export * from "./delete";
export * from "./generate";
export * from "./mget";
export * from "./personalList";
export * from "./pull";
export * from "./update";
export * from "./sync";
export * from "./updatePulibc";
export * from "./upload";
import { useConfig } from "@/config";
import { MdproDeck } from "./schema";
import { mdproHeaders } from "./util";
import { MdproDeck, MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/deck";
interface MgetResp {
code: number;
message: string;
data?: MdproDeck;
}
export async function mgetDeck(id: string): Promise<MgetResp | undefined> {
export async function mgetDeck(
id: string,
): Promise<MdproResp<MdproDeck> | undefined> {
const myHeaders = mdproHeaders();
const resp = await fetch(`${mdproServer}/${API_PATH}/${id}`, {
......@@ -22,10 +18,5 @@ export async function mgetDeck(id: string): Promise<MgetResp | undefined> {
redirect: "follow",
});
if (!resp.ok) {
console.error(`[Mget of Mdpro Decks] HTTPS error! status: ${resp.status}`);
return undefined;
} else {
return await resp.json();
}
return await handleHttps(resp, API_PATH);
}
import { useConfig } from "@/config";
import { MdproDeck, MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/sync/";
export interface PersonalListReq {
/* ID of MyCard Account */
userID: number;
/* Token of MyCard Account */
token: string;
}
export async function getPersonalList(
req: PersonalListReq,
): Promise<MdproResp<MdproDeck[]> | undefined> {
const myHeaders = mdproHeaders();
myHeaders.append("token", req.token);
const resp = await fetch(`${mdproServer}/${API_PATH}/${req.userID}`, {
method: "GET",
headers: myHeaders,
redirect: "follow",
});
return await handleHttps(resp, API_PATH);
}
import { useConfig } from "@/config";
import { MdproDeck } from "./schema";
import { mdproHeaders } from "./util";
import { MdproDeckLike, MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "api/mdpro3/deck/list";
......@@ -20,21 +20,17 @@ const defaultPullReq: PullReq = {
size: 20,
};
interface PullResp {
code: number;
message: string;
data?: {
current: number;
size: number;
total: number;
pages: number;
records: MdproDeck[];
};
interface RespData {
current: number;
size: number;
total: number;
pages: number;
records: MdproDeckLike[];
}
export async function pullDecks(
req: PullReq = defaultPullReq,
): Promise<PullResp | undefined> {
): Promise<MdproResp<RespData> | undefined> {
const myHeaders = mdproHeaders();
const params = new URLSearchParams();
......@@ -53,10 +49,5 @@ export async function pullDecks(
redirect: "follow",
});
if (!resp.ok) {
console.error(`[Pull of Mdpro Decks] HTTPS error! status: ${resp.status}`);
return undefined;
} else {
return await resp.json();
}
return await handleHttps(resp, API_PATH);
}
export interface MdproResp<T> {
code: number;
message: string;
data?: T;
}
export interface MdproDeck {
/*
*`ID` of the online deck.
......@@ -18,4 +24,15 @@ export interface MdproDeck {
/* Content of the deck. */
deckYdk?: string;
deckCase: number;
/* User ID of MyCard Account */
userId: number;
}
export interface MdproDeckLike {
deckId: string;
deckContributor: string;
deckName: string;
deckLike?: number;
deckCase: number;
lastDate?: string;
}
import { useConfig } from "@/config";
import { MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "/api/mdpro3/sync/single";
export interface SyncReq {
userId: number;
deckContributor: string;
deck: {
deckId: string;
deckName: string;
deckCase: number;
deckYdk: string;
};
}
export async function syncDeck(
req: SyncReq,
token: string,
): Promise<MdproResp<boolean> | undefined> {
const myHeaders = mdproHeaders();
myHeaders.append("Content-Type", "application/json");
myHeaders.append("token", token);
const resp = await fetch(`${mdproServer}/${API_PATH}`, {
method: "POST",
headers: myHeaders,
body: JSON.stringify(req),
redirect: "follow",
});
return await handleHttps(resp, API_PATH);
}
import { useConfig } from "@/config";
import { MdproDeck } from "./schema";
import { mdproHeaders } from "./util";
import { MdproResp } from "./schema";
import { handleHttps, mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "api/mdpro3/deck/update";
const API_PATH = "/api/mdpro3/deck/public";
interface UpdateResp {
code: number;
message: string;
data: MdproDeck;
export interface UpdatePublicReq {
userId: number;
deckId: string;
isPublic: boolean;
}
export async function updateDeck(
req: MdproDeck,
): Promise<UpdateResp | undefined> {
export async function updatePublic(
req: UpdatePublicReq,
token: string,
): Promise<MdproResp<void> | undefined> {
const myHeaders = mdproHeaders();
myHeaders.append("Content-Type", "application/json");
myHeaders.append("token", token);
const resp = await fetch(`${mdproServer}/${API_PATH}`, {
method: "PUT",
method: "POST",
headers: myHeaders,
body: JSON.stringify(req),
redirect: "follow",
});
if (!resp.ok) {
console.error(`[Update of MdproDeck] HTTPS error! status: ${resp.status}`);
return undefined;
} else {
return await resp.json();
}
return await handleHttps(resp, API_PATH);
}
import { useConfig } from "@/config";
import { generateDeck } from "./generate";
import { MdproResp } from "./schema";
import { syncDeck } from "./sync";
import { updatePublic } from "./updatePulibc";
import { MdproDeck } from "./schema";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "api/mdpro3/deck/upload";
interface UploadResp {
code: number;
message: string;
data: MdproDeck;
export interface UploadReq {
userId: number;
token: string;
deckContributor: string;
deck: {
deckName: string;
deckCase: number;
deckYdk: string;
};
}
export async function uploadDeck(
req: MdproDeck,
): Promise<UploadResp | undefined> {
const myHeaders = mdproHeaders();
myHeaders.append("Content-Type", "application/json");
const resp = await fetch(`${mdproServer}/${API_PATH}`, {
method: "POST",
headers: myHeaders,
body: JSON.stringify(req),
redirect: "follow",
});
if (!resp.ok) {
console.error(
`[Upload of Mdpro Decks] HTTPS error! status: ${resp.status}`,
req: UploadReq,
): Promise<MdproResp<void> | undefined> {
const generateResp = await generateDeck();
if (generateResp === undefined) return undefined;
if (generateResp.code !== 0 || generateResp.data === undefined)
return { code: generateResp.code, message: generateResp.message };
const deckId = generateResp.data;
const syncRes = await syncDeck(
{
userId: req.userId,
deckContributor: req.deckContributor,
deck: {
deckId,
deckName: req.deck.deckName,
deckCase: req.deck.deckCase,
deckYdk: req.deck.deckYdk,
},
},
req.token,
);
if (syncRes === undefined) return undefined;
if (syncRes.code === 0 && syncRes.data === true) {
// succeed in syncing
return await updatePublic(
{
userId: req.userId,
deckId,
isPublic: true,
},
req.token,
);
return undefined;
} else {
return await resp.json();
return { code: syncRes.code, message: syncRes.message };
}
}
......@@ -4,3 +4,17 @@ export function mdproHeaders(): Headers {
return myHeaders;
}
export async function handleHttps<T>(
resp: Response,
api: string,
): Promise<T | undefined> {
if (!resp.ok) {
console.error(
`[Mdpro] Https error from api ${api}! status: ${resp.status}`,
);
return undefined;
} else {
return await resp.json();
}
}
......@@ -3,7 +3,7 @@ import { proxy } from "valtio";
import { type NeosStore } from "./shared";
export interface User {
id: string;
id: number;
username: string;
name: string;
email: string;
......
import { message, Pagination } from "antd";
import { App, Dropdown, message, Pagination } from "antd";
import { MessageInstance } from "antd/es/message/interface";
import Fuse from "fuse.js";
import React, { memo, useEffect } from "react";
import { type INTERNAL_Snapshot as Snapshot, proxy, useSnapshot } from "valtio";
import YGOProDeck from "ygopro-deck-encode";
import { mgetDeck, pullDecks } from "@/api";
import { MdproDeck } from "@/api/mdproDeck/schema";
import { deleteDeck, getPersonalList, mgetDeck, pullDecks } from "@/api";
import { MdproDeckLike } from "@/api/mdproDeck/schema";
import { useConfig } from "@/config";
import { accountStore } from "@/stores";
import { IconFont } from "@/ui/Shared";
import { setSelectedDeck } from "../..";
......@@ -18,39 +21,53 @@ const { assetsPath } = useConfig();
interface Props {
query: string;
page: number;
decks: MdproDeck[];
decks: MdproDeckLike[];
total: number;
onlyMine: boolean;
}
// TODO: useConfig
const PAGE_SIZE = 30;
const SORT_LIKE = true;
const store = proxy<Props>({ query: "", page: 1, decks: [], total: 0 });
const store = proxy<Props>({
query: "",
page: 1,
decks: [],
total: 0,
onlyMine: false,
});
export const DeckResults: React.FC = memo(() => {
const snap = useSnapshot(store);
const { message } = App.useApp();
useEffect(() => {
const update = async () => {
const resp = await pullDecks({
page: snap.page,
size: PAGE_SIZE,
keyWord: snap.query !== "" ? snap.query : undefined,
sortLike: SORT_LIKE,
});
if (resp?.data) {
const { total, records: newDecks } = resp.data;
store.total = total;
store.decks = newDecks;
} else {
store.decks = [];
}
};
if (snap.onlyMine) {
// show only decks uploaded by myself
updatePersonalList(message);
} else {
const update = async () => {
const resp = await pullDecks({
page: snap.page,
size: PAGE_SIZE,
keyWord: snap.query !== "" ? snap.query : undefined,
sortLike: SORT_LIKE,
});
if (resp?.data) {
const { total, records: newDecks } = resp.data;
store.total = total;
store.decks = newDecks;
} else {
store.decks = [];
}
};
update();
}, [snap.query, snap.page]);
update();
}
}, [snap.query, snap.page, snap.onlyMine]);
const onChangePage = async (page: number) => {
const resp = await pullDecks({
......@@ -78,7 +95,11 @@ export const DeckResults: React.FC = memo(() => {
<div className={styles.container}>
<div className={styles["search-decks"]}>
{snap.decks.map((deck) => (
<MdproDeckBlock key={deck.deckId} {...deck} />
<MdproDeckBlock
key={deck.deckId}
deck={deck}
onlyMine={snap.onlyMine}
/>
))}
</div>
<div style={{ textAlign: "center", padding: "0.625rem 0 1.25rem" }}>
......@@ -102,24 +123,116 @@ export const DeckResults: React.FC = memo(() => {
);
});
const MdproDeckBlock: React.FC<Snapshot<MdproDeck>> = (deck) => (
<div
className={styles["mdpro-deck"]}
onClick={async () => await copyMdproDeckToEditing(deck)}
>
<img
src={`${assetsPath}/deck-cases/DeckCase${deck.deckCase
.toString()
.slice(-4)}_L.png`}
/>
<div className={styles.text}>
<div>{truncateString(deck.deckName, 8)}</div>
<div>{`By ${truncateString(deck.deckContributor, 6)}`}</div>
</div>
</div>
);
const copyMdproDeckToEditing = async (mdproDeck: MdproDeck) => {
const MdproDeckBlock: React.FC<{
deck: Snapshot<MdproDeckLike>;
onlyMine: boolean;
}> = ({ deck, onlyMine }) => {
const { message } = App.useApp();
const user = accountStore.user;
const onDelete = async () => {
if (user) {
const resp = await deleteDeck(user.id, user.token, deck.deckId);
if (resp?.code === 0 && resp.data === true) {
message.success(
"删除卡组成功,由于缓存的原因请稍等片刻后重新刷新页面。",
);
// fresh when deletion succeed
await updatePersonalList(message);
} else if (resp !== undefined && resp.message !== "") {
message.error(resp.message);
} else {
message.error("删除卡组失败,请检查自己的网络状况。");
}
} else {
message.error("需要先登录萌卡才能删除卡组。");
}
};
const items = [];
if (onlyMine) {
items.push({ key: 0, label: "删除", danger: true, onClick: onDelete });
}
return (
<Dropdown
menu={{
items,
}}
trigger={["contextMenu"]}
>
<div
className={styles["mdpro-deck"]}
onClick={async () => await copyMdproDeckToEditing(deck)}
>
<img
src={`${assetsPath}/deck-cases/DeckCase${deck.deckCase
.toString()
.slice(-4)}_L.png`}
/>
<div className={styles.text}>
<div>{truncateString(deck.deckName, 8)}</div>
<div>{`By ${truncateString(deck.deckContributor, 6)}`}</div>
</div>
</div>
</Dropdown>
);
};
const updatePersonalList = async (message: MessageInstance) => {
const user = accountStore.user;
if (user) {
const resp = await getPersonalList({
userID: user.id,
token: user.token,
});
if (resp) {
if (resp.code !== 0 || resp.data === undefined) {
message.error(resp.message);
} else {
let decks = resp.data;
if (store.query !== "") {
// use `fuse.js` to search
const fuse = new Fuse(decks, {
keys: ["deckName"],
includeScore: true,
threshold: 0.3,
});
const results = fuse.search(store.query);
decks = results.map((result) => result.item);
}
const total = decks.length;
store.total = total;
if (total === 0) {
store.page = 1;
store.decks = [];
} else {
if (total <= (store.page - 1) * PAGE_SIZE)
store.page = Math.floor((total - 1) / PAGE_SIZE) + 1;
store.decks = decks.slice(
(store.page - 1) * PAGE_SIZE,
store.page * PAGE_SIZE,
);
}
}
} else {
message.error("获取个人卡组列表失败,请检查自己的网络状况。");
}
} else {
message.error("需要先登录萌卡账号才能查看自己的在线卡组");
// set to default
store.page = 1;
store.decks = [];
store.total = 0;
}
};
const copyMdproDeckToEditing = async (mdproDeck: MdproDeckLike) => {
// currently the content of the deck, which we named `Ydk`,
// haven't been downloaded, so we need to fetch from server again by `mgetDeck`
// API.
......@@ -160,4 +273,8 @@ function truncateString(str: string, maxLen: number): string {
return `${start}...${end}`;
}
export const freshMdrpoDecks = (query: string) => (store.query = query);
export const freshMdrpoDecks = (query: string, onlyMine?: boolean) => {
store.query = query;
if (onlyMine !== undefined) store.onlyMine = onlyMine;
};
......@@ -15,7 +15,7 @@ import { useDrop } from "react-dnd";
import { CardMeta, searchCards } from "@/api";
import { isToken } from "@/common";
import { emptySearchConditions, FtsConditions } from "@/middleware/sqlite/fts";
import { ScrollableArea, Type } from "@/ui/Shared";
import { ScrollableArea, Select, Type } from "@/ui/Shared";
import { Filter } from "../Filter";
import styles from "../index.module.scss";
......@@ -149,19 +149,35 @@ export const DeckDatabase: React.FC = () => {
</Button>
</Space>
<div className={styles["select-btns"]}>
<Button
block
type={
isEqual(emptySearchConditions, searchConditions)
? "text"
: "primary"
}
disabled={showMdproDecks}
icon={<FilterOutlined />}
onClick={showFilterModal}
>
筛选
</Button>
{showMdproDecks ? (
<Select
title=""
style={{ width: "18.90rem" }}
defaultValue={false}
options={[
{ value: true, label: "只显示我上传的卡组" },
{ value: false, label: "显示全部在线卡组" },
]}
onChange={
// @ts-ignore
(value) => freshMdrpoDecks(searchWord, value)
}
/>
) : (
<Button
block
type={
isEqual(emptySearchConditions, searchConditions)
? "text"
: "primary"
}
disabled={showMdproDecks}
icon={<FilterOutlined />}
onClick={showFilterModal}
>
筛选
</Button>
)}
<Dropdown
menu={{ items: dropdownOptions }}
disabled={showMdproDecks}
......
......@@ -11,7 +11,6 @@ import React, { useRef, useState } from "react";
import YGOProDeck from "ygopro-deck-encode";
import { uploadDeck } from "@/api";
import { MdproDeck } from "@/api/mdproDeck/schema";
import { accountStore, deckStore, IDeck } from "@/stores";
import { Uploader } from "../../Shared";
......@@ -130,15 +129,16 @@ export const DeckSelect: React.FC<{
const user = accountStore.user;
if (user) {
// TODO: Deck Case
const mdproDeck: MdproDeck = {
deckId: "",
const resp = await uploadDeck({
userId: user.id,
token: user.token,
deckContributor: user.username,
deckName: deck.deckName,
deckYdk: genYdkText(deck),
deckCase: DEFAULT_DECK_CASE,
};
const resp = await uploadDeck(mdproDeck);
deck: {
deckName: deck.deckName,
deckCase: DEFAULT_DECK_CASE,
deckYdk: genYdkText(deck),
},
});
if (resp) {
if (resp.code) {
message.error(resp.message);
......
......@@ -82,6 +82,8 @@ export const handleSSOLogin = async (search: string) => {
const sso = new URLSearchParams(search).get("sso");
const user = sso ? getSSOUser(new URLSearchParams(atob(sso))) : undefined;
if (user) {
// Convert userID to [`Number`] here
user.id = Number(user.id);
accountStore.login(user);
setCookie(CookieKeys.USER, JSON.stringify(user));
// TODO: toast显示登录成功
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment