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 FROM oven/bun:latest
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN bun install RUN bun install && bunx --bun prisma generate
ENTRYPOINT [ "bun", "start"] ENTRYPOINT [ "bun", "start"]
EXPOSE 3000 EXPOSE 3000

View File

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

View File

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