refactor: usercard

This commit is contained in:
kawamataryo 2024-11-30 17:07:51 +09:00
parent 4229a39aae
commit 4383913ed5
6 changed files with 315 additions and 190 deletions

View File

@ -0,0 +1,112 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ACTION_MODE, BSKY_USER_MATCH_TYPE } from "../constants";
import DetectedUserListItem, { type Props } from "./DetectedUserListItem";
const meta: Meta<typeof DetectedUserListItem> = {
title: "Components/DetectedUserListItem",
component: DetectedUserListItem,
};
export default meta;
type Story = StoryObj<{
items: {
user: Props["user"];
action: Props["clickAction"];
}[];
}>;
const demoUser: Props["user"] = {
did: "",
handle: "kawamataryo.bsky.social",
displayName: "KawamataRyo",
description: `
Frontend engineer @lapras-inc/ TypeScript / Vue.js / Firebase / ex-FireFighter 🔥
Developer of Sky Follower Bridge.
Twitter: twitter.com/KawamataRyo
GitHub: github.com/kawamataryo
Zenn: zenn.dev/ryo_kawamata`,
avatar: "https://i.pravatar.cc/150?u=123",
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
isFollowing: false,
followingUri: "",
isBlocking: false,
blockingUri: "",
originalAvatar: "https://i.pravatar.cc/150?u=123",
originalHandle: "kawamataryo",
originalDisplayName: "KawamataRyo",
originalProfileLink: "https://x.com/kawamataryo",
};
const mockAction: Props["clickAction"] = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
};
const CardTemplate = {
render: (args: Story["args"]["items"][0]) => (
<DetectedUserListItem
user={args.user}
clickAction={args.action}
actionMode={ACTION_MODE.FOLLOW}
/>
),
};
const CardsTemplate: Story = {
render: (args) => (
<div className="divide-y divide-gray-400 border-y border-gray-400">
{args.items.map((arg, i) => (
<DetectedUserListItem
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={i}
user={arg.user}
clickAction={arg.action}
actionMode={ACTION_MODE.FOLLOW}
/>
))}
</div>
),
};
export const Default = {
...CardTemplate,
args: {
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
},
},
};
export const Cards = {
...CardsTemplate,
args: {
items: [
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
isFollowing: true,
},
},
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.DESCRIPTION,
},
},
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.DISPLAY_NAME,
inFollowing: true,
},
},
],
},
};

View File

@ -0,0 +1,99 @@
import React from "react";
import { match } from "ts-pattern";
import type { BskyUser } from "~types";
import { ACTION_MODE } from "../constants";
import DetectedUserSource from "./DetectedUserSource";
import UserCard from "./UserCard";
export type Props = {
user: BskyUser;
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
clickAction: (user: BskyUser) => Promise<void>;
};
const DetectedUserListItem = ({ user, actionMode, clickAction }: Props) => {
const [isBtnHovered, setIsBtnHovered] = React.useState(false);
const [isJustClicked, setIsJustClicked] = React.useState(false);
const actionBtnLabelAndClass = React.useMemo(
() =>
match(actionMode)
.with(ACTION_MODE.FOLLOW, ACTION_MODE.IMPORT_LIST, () => {
const follow = {
label: "Follow on Bluesky",
class: "btn-primary",
};
const following = {
label: "Following on Bluesky",
class:
"btn-outline hover:bg-transparent hover:border hover:bg-transparent hover:text-base-content",
};
const unfollow = {
label: "Unfollow on Bluesky",
class:
"text-red-500 hover:bg-transparent hover:border hover:border-red-500",
};
if (!isBtnHovered) {
return user.isFollowing ? following : follow;
}
if (user.isFollowing) {
return isJustClicked ? following : unfollow;
}
return follow;
})
.with(ACTION_MODE.BLOCK, () => {
const block = {
label: "Block on Bluesky",
class: "btn-primary",
};
const blocking = {
label: "Blocking on Bluesky",
class:
"btn-outline hover:bg-transparent hover:border hover:bg-transparent hover:text-base-content",
};
const unblock = {
label: "Unblock on Bluesky",
class:
"text-red-500 hover:bg-transparent hover:border hover:border-red-500",
};
if (!isBtnHovered) {
return user.isBlocking ? blocking : block;
}
if (user.isBlocking) {
return isJustClicked ? blocking : unblock;
}
return block;
})
.run(),
[
user.isFollowing,
user.isBlocking,
actionMode,
isBtnHovered,
isJustClicked,
],
);
const [loading, setLoading] = React.useState(false);
const handleActionButtonClick = async () => {
setLoading(true);
await clickAction(user);
setLoading(false);
setIsJustClicked(true);
};
return (
<div className="bg-base-100 w-full relative grid grid-cols-[22%_1fr] gap-5">
<DetectedUserSource user={user} />
<UserCard
user={user}
loading={loading}
actionBtnLabelAndClass={actionBtnLabelAndClass}
handleActionButtonClick={handleActionButtonClick}
setIsBtnHovered={setIsBtnHovered}
setIsJustClicked={setIsJustClicked}
/>
</div>
);
};
export default DetectedUserListItem;

