|
1 | 1 | import { cn } from "@/lib/utils";
|
2 | 2 | import { motion } from "motion/react";
|
3 |
| -import { |
4 |
| - Dispatch, |
5 |
| - SetStateAction, |
6 |
| - useState, |
7 |
| - useCallback, |
8 |
| - useMemo, |
9 |
| -} from "react"; |
| 3 | +import { useState } from "react"; |
10 | 4 |
|
11 |
| -const MusicSheet = () => { |
12 |
| - const [active, setActive] = useState(false); |
13 |
| - const [hovered, setHovered] = useState(-1); |
| 5 | +interface CardData { |
| 6 | + id: number; |
| 7 | + title: string; |
| 8 | + subtitle: string; |
| 9 | + description: string; |
| 10 | + image: string; |
| 11 | + salary: { |
| 12 | + amount: string; |
| 13 | + className: string; |
| 14 | + }; |
| 15 | +} |
14 | 16 |
|
15 |
| - const items = useMemo( |
16 |
| - () => |
17 |
| - [ |
18 |
| - { content: "Nothing 1", angle: 10 }, |
19 |
| - { content: "Nothing 2", angle: 15 }, |
20 |
| - { content: "Nothing 3", angle: -10 }, |
21 |
| - { content: "Nothing 4", angle: -5 }, |
22 |
| - ].map((item, idx) => ({ |
23 |
| - ...item, |
24 |
| - id: idx + 1, |
25 |
| - })), |
26 |
| - [] |
27 |
| - ); |
| 17 | +const INITIAL_CARDS = [ |
| 18 | + { |
| 19 | + id: 1, |
| 20 | + title: "Developer", |
| 21 | + subtitle: "Software Engineer", |
| 22 | + description: |
| 23 | + "I'm a software engineer with a passion for building web and mobile applications.", |
| 24 | + image: "/things/dev.png", |
| 25 | + salary: { |
| 26 | + amount: "100,000", |
| 27 | + className: "text-green-500 bg-green-500/10 border border-green-500", |
| 28 | + }, |
| 29 | + }, |
| 30 | + { |
| 31 | + id: 2, |
| 32 | + title: "Nurse", |
| 33 | + subtitle: "Medical Assistant", |
| 34 | + description: "I'm a nurse with a passion for helping people.", |
| 35 | + image: "/things/nurse.png", |
| 36 | + salary: { |
| 37 | + amount: "50,000", |
| 38 | + className: "text-blue-500 bg-blue-500/10 border border-blue-500", |
| 39 | + }, |
| 40 | + }, |
| 41 | + { |
| 42 | + id: 3, |
| 43 | + title: "Photographer", |
| 44 | + subtitle: "Camera Man", |
| 45 | + description: "I'm a photographer with a passion for capturing moments.", |
| 46 | + image: "/things/photographer.png", |
| 47 | + salary: { |
| 48 | + amount: "30,000", |
| 49 | + className: "text-yellow-500 bg-yellow-500/10 border border-yellow-500", |
| 50 | + }, |
| 51 | + }, |
| 52 | + { |
| 53 | + id: 4, |
| 54 | + title: "Waiter", |
| 55 | + subtitle: "Restaurant Staff", |
| 56 | + description: "I'm a waiter with a passion for serving people.", |
| 57 | + image: "/things/waiter.png", |
| 58 | + salary: { |
| 59 | + amount: "20,000", |
| 60 | + className: "text-blue-500 bg-blue-500/10 border border-blue-500", |
| 61 | + }, |
| 62 | + }, |
| 63 | +] as const; |
| 64 | + |
| 65 | +const ANIMATION_CONFIG = { |
| 66 | + stacked: { |
| 67 | + rotate: (index: number) => -5 * (index + 1), |
| 68 | + y: 0, |
| 69 | + }, |
| 70 | + revealed: { |
| 71 | + rotate: (index: number) => { |
| 72 | + const angles = [-10, -5, 5, 10]; |
| 73 | + return angles[index] || 0; |
| 74 | + }, |
| 75 | + y: (index: number, total: number) => { |
| 76 | + if (index === 0) return 20; |
| 77 | + if (index === total - 1) return 20; |
| 78 | + return 0; |
| 79 | + }, |
| 80 | + }, |
| 81 | +}; |
28 | 82 |
|
29 |
| - const handleMouseEnter = useCallback(() => setActive(true), []); |
30 |
| - const handleMouseLeave = useCallback(() => setActive(false), []); |
| 83 | +export default function CareerCards() { |
| 84 | + const [isRevealed, setIsRevealed] = useState(false); |
| 85 | + const [hoveredId, setHoveredId] = useState<number | null>(null); |
31 | 86 |
|
32 | 87 | return (
|
33 | 88 | <div
|
34 | 89 | className="relative size-full flex items-center justify-center rounded-xl bg-background"
|
35 |
| - onMouseEnter={handleMouseEnter} |
36 |
| - onMouseLeave={handleMouseLeave} |
| 90 | + onMouseEnter={() => setIsRevealed(true)} |
| 91 | + onMouseLeave={() => { |
| 92 | + setIsRevealed(false); |
| 93 | + setHoveredId(null); |
| 94 | + }} |
37 | 95 | >
|
38 |
| - {active ? ( |
39 |
| - <ReleasedPlayer |
40 |
| - items={items} |
41 |
| - hovered={hovered} |
42 |
| - setHovered={setHovered} |
43 |
| - /> |
44 |
| - ) : ( |
45 |
| - <Stacked items={items} hovered={hovered} setHovered={setHovered} /> |
46 |
| - )} |
| 96 | + <div |
| 97 | + className={cn("relative", isRevealed ? "w-full h-full" : "w-56 h-72")} |
| 98 | + > |
| 99 | + {INITIAL_CARDS.map((card, index) => ( |
| 100 | + <Card |
| 101 | + key={card.id} |
| 102 | + data={card} |
| 103 | + index={index} |
| 104 | + total={INITIAL_CARDS.length} |
| 105 | + isRevealed={isRevealed} |
| 106 | + isHovered={hoveredId === card.id} |
| 107 | + onHover={(id) => setHoveredId(id)} |
| 108 | + className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" |
| 109 | + /> |
| 110 | + ))} |
| 111 | + </div> |
47 | 112 | </div>
|
48 | 113 | );
|
49 |
| -}; |
| 114 | +} |
50 | 115 |
|
51 |
| -type DisplayProps = { |
52 |
| - hovered: number; |
53 |
| - setHovered: Dispatch<SetStateAction<number>>; |
54 |
| - items: { id: number; content: string; angle: number }[]; |
55 |
| -}; |
56 |
| - |
57 |
| -const Stacked = ({ items, hovered, setHovered }: DisplayProps) => ( |
58 |
| - <div className="size-40 flex items-center justify-center"> |
59 |
| - {items.map((item) => ( |
60 |
| - <Item |
61 |
| - key={item.id} |
62 |
| - item={item} |
63 |
| - hovered={hovered} |
64 |
| - setHovered={setHovered} |
65 |
| - className="absolute" |
66 |
| - /> |
67 |
| - ))} |
68 |
| - </div> |
69 |
| -); |
70 |
| - |
71 |
| -const ReleasedPlayer = ({ items, hovered, setHovered }: DisplayProps) => ( |
72 |
| - <div className="size-full flex items-center justify-center relative"> |
73 |
| - {items.map((item) => ( |
74 |
| - <Item |
75 |
| - key={item.id} |
76 |
| - item={item} |
77 |
| - hovered={hovered} |
78 |
| - setHovered={setHovered} |
79 |
| - /> |
80 |
| - ))} |
81 |
| - </div> |
82 |
| -); |
83 |
| - |
84 |
| -type ItemProps = { |
85 |
| - item: { id: number; content: string; angle: number }; |
| 116 | +interface CardProps { |
| 117 | + data: CardData; |
| 118 | + index: number; |
| 119 | + total: number; |
| 120 | + isRevealed: boolean; |
| 121 | + isHovered: boolean; |
| 122 | + onHover: (id: number | null) => void; |
86 | 123 | className?: string;
|
87 |
| - hovered: number; |
88 |
| - setHovered: Dispatch<SetStateAction<number>>; |
89 |
| -}; |
| 124 | +} |
90 | 125 |
|
91 |
| -const Item = ({ item, hovered, setHovered, className }: ItemProps) => { |
92 |
| - const handleHover = useCallback( |
93 |
| - (id: number) => { |
94 |
| - setHovered((prev) => (prev !== id ? id : prev)); |
95 |
| - }, |
96 |
| - [setHovered] |
97 |
| - ); |
| 126 | +function Card({ |
| 127 | + data, |
| 128 | + index, |
| 129 | + total, |
| 130 | + isRevealed, |
| 131 | + isHovered, |
| 132 | + onHover, |
| 133 | + className, |
| 134 | +}: CardProps) { |
| 135 | + const config = isRevealed |
| 136 | + ? ANIMATION_CONFIG.revealed |
| 137 | + : ANIMATION_CONFIG.stacked; |
| 138 | + const rotateValue = config.rotate(index); |
| 139 | + const yOffset = |
| 140 | + typeof config.y === "function" ? config.y(index, total) : config.y; |
98 | 141 |
|
99 | 142 | return (
|
100 | 143 | <motion.div
|
| 144 | + layoutId={`career-card-${data.id}`} |
101 | 145 | className={cn(
|
102 |
| - "size-56 border rounded-xl flex items-center justify-center text-4xl font-semibold bg-muted", |
103 |
| - "relative", |
| 146 | + "w-80 h-96 bg-muted rounded-2xl shadow-lg overflow-hidden cursor-pointer", |
104 | 147 | className
|
105 | 148 | )}
|
106 |
| - onMouseEnter={() => handleHover(item.id)} |
107 |
| - onMouseLeave={() => handleHover(-1)} |
108 |
| - layoutId={`sheet-player-${item.id}`} |
109 | 149 | animate={{
|
110 |
| - rotate: hovered === item.id ? 0 : item.angle, |
111 |
| - zIndex: hovered === item.id ? 100 : 0, |
112 |
| - boxShadow: |
113 |
| - hovered === item.id ? "0 0 10px 0 rgba(0, 0, 0, 0.5)" : "none", |
| 150 | + rotate: rotateValue, |
| 151 | + y: yOffset, |
| 152 | + x: isRevealed ? index * -30 : 0, |
| 153 | + scale: isHovered ? 1.02 : 1, |
| 154 | + zIndex: isHovered ? 50 : total - index, |
| 155 | + }} |
| 156 | + onMouseEnter={() => onHover(data.id)} |
| 157 | + onMouseLeave={() => onHover(null)} |
| 158 | + transition={{ |
| 159 | + type: "spring", |
| 160 | + stiffness: 200, |
| 161 | + damping: 20, |
114 | 162 | }}
|
115 |
| - transition={{ type: "spring", stiffness: 200, damping: 20 }} |
116 | 163 | >
|
117 |
| - {item.id} |
| 164 | + <div className="size-full bg-background p-4 flex flex-col gap-4"> |
| 165 | + <div className="relative aspect-square rounded-lg overflow-hidden bg-muted"> |
| 166 | + <img |
| 167 | + src={data.image} |
| 168 | + alt={data.title} |
| 169 | + className="size-full object-cover" |
| 170 | + /> |
| 171 | + <div |
| 172 | + className={cn( |
| 173 | + "absolute top-2 right-2 px-2 py-1 rounded-lg text-sm font-medium", |
| 174 | + data.salary.className |
| 175 | + )} |
| 176 | + > |
| 177 | + ${data.salary.amount} |
| 178 | + </div> |
| 179 | + </div> |
118 | 180 |
|
119 |
| - {hovered === item.id && ( |
120 |
| - <motion.div |
121 |
| - layoutId={`player-id-${item.id}`} |
122 |
| - className="bg-blue-500 w-56 h-48 absolute -top-[100%] flex items-center justify-center text-2xl rounded-xl" |
123 |
| - > |
124 |
| - {item.content} |
125 |
| - </motion.div> |
126 |
| - )} |
| 181 | + <div className="flex-1 flex flex-col"> |
| 182 | + <h3 className="text-2xl font-bold">{data.title}</h3> |
| 183 | + <p className="text-muted-foreground">{data.subtitle}</p> |
| 184 | + <p className="text-sm text-muted-foreground/80 mt-2 line-clamp-3"> |
| 185 | + {data.description} |
| 186 | + </p> |
| 187 | + </div> |
| 188 | + </div> |
127 | 189 | </motion.div>
|
128 | 190 | );
|
129 |
| -}; |
130 |
| - |
131 |
| -export default MusicSheet; |
| 191 | +} |
0 commit comments