feat: add Web3 punk avatars and official social links

- Add PunkAvatar component for Web3-style trader avatars
- Integrate punk avatars into trader cards and dashboard header
- Add official Twitter/Telegram links to footer with anti-fork protection
- Create branding.ts with Base64 encoded official links
This commit is contained in:
tinkle-community
2025-12-08 17:52:11 +08:00
parent e55a6a6ff4
commit 9d6b631cd9
9 changed files with 619 additions and 66 deletions

View File

@@ -14,6 +14,7 @@ import { useAuth } from '../contexts/AuthContext'
import { getExchangeIcon } from './ExchangeIcons'
import { getModelIcon } from './ModelIcons'
import { TraderConfigModal } from './TraderConfigModal'
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
import {
TwoStageKeyModal,
type TwoStageKeyModalResult,
@@ -980,16 +981,17 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-3 md:gap-4">
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-full flex items-center justify-center flex-shrink-0"
style={{
background: trader.ai_model.includes('deepseek')
? '#60a5fa'
: '#c084fc',
color: '#fff',
}}
>
<Bot className="w-5 h-5 md:w-6 md:h-6" />
<div className="flex-shrink-0">
<PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={48}
className="rounded-lg hidden md:block"
/>
<PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={40}
className="rounded-lg md:hidden"
/>
</div>
<div className="min-w-0">
<div

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Trophy, Medal } from 'lucide-react'
import { Trophy } from 'lucide-react'
import useSWR from 'swr'
import { api } from '../lib/api'
import type { CompetitionData } from '../types'
@@ -8,6 +8,7 @@ import { TraderConfigViewModal } from './TraderConfigViewModal'
import { getTraderColor } from '../utils/traderColors'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
export function CompetitionPage() {
const { language } = useLanguage()
@@ -259,21 +260,30 @@ export function CompetitionPage() {
}}
>
<div className="flex items-center justify-between">
{/* Rank & Name */}
{/* Rank & Avatar & Name */}
<div className="flex items-center gap-3">
<div className="w-6 flex items-center justify-center">
<Medal
className="w-5 h-5"
style={{
color:
index === 0
? '#F0B90B'
: index === 1
? '#C0C0C0'
: '#CD7F32',
}}
/>
{/* Rank Badge */}
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
style={{
background: index === 0
? 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)'
: index === 1
? 'linear-gradient(135deg, #C0C0C0 0%, #E8E8E8 100%)'
: index === 2
? 'linear-gradient(135deg, #CD7F32 0%, #E8A64C 100%)'
: '#2B3139',
color: index < 3 ? '#000' : '#848E9C',
}}
>
{index + 1}
</div>
{/* Punk Avatar */}
<PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={36}
className="rounded-lg"
/>
<div>
<div
className="font-bold text-sm"
@@ -424,6 +434,14 @@ export function CompetitionPage() {
}
>
<div className="text-center">
{/* Avatar */}
<div className="flex justify-center mb-3">
<PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={56}
className="rounded-xl"
/>
</div>
<div
className="text-sm md:text-base font-bold mb-2"
style={{

View File

@@ -0,0 +1,343 @@
import { useMemo } from 'react'
interface PunkAvatarProps {
seed: string
size?: number
className?: string
}
// Hash function to generate consistent random values from seed
function hashCode(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return Math.abs(hash)
}
// Get a value from hash at specific position
function getHashValue(hash: number, position: number, max: number): number {
return ((hash >> (position * 4)) & 0xF) % max
}
// Color palettes - Web3/Crypto aesthetic
const BACKGROUNDS = [
'#1a1a2e', '#16213e', '#0f3460', '#1b1b2f', '#162447',
'#1f1f3d', '#2d132c', '#1e1e3f', '#0d1b2a', '#1b263b',
'#252538', '#2a2a4a', '#1e2a3a', '#0f172a', '#1a1f35',
]
const SKIN_TONES = [
'#ffd5c8', '#f5c5b5', '#daa06d', '#c68642', '#8d5524',
'#6b4423', '#4a3728', '#ffdbac', '#f1c27d', '#e0ac69',
]
const HAIR_COLORS = [
'#090806', '#2c222b', '#3b3024', '#4a4035', '#504444',
'#6a4e42', '#a55728', '#b55239', '#8d4a43', '#91553d',
'#e6cea8', '#e5c8a8', '#debc99', '#977961', '#343434',
'#9a3300', '#ff6b6b', '#4ecdc4', '#ffe66d', '#a855f7',
]
const ACCESSORY_COLORS = [
'#F0B90B', '#0ECB81', '#F6465D', '#60a5fa', '#a855f7',
'#ec4899', '#14b8a6', '#f97316', '#84cc16', '#06b6d4',
]
export function PunkAvatar({ seed, size = 40, className = '' }: PunkAvatarProps) {
const avatar = useMemo(() => {
const hash = hashCode(seed)
// Deterministic selections based on hash
const bgColor = BACKGROUNDS[getHashValue(hash, 0, BACKGROUNDS.length)]
const skinColor = SKIN_TONES[getHashValue(hash, 1, SKIN_TONES.length)]
const hairColor = HAIR_COLORS[getHashValue(hash, 2, HAIR_COLORS.length)]
const accColor = ACCESSORY_COLORS[getHashValue(hash, 3, ACCESSORY_COLORS.length)]
const hairStyle = getHashValue(hash, 4, 8)
const eyeStyle = getHashValue(hash, 5, 6)
const mouthStyle = getHashValue(hash, 6, 5)
const hasGlasses = getHashValue(hash, 7, 4) === 0
const hasEarring = getHashValue(hash, 8, 5) === 0
const hasMask = getHashValue(hash, 9, 8) === 0
const hasLaser = getHashValue(hash, 10, 12) === 0
return {
bgColor,
skinColor,
hairColor,
accColor,
hairStyle,
eyeStyle,
mouthStyle,
hasGlasses,
hasEarring,
hasMask,
hasLaser,
}
}, [seed])
// Pixel size for 24x24 grid
const px = size / 24
const renderHair = () => {
const { hairColor, hairStyle } = avatar
switch (hairStyle) {
case 0: // Mohawk
return (
<>
<rect x={11*px} y={2*px} width={2*px} height={5*px} fill={hairColor} />
<rect x={10*px} y={3*px} width={4*px} height={1*px} fill={hairColor} />
</>
)
case 1: // Messy
return (
<>
<rect x={7*px} y={4*px} width={10*px} height={3*px} fill={hairColor} />
<rect x={8*px} y={3*px} width={8*px} height={1*px} fill={hairColor} />
<rect x={6*px} y={5*px} width={2*px} height={2*px} fill={hairColor} />
<rect x={16*px} y={5*px} width={2*px} height={2*px} fill={hairColor} />
</>
)
case 2: // Cap
return (
<>
<rect x={6*px} y={5*px} width={12*px} height={3*px} fill={avatar.accColor} />
<rect x={5*px} y={7*px} width={14*px} height={1*px} fill={avatar.accColor} />
<rect x={7*px} y={4*px} width={10*px} height={1*px} fill={avatar.accColor} />
</>
)
case 3: // Long
return (
<>
<rect x={7*px} y={4*px} width={10*px} height={4*px} fill={hairColor} />
<rect x={6*px} y={6*px} width={2*px} height={8*px} fill={hairColor} />
<rect x={16*px} y={6*px} width={2*px} height={8*px} fill={hairColor} />
</>
)
case 4: // Bald with shine
return (
<rect x={9*px} y={5*px} width={2*px} height={1*px} fill="rgba(255,255,255,0.3)" />
)
case 5: // Spiky
return (
<>
<rect x={7*px} y={5*px} width={10*px} height={2*px} fill={hairColor} />
<rect x={8*px} y={3*px} width={2*px} height={2*px} fill={hairColor} />
<rect x={11*px} y={2*px} width={2*px} height={3*px} fill={hairColor} />
<rect x={14*px} y={3*px} width={2*px} height={2*px} fill={hairColor} />
</>
)
case 6: // Hoodie
return (
<>
<rect x={5*px} y={6*px} width={14*px} height={6*px} fill={avatar.accColor} />
<rect x={6*px} y={5*px} width={12*px} height={1*px} fill={avatar.accColor} />
<rect x={8*px} y={8*px} width={8*px} height={4*px} fill={avatar.skinColor} />
</>
)
case 7: // Crown
return (
<>
<rect x={7*px} y={4*px} width={10*px} height={1*px} fill="#F0B90B" />
<rect x={8*px} y={2*px} width={2*px} height={2*px} fill="#F0B90B" />
<rect x={11*px} y={1*px} width={2*px} height={3*px} fill="#F0B90B" />
<rect x={14*px} y={2*px} width={2*px} height={2*px} fill="#F0B90B" />
</>
)
default:
return null
}
}
const renderEyes = () => {
const { eyeStyle, accColor } = avatar
const eyeY = 10 * px
switch (eyeStyle) {
case 0: // Normal
return (
<>
<rect x={8*px} y={eyeY} width={2*px} height={2*px} fill="#000" />
<rect x={14*px} y={eyeY} width={2*px} height={2*px} fill="#000" />
<rect x={8*px} y={eyeY} width={1*px} height={1*px} fill="#fff" />
<rect x={14*px} y={eyeY} width={1*px} height={1*px} fill="#fff" />
</>
)
case 1: // Angry
return (
<>
<rect x={8*px} y={eyeY} width={2*px} height={2*px} fill="#000" />
<rect x={14*px} y={eyeY} width={2*px} height={2*px} fill="#000" />
<rect x={7*px} y={9*px} width={3*px} height={1*px} fill={avatar.skinColor} />
<rect x={14*px} y={9*px} width={3*px} height={1*px} fill={avatar.skinColor} />
</>
)
case 2: // Wink
return (
<>
<rect x={8*px} y={eyeY} width={2*px} height={2*px} fill="#000" />
<rect x={14*px} y={10.5*px} width={2*px} height={1*px} fill="#000" />
</>
)
case 3: // Sleepy
return (
<>
<rect x={8*px} y={10.5*px} width={2*px} height={1*px} fill="#000" />
<rect x={14*px} y={10.5*px} width={2*px} height={1*px} fill="#000" />
</>
)
case 4: // Big eyes
return (
<>
<rect x={7*px} y={9*px} width={3*px} height={3*px} fill="#fff" />
<rect x={14*px} y={9*px} width={3*px} height={3*px} fill="#fff" />
<rect x={8*px} y={10*px} width={2*px} height={2*px} fill="#000" />
<rect x={15*px} y={10*px} width={2*px} height={2*px} fill="#000" />
</>
)
case 5: // Robot
return (
<>
<rect x={7*px} y={9*px} width={3*px} height={3*px} fill={accColor} />
<rect x={14*px} y={9*px} width={3*px} height={3*px} fill={accColor} />
<rect x={8*px} y={10*px} width={1*px} height={1*px} fill="#000" />
<rect x={15*px} y={10*px} width={1*px} height={1*px} fill="#000" />
</>
)
default:
return null
}
}
const renderMouth = () => {
const { mouthStyle } = avatar
const mouthY = 14 * px
switch (mouthStyle) {
case 0: // Smile
return (
<>
<rect x={10*px} y={mouthY} width={4*px} height={1*px} fill="#000" />
<rect x={9*px} y={13*px} width={1*px} height={1*px} fill="#000" />
<rect x={14*px} y={13*px} width={1*px} height={1*px} fill="#000" />
</>
)
case 1: // Neutral
return <rect x={10*px} y={mouthY} width={4*px} height={1*px} fill="#000" />
case 2: // Smirk
return (
<>
<rect x={11*px} y={mouthY} width={3*px} height={1*px} fill="#000" />
<rect x={14*px} y={13*px} width={1*px} height={1*px} fill="#000" />
</>
)
case 3: // Open
return (
<>
<rect x={10*px} y={13*px} width={4*px} height={2*px} fill="#000" />
<rect x={11*px} y={14*px} width={2*px} height={1*px} fill="#ff6b6b" />
</>
)
case 4: // Teeth
return (
<>
<rect x={10*px} y={mouthY} width={4*px} height={2*px} fill="#000" />
<rect x={10*px} y={mouthY} width={4*px} height={1*px} fill="#fff" />
</>
)
default:
return null
}
}
const renderAccessories = () => {
const { hasGlasses, hasEarring, hasMask, hasLaser, accColor } = avatar
const elements = []
if (hasGlasses) {
elements.push(
<g key="glasses">
<rect x={6*px} y={9*px} width={5*px} height={4*px} fill="transparent" stroke={accColor} strokeWidth={px} />
<rect x={13*px} y={9*px} width={5*px} height={4*px} fill="transparent" stroke={accColor} strokeWidth={px} />
<rect x={11*px} y={10*px} width={2*px} height={1*px} fill={accColor} />
</g>
)
}
if (hasEarring) {
elements.push(
<circle key="earring" cx={5*px} cy={12*px} r={px} fill="#F0B90B" />
)
}
if (hasMask) {
elements.push(
<g key="mask">
<rect x={7*px} y={13*px} width={10*px} height={4*px} fill="#1a1a2e" />
<rect x={8*px} y={14*px} width={2*px} height={1*px} fill={accColor} />
<rect x={14*px} y={14*px} width={2*px} height={1*px} fill={accColor} />
</g>
)
}
if (hasLaser) {
elements.push(
<g key="laser">
<rect x={9*px} y={10*px} width={15*px} height={2*px} fill="#F6465D" opacity={0.8} />
<rect x={10*px} y={10.5*px} width={14*px} height={1*px} fill="#fff" opacity={0.5} />
</g>
)
}
return elements
}
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={className}
style={{ imageRendering: 'pixelated' }}
>
{/* Background */}
<rect width={size} height={size} fill={avatar.bgColor} rx={size * 0.15} />
{/* Head shape */}
<rect x={7*px} y={6*px} width={10*px} height={12*px} fill={avatar.skinColor} />
<rect x={8*px} y={5*px} width={8*px} height={1*px} fill={avatar.skinColor} />
<rect x={8*px} y={18*px} width={8*px} height={1*px} fill={avatar.skinColor} />
{/* Ears */}
<rect x={6*px} y={10*px} width={1*px} height={3*px} fill={avatar.skinColor} />
<rect x={17*px} y={10*px} width={1*px} height={3*px} fill={avatar.skinColor} />
{/* Neck */}
<rect x={10*px} y={18*px} width={4*px} height={3*px} fill={avatar.skinColor} />
{/* Hair (rendered before accessories) */}
{renderHair()}
{/* Eyes */}
{renderEyes()}
{/* Nose */}
<rect x={11*px} y={12*px} width={2*px} height={1*px} fill={avatar.skinColor} style={{ filter: 'brightness(0.9)' }} />
{/* Mouth */}
{renderMouth()}
{/* Accessories (glasses, earrings, etc.) */}
{renderAccessories()}
</svg>
)
}
// Pre-defined punk collection for special traders
export function getTraderAvatar(traderId: string, traderName: string): string {
// Use a combination of ID and name for more unique results
return `${traderId}-${traderName}`
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { toast } from 'sonner'
import type { TraderConfigData } from '../types'
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
@@ -91,9 +92,11 @@ export function TraderConfigViewModal({
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center">
<span className="text-lg">👁</span>
</div>
<PunkAvatar
seed={getTraderAvatar(traderData.trader_id || '', traderData.trader_name)}
size={48}
className="rounded-lg"
/>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]"></h2>
<p className="text-sm text-[#848E9C] mt-1">

View File

@@ -2,6 +2,7 @@ import { Bot, BarChart3, Trash2, Pencil } from 'lucide-react'
import { t, type Language } from '../../../i18n/translations'
import { getModelDisplayName } from '../index'
import type { TraderInfo } from '../../../types'
import { PunkAvatar, getTraderAvatar } from '../../PunkAvatar'
interface TradersGridProps {
language: Language
@@ -43,16 +44,17 @@ export function TradersGrid({
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-3 md:gap-4">
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-full flex items-center justify-center flex-shrink-0"
style={{
background: trader.ai_model.includes('deepseek')
? '#60a5fa'
: '#c084fc',
color: '#fff',
}}
>
<Bot className="w-5 h-5 md:w-6 md:h-6" />
<div className="flex-shrink-0">
<PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={48}
className="rounded-lg hidden md:block"
/>
<PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={40}
className="rounded-lg md:hidden"
/>
</div>
<div className="min-w-0">
<div