View File

@ -0,0 +1,50 @@
import React from "react";
import type { BskyUser } from "~types";
import { MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
import { UserInfo, UserProfile } from "./UserCard";
type DetectedUserSourceProps = {
user: BskyUser;
};
const DetectedUserSource = ({ user }: DetectedUserSourceProps) => (
<div className="flex flex-row gap-2 bg-slate-100 dark:bg-slate-800 justify-between pr-2">
<div
className={`border-l-8 border-${
MATCH_TYPE_LABEL_AND_COLOR[user.matchType].color
} relative py-3 pl-4 pr-1 grid grid-cols-[50px_1fr]`}
>
<UserProfile
avatar={user.originalAvatar}
url={user.originalProfileLink}
/>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<UserInfo
handle={user.originalHandle}
displayName={user.originalDisplayName}
url={user.originalProfileLink}
/>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-7 w-7"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</div>
</div>
);
export default DetectedUserSource;

View File

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ACTION_MODE, BSKY_USER_MATCH_TYPE } from "../constants";
import UserCard, { type Props } from "./UserCard";
import { BSKY_USER_MATCH_TYPE } from "../constants";
import UserCard, { type UserCardProps } from "./UserCard";
const meta: Meta<typeof UserCard> = {
title: "Components/UserCard",
@ -11,12 +11,11 @@ export default meta;
type Story = StoryObj<{
items: {
user: Props["user"];
action: Props["clickAction"];
user: UserCardProps["user"];
}[];
}>;
const demoUser: Props["user"] = {
const demoUser: UserCardProps["user"] = {
did: "",
handle: "kawamataryo.bsky.social",
displayName: "KawamataRyo",
@ -38,17 +37,15 @@ const demoUser: Props["user"] = {
originalDisplayName: "KawamataRyo",
originalProfileLink: "https://x.com/kawamataryo",
};
const mockAction: Props["clickAction"] = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
};
const CardTemplate = {
render: (args: Story["args"]["items"][0]) => (
<UserCard
user={args.user}
clickAction={args.action}
actionMode={ACTION_MODE.FOLLOW}
loading={false}
actionBtnLabelAndClass={{ label: "Follow", class: "btn-primary" }}
handleActionButtonClick={() => {}}
setIsBtnHovered={() => {}}
setIsJustClicked={() => {}}
/>
),
};
@ -61,8 +58,11 @@ const CardsTemplate: Story = {
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={i}
user={arg.user}
clickAction={arg.action}
actionMode={ACTION_MODE.FOLLOW}
loading={false}
actionBtnLabelAndClass={{ label: "Follow", class: "btn-primary" }}
handleActionButtonClick={() => {}}
setIsBtnHovered={() => {}}
setIsJustClicked={() => {}}
/>
))}
</div>
@ -72,41 +72,9 @@ const CardsTemplate: Story = {
export const Default = {
...CardTemplate,
args: {
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
},
},
};
export const Cards = {
...CardsTemplate,
args: {
items: [
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
isFollowing: true,
},
},
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.DESCRIPTION,
},
},
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.DISPLAY_NAME,
inFollowing: true,
},
},
],
},
};

View File

