feat(likes): sync local favorites and add auto-login flow

- Add Prisma code generation in Dockerfile build step.
- Implement instant state updates for favoriting players via store hooks.
- Enhance 'FavPlayersPage' with:
  - New "Sync to cloud" and "Clear local favorites" actions.
  - Updated tabs showing counts for local vs. account favorites.
  - Auto-detection of view mode based on authentication status.
- Add auto-login support in 'UserCenter' triggered by query parameter `autoSignIn=true`.
This commit is contained in:
kyuuseiryuu 2026-03-09 16:51:57 +09:00
parent 80aebac57a
commit 9499de0180
4 changed files with 44 additions and 19 deletions

View File

@ -1,6 +1,6 @@
FROM oven/bun:latest
COPY . /app
WORKDIR /app
RUN bun install
RUN bun install && bunx --bun prisma generate
ENTRYPOINT [ "bun", "start"]
EXPOSE 3000

View File

@ -22,6 +22,7 @@ const StyledContainer = styled.div`
export function FavButton(props: Props) {
const { isAuthenticated, getIdTokenClaims } = useLogto();
const { fav, unFav } = useFavPlayerStore(store => store);
const favReq = useRequest(async () => {
if (!isAuthenticated) return;
const claims = await getIdTokenClaims();
@ -48,10 +49,12 @@ export function FavButton(props: Props) {
if (!props.user) return;
setValue(value);
if (value) {
fav(props.user);
favReq.run();
setValue(1);
} else {
unFavReq.run();
unFav(props.user.uid);
setValue(0);
}
}, []);

View File

@ -1,9 +1,9 @@
import { Avatar, Button, Card, Divider, Flex, Image, Segmented, Spin, Typography } from "antd";
import { Avatar, Button, Card, Divider, Flex, Image, Popconfirm, Radio, Segmented, Spin, Typography } from "antd";
import { useFavPlayerStore } from "../store/useFavPlayerStore";
import { useNavigate } from "react-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLogto } from "@logto/react";
import { LoginOutlined, SyncOutlined, UploadOutlined } from "@ant-design/icons";
import { DeleteOutlined, LoginOutlined, UploadOutlined } from "@ant-design/icons";
import { useRequest } from "ahooks";
import type { XCXProfile } from "../types";
@ -18,7 +18,7 @@ enum ShowType {
}
export function FavePlayersPage() {
const { favMap } = useFavPlayerStore(state => state);
const { favMap, unFav } = useFavPlayerStore(state => state);
const [sortType, setSortType] = useState<SortType>(SortType.DEFAULT);
const list = useMemo(() => {
const l = Object.values(favMap);
@ -30,7 +30,7 @@ export function FavePlayersPage() {
}
}, [favMap, sortType]);
const navigate = useNavigate();
const { isAuthenticated, getIdTokenClaims, signIn } = useLogto();
const { isAuthenticated, getIdTokenClaims } = useLogto();
const favListRequest = useRequest<XCXProfile[], [string]>(async (aud: string) => {
const res = await fetch(`/api/fav/${aud}`);
const data = await res.json();
@ -55,6 +55,12 @@ export function FavePlayersPage() {
await favListRequest.runAsync(aud!);
}, [isAuthenticated, list]);
const [showType, setShowType] = useState(ShowType.LOCAL);
const handleClearLocal = useCallback(() => {
list.forEach(e => unFav(e.uid));
}, []);
useEffect(() => {
setShowType(isAuthenticated ? ShowType.ACCOUNT : ShowType.LOCAL);
}, [isAuthenticated]);
const showList = useMemo(() => {
if (showType === ShowType.LOCAL) {
return list;
@ -71,13 +77,13 @@ export function FavePlayersPage() {
<Flex vertical gap={48}>
<Typography.Title></Typography.Title>
<div style={{ display: isAuthenticated ? 'block' : 'none' }}>
<Segmented
disabled={!isAuthenticated}
<Radio.Group
optionType="button"
value={showType}
onChange={setShowType}
onChange={e => setShowType(e.target.value)}
options={[
ShowType.LOCAL,
ShowType.ACCOUNT,
{ label: `${ShowType.LOCAL}(${list.length})`, value: ShowType.LOCAL},
{ label: `${ShowType.ACCOUNT}(${favListRequest.data?.length ?? 0})`, value: ShowType.ACCOUNT}
]}
/>
</div>
@ -125,18 +131,25 @@ export function FavePlayersPage() {
</Card>
))}
</Flex>
<div style={{ marginTop: 12 }}>
{ (showType === ShowType.LOCAL && isAuthenticated && list.length > 0) && <Button onClick={handleSyncFav} icon={<UploadOutlined />}></Button> }
<Flex vertical gap={12} style={{ marginTop: 12 }}>
{ (showType === ShowType.LOCAL && isAuthenticated && list.length > 0) && (
<Button onClick={handleSyncFav} icon={<UploadOutlined />}></Button>
) }
{ (showType === ShowType.LOCAL && !isAuthenticated && list.length > 0) && (
<Button
onClick={() => {
sessionStorage.setItem('redirect', window.location.pathname);
navigate('/user-center');
navigate('/user-center?autoSignIn=true');
}}
icon={<LoginOutlined />}
></Button>
) }
</div>
{(showType === ShowType.LOCAL && (
<Popconfirm title='确定要清空本地收藏吗?' onConfirm={handleClearLocal}>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
))}
</Flex>
</>
)}
</Spin>

View File

@ -2,7 +2,7 @@ import { EditOutlined, FileTextOutlined, HomeOutlined, KeyOutlined, LockOutlined
import { useLogto, type IdTokenClaims } from "@logto/react";
import { Avatar, Button, Divider, Flex, Modal, Typography } from "antd";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { useLocation, useNavigate } from "react-router";
enum modifyRoutes {
username = '/account/username',
@ -20,6 +20,18 @@ export const UserCenter = () => {
const { signIn, isAuthenticated, signOut, getIdTokenClaims } = useLogto();
const [user, setUser] = useState<IdTokenClaims>();
const navigate = useNavigate();
const location = useLocation();
const handleSignIn = useCallback(() => {
const redirect = `${window.location.origin}/auth/callback`;
signIn(redirect);
}, []);
useEffect(() => {
if (isAuthenticated) return;
const autoSignIn = new URLSearchParams(location.search).get('autoSignIn');
if (autoSignIn) {
handleSignIn();
}
}, [isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) return;
getIdTokenClaims().then(claims => setUser(claims));
@ -35,10 +47,7 @@ export const UserCenter = () => {
type="primary"
icon={<LogoutOutlined />}
size="large"
onClick={() => {
const redirect = `${window.location.origin}/auth/callback`;
signIn(redirect);
}}
onClick={handleSignIn}
>
</Button>