|
| 1 | +import React from 'react'; |
| 2 | +import { useTranslation } from '@/lib/i18n'; |
| 3 | +import { ArrowLeftRight, Check, Brain } from 'lucide-react'; |
| 4 | +import { Player, RoleActionInstance } from '@/api/types.gen'; |
| 5 | +import { getRoleConfig } from '@/constants/gameData'; |
| 6 | +import { DiscordAvatar, DiscordName } from '@/components/DiscordUser'; |
| 7 | +import { EnrichedActionStatus } from './types'; |
| 8 | + |
| 9 | +interface MagicianActionCardProps { |
| 10 | + status: EnrichedActionStatus | RoleActionInstance; |
| 11 | + index?: number; |
| 12 | + players: Player[]; |
| 13 | + guildId?: string; |
| 14 | + variant?: 'default' | 'large'; |
| 15 | +} |
| 16 | + |
| 17 | +export const MagicianActionCard: React.FC<MagicianActionCardProps> = ({ |
| 18 | + status, |
| 19 | + index = 0, |
| 20 | + players, |
| 21 | + guildId, |
| 22 | + variant = 'default', |
| 23 | +}) => { |
| 24 | + const { t } = useTranslation(); |
| 25 | + |
| 26 | + // Handle potentially different shape of status object |
| 27 | + const actorRole = 'actorRole' in status ? status.actorRole : 'MAGICIAN'; |
| 28 | + const roleConfig = getRoleConfig(actorRole); |
| 29 | + const RoleIcon = roleConfig.icon || Brain; |
| 30 | + const roleName = t('roles.magician'); |
| 31 | + |
| 32 | + const isActing = status.status === 'ACTING'; |
| 33 | + const isSkipped = status.status === 'SKIPPED'; |
| 34 | + const isProcessed = status.status === 'PROCESSED'; |
| 35 | + const isSubmitted = status.status === 'SUBMITTED' || isProcessed; |
| 36 | + |
| 37 | + const t1Id = status.targets?.[0]; |
| 38 | + const t2Id = status.targets?.[1]; |
| 39 | + const t1 = t1Id && t1Id !== -1 ? players.find((p) => p.id === Number(t1Id)) : null; |
| 40 | + const t2 = t2Id && t2Id !== -1 ? players.find((p) => p.id === Number(t2Id)) : null; |
| 41 | + |
| 42 | + const actorId = 'playerUserId' in status ? status.playerUserId : (status as RoleActionInstance).actor; |
| 43 | + const actor = players.find(p => p.id === Number(actorId)); |
| 44 | + |
| 45 | + const isLarge = variant === 'large'; |
| 46 | + |
| 47 | + // --- Sub-components --- |
| 48 | + |
| 49 | + const TargetDisplay = ({ target, label }: { target: Player | null | undefined, label: string }) => ( |
| 50 | + <div className={`flex flex-col items-center gap-2 ${isLarge ? 'z-10' : 'flex-1'}`}> |
| 51 | + <div |
| 52 | + className={` |
| 53 | + rounded-full p-1 bg-white dark:bg-slate-800 relative |
| 54 | + ${isLarge |
| 55 | + ? 'w-24 h-24 shadow-xl ring-4 ring-slate-100 dark:ring-slate-800' |
| 56 | + : `w-10 h-10 border-2 overflow-hidden flex items-center justify-center ${target ? 'border-indigo-400' : 'border-slate-300 dark:border-slate-700 dashed'}` |
| 57 | + } |
| 58 | + `} |
| 59 | + > |
| 60 | + {target ? ( |
| 61 | + <div className="w-full h-full rounded-full overflow-hidden relative"> |
| 62 | + <DiscordAvatar |
| 63 | + userId={String(target.userId)} |
| 64 | + guildId={guildId} |
| 65 | + avatarClassName="w-full h-full object-cover" |
| 66 | + /> |
| 67 | + </div> |
| 68 | + ) : ( |
| 69 | + <div className="w-full h-full rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center"> |
| 70 | + <div className={`${isLarge ? 'text-4xl' : 'text-xs'} text-slate-300 dark:text-slate-600 font-thin`}> |
| 71 | + ? |
| 72 | + </div> |
| 73 | + </div> |
| 74 | + )} |
| 75 | + {isLarge && ( |
| 76 | + <div className="absolute -bottom-2 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] font-bold px-2 py-0.5 rounded-full uppercase"> |
| 77 | + {label} |
| 78 | + </div> |
| 79 | + )} |
| 80 | + </div> |
| 81 | + <div className="text-center"> |
| 82 | + {isLarge ? ( |
| 83 | + <p className="font-bold text-slate-900 dark:text-white text-lg"> |
| 84 | + {target ? ( |
| 85 | + <DiscordName |
| 86 | + userId={String(target.userId)} |
| 87 | + guildId={guildId} |
| 88 | + fallbackName={target.nickname} |
| 89 | + /> |
| 90 | + ) : ( |
| 91 | + <span className="text-slate-400 italic"> |
| 92 | + {t('nightStatus.waiting')} |
| 93 | + </span> |
| 94 | + )} |
| 95 | + </p> |
| 96 | + ) : ( |
| 97 | + <span className="text-[10px] font-bold text-slate-600 dark:text-slate-300 truncate max-w-[60px] block"> |
| 98 | + {target ? ( |
| 99 | + <DiscordName |
| 100 | + userId={String(target.userId)} |
| 101 | + guildId={guildId} |
| 102 | + fallbackName={target.nickname} |
| 103 | + /> |
| 104 | + ) : ( |
| 105 | + t('nightStatus.waiting') |
| 106 | + )} |
| 107 | + </span> |
| 108 | + )} |
| 109 | + </div> |
| 110 | + </div> |
| 111 | + ); |
| 112 | + |
| 113 | + const StatusBadge = () => ( |
| 114 | + <div |
| 115 | + className={` |
| 116 | + px-2.5 py-1 rounded-full text-[10px] font-bold tracking-wide uppercase border flex items-center gap-1 |
| 117 | + ${isLarge ? 'px-4 py-1.5 text-sm rounded-full tracking-wider' : 'rounded'} |
| 118 | + ${ |
| 119 | + isSubmitted |
| 120 | + ? 'bg-green-100 text-green-700 border-green-200 dark:bg-green-500/10 dark:text-green-400 dark:border-green-500/30' |
| 121 | + : isActing |
| 122 | + ? 'bg-purple-500/10 text-purple-500 border-purple-500/20 animate-pulse' |
| 123 | + : 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/30' |
| 124 | + } |
| 125 | + `} |
| 126 | + > |
| 127 | + {isProcessed && <Check className="w-3 h-3" />} |
| 128 | + {status.status |
| 129 | + ? t(`nightStatus.${status.status.toLowerCase()}`) |
| 130 | + : status.status} |
| 131 | + </div> |
| 132 | + ); |
| 133 | + |
| 134 | + const SwapIcon = () => ( |
| 135 | + <div className={`flex items-center justify-center ${isLarge ? 'z-10 bg-white dark:bg-slate-900 p-4 rounded-full shadow-lg border border-slate-100 dark:border-slate-800' : 'px-2'}`}> |
| 136 | + {isLarge ? ( |
| 137 | + <ArrowLeftRight className="w-8 h-8 text-purple-500" /> |
| 138 | + ) : ( |
| 139 | + <div className="w-8 h-8 rounded-full bg-white dark:bg-slate-800 shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center"> |
| 140 | + <ArrowLeftRight className="w-4 h-4 text-purple-500" /> |
| 141 | + </div> |
| 142 | + )} |
| 143 | + </div> |
| 144 | + ); |
| 145 | + |
| 146 | + const PerformerInfo = () => ( |
| 147 | + <div className={`flex items-center justify-end gap-2 opacity-60 ${isLarge ? 'mt-12 justify-center' : 'mt-3'}`}> |
| 148 | + {isLarge ? ( |
| 149 | + <div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-800/50 px-3 py-1.5 rounded-full"> |
| 150 | + <span className="uppercase text-[10px] font-bold tracking-wider">{t('nightStatus.actor')}:</span> |
| 151 | + <span>{actor?.nickname || 'Unknown'}</span> |
| 152 | + <DiscordAvatar userId={String(actor?.userId)} guildId={guildId} avatarClassName="w-4 h-4 rounded-full" /> |
| 153 | + </div> |
| 154 | + ) : ( |
| 155 | + <> |
| 156 | + <span className="text-[9px] uppercase font-bold text-slate-400">{t('nightStatus.actor')}</span> |
| 157 | + <div className="w-5 h-5 rounded-full border border-slate-200 dark:border-slate-700 overflow-hidden"> |
| 158 | + <DiscordAvatar userId={String(actorId)} guildId={guildId} avatarClassName="w-full h-full object-cover" /> |
| 159 | + </div> |
| 160 | + </> |
| 161 | + )} |
| 162 | + </div> |
| 163 | + ); |
| 164 | + |
| 165 | + const Header = () => ( |
| 166 | + <div className={isLarge ? "flex flex-col md:flex-row items-center justify-between gap-6 mb-12" : "flex justify-between items-start mb-6"}> |
| 167 | + <div className={`flex items-center ${isLarge ? 'gap-5' : 'gap-3'}`}> |
| 168 | + <div |
| 169 | + className={`flex items-center justify-center ${ |
| 170 | + isLarge |
| 171 | + ? `p-4 rounded-2xl shadow-lg` |
| 172 | + : `h-10 w-10 rounded-lg` |
| 173 | + }`} |
| 174 | + style={{ |
| 175 | + backgroundColor: isSkipped ? undefined : `${roleConfig.color}20`, |
| 176 | + color: isSkipped ? undefined : roleConfig.color, |
| 177 | + }} |
| 178 | + > |
| 179 | + <RoleIcon className={isLarge ? "w-8 h-8" : "w-5 h-5"} /> |
| 180 | + </div> |
| 181 | + <div> |
| 182 | + <h4 className={`font-bold text-slate-900 dark:text-white ${isLarge ? 'text-3xl tracking-tight' : ''}`}> |
| 183 | + {isLarge ? roleName : actorRole} |
| 184 | + </h4> |
| 185 | + <span |
| 186 | + className={ |
| 187 | + isLarge |
| 188 | + ? "text-slate-500 dark:text-slate-400 font-medium" |
| 189 | + : `text-[10px] font-bold mt-1 px-1.5 py-0.5 rounded inline-block` |
| 190 | + } |
| 191 | + style={!isLarge ? { |
| 192 | + backgroundColor: `${roleConfig.color}20`, |
| 193 | + color: roleConfig.color, |
| 194 | + } : undefined} |
| 195 | + > |
| 196 | + {t('actions.labels.MAGICIAN_SWAP') || 'Swap'} |
| 197 | + </span> |
| 198 | + </div> |
| 199 | + </div> |
| 200 | + <StatusBadge /> |
| 201 | + </div> |
| 202 | + ); |
| 203 | + |
| 204 | + const Body = () => ( |
| 205 | + <div className={ |
| 206 | + isLarge |
| 207 | + ? "flex flex-col md:flex-row items-center justify-center gap-8 md:gap-12 relative" |
| 208 | + : "bg-slate-50 dark:bg-black/20 rounded-lg p-4 relative min-h-[100px] border border-slate-100 dark:border-slate-700 flex items-center justify-between" |
| 209 | + }> |
| 210 | + <TargetDisplay target={t1} label={t('nightStatus.target') + " 1"} /> |
| 211 | + <SwapIcon /> |
| 212 | + <TargetDisplay target={t2} label={t('nightStatus.target') + " 2"} /> |
| 213 | + </div> |
| 214 | + ); |
| 215 | + |
| 216 | + // --- Render --- |
| 217 | + |
| 218 | + const content = ( |
| 219 | + <> |
| 220 | + <Header /> |
| 221 | + <Body /> |
| 222 | + <PerformerInfo /> |
| 223 | + </> |
| 224 | + ); |
| 225 | + |
| 226 | + if (isLarge) { |
| 227 | + return ( |
| 228 | + <div className="max-w-4xl mx-auto mt-10 animate-in fade-in zoom-in-95 duration-500 p-4"> |
| 229 | + <div |
| 230 | + className="relative overflow-hidden rounded-3xl shadow-2xl bg-white dark:bg-slate-900 border-2" |
| 231 | + style={{ borderColor: roleConfig.color }} |
| 232 | + > |
| 233 | + <div |
| 234 | + className="absolute top-0 left-0 right-0 h-32 opacity-10" |
| 235 | + style={{ |
| 236 | + background: `linear-gradient(to bottom, ${roleConfig.color}, transparent)`, |
| 237 | + }} |
| 238 | + /> |
| 239 | + |
| 240 | + <div className="relative p-8 md:p-12"> |
| 241 | + {content} |
| 242 | + </div> |
| 243 | + </div> |
| 244 | + </div> |
| 245 | + ); |
| 246 | + } |
| 247 | + |
| 248 | + return ( |
| 249 | + <div |
| 250 | + key={`${'actor' in status ? status.actor : (status as any).actor}-${index}`} |
| 251 | + className={`bg-white dark:bg-slate-900 rounded-xl border-l-4 border-t border-r border-b border-slate-200 dark:border-slate-800 overflow-hidden group transition-all duration-300 animate-in fade-in slide-in-from-left-4 fill-mode-both ${ |
| 252 | + isActing ? 'ring-1' : 'shadow-lg' |
| 253 | + } ${isSkipped ? 'opacity-75 grayscale border-l-slate-500' : ''}`} |
| 254 | + style={{ |
| 255 | + animationDelay: `${250 + index * 100}ms`, |
| 256 | + borderLeftColor: isSkipped ? undefined : roleConfig.color, |
| 257 | + boxShadow: isActing ? `0 0 20px ${roleConfig.color}40` : undefined, |
| 258 | + }} |
| 259 | + > |
| 260 | + <div className="p-5 relative z-10"> |
| 261 | + {content} |
| 262 | + </div> |
| 263 | + </div> |
| 264 | + ); |
| 265 | +}; |
0 commit comments