@ -1,7 +1,5 @@
import React from "react";
import { match } from "ts-pattern";
import type { BskyUser } from "~types";
import { ACTION_MODE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
import AvatarFallbackSvg from "./Icons/AvatarFallbackSvg";
type UserProfileProps = {
@ -9,7 +7,7 @@ type UserProfileProps = {
url: string;
};
const UserProfile = ({ avatar, url }: UserProfileProps) => (
export const UserProfile = ({ avatar, url }: UserProfileProps) => (
<div className="avatar">
<div className="w-10 h-10 rounded-full border border-white">
<a href={url} target="_blank" rel="noreferrer">
@ -25,7 +23,7 @@ type UserInfoProps = {
url: string;
};
const UserInfo = ({ handle, displayName, url }: UserInfoProps) => (
export const UserInfo = ({ handle, displayName, url }: UserInfoProps) => (
<div>
<h2 className="card-title break-all text-[1.1rem] font-bold">
<a href={url} target="_blank" rel="noreferrer">
@ -48,7 +46,7 @@ type ActionButtonProps = {
setIsJustClicked: (value: boolean) => void;
};
const ActionButton = ({
export const ActionButton = ({
loading,
actionBtnLabelAndClass,
handleActionButtonClick,
@ -71,150 +69,48 @@ const ActionButton = ({
{loading ? "Processing..." : actionBtnLabelAndClass.label}
</button>
);
export type Props = {
export type UserCardProps = {
user: BskyUser;
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
clickAction: (user: BskyUser) => Promise<void>;
loading: boolean;
actionBtnLabelAndClass: { label: string; class: string };
handleActionButtonClick: () => void;
setIsBtnHovered: (value: boolean) => void;
setIsJustClicked: (value: boolean) => void;
};
const UserCard = ({ user, actionMode, clickAction }: Props) => {
const [isBtnHovered, setIsBtnHovered] = React.useState(false);
const [isJustClicked, setIsJustClicked] = React.useState(false);
const actionBtnLabelAndClass = React.useMemo(
() =>
match(actionMode)
.with(ACTION_MODE.FOLLOW, ACTION_MODE.IMPORT_LIST, () => {
const follow = {
label: "Follow on Bluesky",
class: "btn-primary",
};
const following = {
label: "Following on Bluesky",
class:
"btn-outline hover:bg-transparent hover:border hover:bg-transparent hover:text-base-content",
};
const unfollow = {
label: "Unfollow on Bluesky",
class:
"text-red-500 hover:bg-transparent hover:border hover:border-red-500",
};
if (!isBtnHovered) {
return user.isFollowing ? following : follow;
}
if (user.isFollowing) {
return isJustClicked ? following : unfollow;
}
return follow;
})
.with(ACTION_MODE.BLOCK, () => {
const block = {
label: "Block on Bluesky",
class: "btn-primary",
};
const blocking = {
label: "Blocking on Bluesky",
class:
"btn-outline hover:bg-transparent hover:border hover:bg-transparent hover:text-base-content",
};
const unblock = {
label: "Unblock on Bluesky",
class:
"text-red-500 hover:bg-transparent hover:border hover:border-red-500",
};
if (!isBtnHovered) {
return user.isBlocking ? blocking : block;
}
if (user.isBlocking) {
return isJustClicked ? blocking : unblock;
}
return block;
})
.run(),
[
user.isFollowing,
user.isBlocking,
actionMode,
isBtnHovered,
isJustClicked,
],
);
const [loading, setLoading] = React.useState(false);
const handleActionButtonClick = async () => {
setLoading(true);
await clickAction(user);
setLoading(false);
setIsJustClicked(true);
};
return (
<div className="bg-base-100 w-full relative grid grid-cols-[22%_1fr] gap-5">
<div className="flex flex-row gap-2 bg-slate-100 dark:bg-slate-800 justify-between pr-2">
<div
className={`border-l-8 border-${
MATCH_TYPE_LABEL_AND_COLOR[user.matchType].color
} relative py-3 pl-4 pr-1 grid grid-cols-[50px_1fr]`}
>
<UserProfile
avatar={user.originalAvatar}
url={user.originalProfileLink}
/>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<UserInfo
handle={user.originalHandle}
displayName={user.originalDisplayName}
url={user.originalProfileLink}
/>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-7 w-7"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</div>
</div>
<div className="relative py-3 pl-0 pr-2 grid grid-cols-[50px_1fr]">
<UserProfile
avatar={user.avatar}
const UserCard = ({
user,
loading,
actionBtnLabelAndClass,
handleActionButtonClick,
setIsBtnHovered,
setIsJustClicked,
}: UserCardProps) => (
<div className="relative py-3 pl-0 pr-2 grid grid-cols-[50px_1fr]">
<UserProfile
avatar={user.avatar}
url={`https://bsky.app/profile/${user.handle}`}
/>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<UserInfo
handle={user.handle}
displayName={user.displayName}
url={`https://bsky.app/profile/${user.handle}`}
/>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<UserInfo
handle={user.handle}
displayName={user.displayName}
url={`https://bsky.app/profile/${user.handle}`}
/>
<div className="card-actions">
<ActionButton
loading={loading}
actionBtnLabelAndClass={actionBtnLabelAndClass}
handleActionButtonClick={handleActionButtonClick}
setIsBtnHovered={setIsBtnHovered}
setIsJustClicked={setIsJustClicked}
/>
</div>
</div>
<p className="text-sm break-all">{user.description}</p>
<div className="card-actions">
<ActionButton
loading={loading}
actionBtnLabelAndClass={actionBtnLabelAndClass}
handleActionButtonClick={handleActionButtonClick}
setIsBtnHovered={setIsBtnHovered}
setIsJustClicked={setIsJustClicked}
/>
</div>
</div>
<p className="text-sm break-all">{user.description}</p>
</div>
);
};
</div>
);
export default UserCard;

View File

@ -1,10 +1,10 @@
import UserCard from "~lib/components/UserCard";
import { useBskyUserManager } from "~lib/hooks/useBskyUserManager";
import "./style.css";
import { ToastContainer, toast } from "react-toastify";
import useConfirm from "~lib/components/ConfirmDialog";
import Sidebar from "~lib/components/Sidebar";
import "react-toastify/dist/ReactToastify.css";
import DetectedUserListItem from "~lib/components/DetectedUserListItem";
const Option = () => {
const {
@ -121,7 +121,7 @@ const Option = () => {
<div className="flex flex-col gap-4">
<div className="divide-y divide-gray-500">
{filteredUsers.map((user) => (
<UserCard
<DetectedUserListItem
key={user.handle}
user={user}
clickAction={handleClickAction}