diff --git a/src/lib/components/DetectedUserListItem.stories.tsx b/src/lib/components/DetectedUserListItem.stories.tsx new file mode 100644 index 0000000..b951c5b --- /dev/null +++ b/src/lib/components/DetectedUserListItem.stories.tsx @@ -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 = { + 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]) => ( + + ), +}; + +const CardsTemplate: Story = { + render: (args) => ( +
+ {args.items.map((arg, i) => ( + + key={i} + user={arg.user} + clickAction={arg.action} + actionMode={ACTION_MODE.FOLLOW} + /> + ))} +
+ ), +}; + +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, + }, + }, + ], + }, +}; diff --git a/src/lib/components/DetectedUserListItem.tsx b/src/lib/components/DetectedUserListItem.tsx new file mode 100644 index 0000000..b5d1c92 --- /dev/null +++ b/src/lib/components/DetectedUserListItem.tsx @@ -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; +}; + +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 ( +
+ + +
+ ); +}; + +export default DetectedUserListItem; diff --git a/src/lib/components/DetectedUserSource.tsx b/src/lib/components/DetectedUserSource.tsx new file mode 100644 index 0000000..62121bd --- /dev/null +++ b/src/lib/components/DetectedUserSource.tsx @@ -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) => ( +
+
+ +
+
+ +
+
+
+
+ + + +
+
+); + +export default DetectedUserSource; diff --git a/src/lib/components/UserCard.stories.tsx b/src/lib/components/UserCard.stories.tsx index 46433a4..70f3366 100644 --- a/src/lib/components/UserCard.stories.tsx +++ b/src/lib/components/UserCard.stories.tsx @@ -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 = { 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]) => ( {}} + setIsBtnHovered={() => {}} + setIsJustClicked={() => {}} /> ), }; @@ -61,8 +58,11 @@ const CardsTemplate: Story = { // biome-ignore lint/suspicious/noArrayIndexKey: key={i} user={arg.user} - clickAction={arg.action} - actionMode={ACTION_MODE.FOLLOW} + loading={false} + actionBtnLabelAndClass={{ label: "Follow", class: "btn-primary" }} + handleActionButtonClick={() => {}} + setIsBtnHovered={() => {}} + setIsJustClicked={() => {}} /> ))} @@ -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, - }, - }, - ], - }, -}; diff --git a/src/lib/components/UserCard.tsx b/src/lib/components/UserCard.tsx index 4e35447..b0a591d 100644 --- a/src/lib/components/UserCard.tsx +++ b/src/lib/components/UserCard.tsx @@ -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) => (
@@ -25,7 +23,7 @@ type UserInfoProps = { url: string; }; -const UserInfo = ({ handle, displayName, url }: UserInfoProps) => ( +export const UserInfo = ({ handle, displayName, url }: UserInfoProps) => (

@@ -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} ); - -export type Props = { +export type UserCardProps = { user: BskyUser; - actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE]; - clickAction: (user: BskyUser) => Promise; + 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 ( -
-
-
- -
-
- -
-
-
-
- - - -
-
-
- ( +
+ +
+
+ -
-
- -
- -
-
-

{user.description}

+
+
+

{user.description}

- ); -}; +
+); export default UserCard; diff --git a/src/options.tsx b/src/options.tsx index 448e32e..e8ad33d 100644 --- a/src/options.tsx +++ b/src/options.tsx @@ -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 = () => {
{filteredUsers.map((user) => ( -