feat(hud): 添加击杀信息生成器并优化页面加载体验

- 新增击杀信息生成器页面,支持CS2和CStrike两种游戏样式
- 引入字体文件用于击杀信息渲染
- 添加多种骨架屏组件(AuthSkeleton、PageSkeleton等)改善加载体验
- 更新依赖包,新增html-to-image、react-icons等库
- 优化现有组件的样式和导入路径
- 统一使用@别名导入模块
This commit is contained in:
2026-03-11 17:29:18 +08:00
parent 6adadce2d6
commit dcc051f3f7
32 changed files with 1722 additions and 29 deletions

View File

@@ -13,17 +13,20 @@
},
"dependencies": {
"@elysiajs/eden": "^1.4.8",
"@formkit/auto-animate": "^0.9.0",
"@heroicons/react": "^2.2.0",
"@heroui/react": "^3.0.0-beta.8",
"@heroui/styles": "^3.0.0-beta.8",
"@tailwindcss/postcss": "^4.2.1",
"ahooks": "^3.9.6",
"better-auth": "^1.5.4",
"html-to-image": "^1.11.13",
"lucide-react": "^0.577.0",
"postcss": "^8.5.8",
"primeicons": "^7.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.5.0",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"swr": "^2.4.1",

9
web/src/app/hud.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom'
export default function HudLayout() {
return (
<main className="flex flex-col items-center justify-start min-h-screen gap-20 py-24 mx-auto">
<Outlet />
</main>
)
}

View File

@@ -0,0 +1,3 @@
export default function Crosshair() {
return <div></div>
}

View File

@@ -0,0 +1,68 @@
import { toPng } from 'html-to-image'
export async function Canvas2Image(e: HTMLElement, name: string, dpi: number) {
// 滚动条置顶解决生成图片不全的问题
window.scrollY = 0
document.documentElement.scrollTop = 0
document.documentElement.scrollLeft = 0
document.body.scrollTop = 0
// 保存原始样式
const originalPosition = e.style.position
const originalTop = e.style.top
const originalBottom = e.style.bottom
const originalLeft = e.style.left
const originalRight = e.style.right
const originalVisibility = e.style.visibility
try {
// 临时调整元素位置,确保在视口内可见
e.style.position = 'relative'
e.style.top = '0'
e.style.bottom = 'auto'
e.style.left = '0'
e.style.right = 'auto'
e.style.visibility = 'visible'
// 等待样式应用
await new Promise(resolve => setTimeout(resolve, 10))
const dataUrl = await toPng(e, {
backgroundColor: 'transparent', // 透明背景
pixelRatio: dpi,
quality: 1.0,
style: {
letterSpacing: 'normal',
lineHeight: 'normal',
},
})
if (dataUrl !== '') {
const link = document.createElement('a')
link.href = dataUrl
link.setAttribute('download', name + '.png')
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// 等待一小段时间确保下载已触发,然后再移除元素
await new Promise(resolve => setTimeout(resolve, 100))
// 确保元素存在且仍在 DOM 中再移除
if (link.parentNode) {
document.body.removeChild(link)
}
}
} catch (error) {
console.error('生成 PNG 失败:', error)
} finally {
// 恢复原始样式
e.style.position = originalPosition
e.style.top = originalTop
e.style.bottom = originalBottom
e.style.left = originalLeft
e.style.right = originalRight
e.style.visibility = originalVisibility
}
}

View File

@@ -0,0 +1,113 @@
/* 参考用 */
@font-face {
font-family: 'Stratum2';
src: url('@/assets/font/Stratum2.ttf');
font-style: normal;
font-weight: bold;
}
.setting-input {
@apply px-3 py-1 rounded-lg outline-none;
}
.dn-input {
@apply px-3 py-1 rounded-lg outline-none bg-gray-100 w-full;
}
.dn-item {
@apply flex flex-col gap-3 bg-white rounded-lg p-3;
}
.dn-camp {
@apply bg-gray-100 rounded w-12;
}
.dn-fix-icon {
@apply rounded p-1 w-8 transition duration-200 hover:bg-gray-300 active:scale-95;
}
/* .dn-button { */
/* } */
#OutputDiv {
clear: both;
/*width: 1920px;*/
/*height: 1080px;*/
/*position: fixed;*/
/*float: end;*/
margin: 30px auto;
/*background: pink; !*debug用的颜色*!*/
background: rgba(0, 0, 0, 0);
/**/
/*font-weight: bold;*/
/*font-family: 'Stratum2';*/
/* src: url("../assets/font/Stratum2.ttf") format('truetype'); */
}
.deathNotice {
font-family: 'Stratum2', 'Arial Unicode MS', 'Helvetica Neue', 'Helvetica', 'PingFang SC', 'Sarasa Gothic SC', 'Source Han Sans', 'Noto Sans SC',
'Alibaba Puhuiti 2', 'Alibaba Puhuiti', Avenir, Arial, sans-serif;
font-size: 18px;
line-height: 18px;
font-weight: bold;
width: max-content;
/*float: right;*/
right: 0;
padding: 6px 12px 6px 12px;
transition-property: opacity;
transition-timing-function: ease-out;
background-color: rgba(0, 0, 0, 0.65);
border-radius: 4px;
text-align: center;
/*box-shadow: inset #e10000e6 0px 0px 1px;*/
/*border: 2px solid #e10000;*/
/*border: 2px outset rgba(0,0,0,0.44);*/
}
.dn-gen {
line-height: 0.5 !important;
}
.CT {
color: rgb(111, 156, 230);
}
.T {
color: rgb(234, 190, 84);
}
.weapon {
background: rgba(0, 0, 0, 0);
height: 23px;
vertical-align: middle;
visibility: visible;
}
.prefix {
height: 24px;
}
.suffix {
height: 24px;
}
.attacker {
padding-right: 4px;
font-size: 16px;
}
.victim {
padding-left: 4px;
font-size: 16px;
}
.DispRedBorder {
/*height: 30px;*/
border: 2px solid #e10000;
padding: 4px 10px 4px 10px;
}
.dn-transparent {
background-color: rgba(0, 0, 0, 0);
opacity: 0;
}

View File

@@ -0,0 +1,554 @@
export type DeathMsg = {
attacker: string
attackerCamp: Camp
victim: string
victimCamp: Camp
weapon: Weapon
prefixIcons: prefixIcon[]
suffixIcons: suffixIcon[]
redBorder: boolean
hide?: boolean
}
export const DefaultDeathMsgs: DeathMsg[] = [
{
attacker: 'Attacker',
attackerCamp: 'T',
victim: 'Victim',
victimCamp: 'CT',
weapon: 'ak47',
prefixIcons: ['blind_kill'],
suffixIcons: ['headshot'],
redBorder: false,
hide: false,
},
{
attacker: '中文字体样式.gg',
attackerCamp: 'CT',
victim: 'Purp1e',
victimCamp: 'T',
weapon: 'awp',
prefixIcons: ['revenge', 'blind_kill'],
suffixIcons: [],
redBorder: true,
hide: false,
},
]
export const CStrikeDefaultDeathMsgs: DeathMsg[] = [
{
attacker: 'Attacker',
attackerCamp: 'T',
victim: 'Victim',
victimCamp: 'CT',
weapon: 'ak47',
prefixIcons: [],
suffixIcons: ['headshot'],
redBorder: false,
hide: false,
},
{
attacker: 'CS1.6样式',
attackerCamp: 'CT',
victim: 'Purp1e',
victimCamp: 'T',
weapon: 'awp',
prefixIcons: [],
suffixIcons: [],
redBorder: true,
hide: false,
},
]
export const DefaultDeathMsg = DefaultDeathMsgs[0]
export const CStrikeDefaultDeathMsg = CStrikeDefaultDeathMsgs[0]
export type Camp = 'CT' | 'T' | ''
export const CampValues: Camp[] = ['CT', 'T']
export type CStrikePrefixIcon = ''
export type CS2PrefixIcon = 'revenge' | 'domination' | 'blind_kill' | ''
export type prefixIcon = CStrikePrefixIcon | CS2PrefixIcon
export const CStrikePrefixIconValues: CStrikePrefixIcon[] = []
export const CS2PrefixIconValues: CS2PrefixIcon[] = ['revenge', 'domination', 'blind_kill']
export type CStrikeSuffixIcon = 'headshot' | ''
export type CS2SuffixIcon = 'noscope' | 'jumpkill' | 'throughsmoke' | 'penetrate' | 'headshot' | 'suicide' | 'kill360' | ''
export type suffixIcon = CStrikeSuffixIcon | CS2SuffixIcon
export const CStrikeSuffixIconValues: CStrikeSuffixIcon[] = ['headshot']
export const CS2SuffixIconValues: CS2SuffixIcon[] = ['noscope', 'jumpkill', 'throughsmoke', 'penetrate', 'headshot', 'suicide', 'kill360']
export type CStrikeWeapon =
| '228-compact'
| '550-commando'
| 'ak47'
| 'aug'
| 'awp'
| 'cs-grenade'
| 'd3_au-1'
| 'deagle'
| 'dual-elites'
| 'famas-1'
| 'famas'
| 'flare'
| 'gauge-super'
| 'glock'
| 'he-grenade'
| 'idf-defender'
| 'knife'
| 'krieg-552'
| 'law-grenade'
| 'm249-2'
| 'm249'
| 'mac10-1'
| 'mac10'
| 'melee'
| 'mp5'
| 'nade'
| 'p90'
| 'rifle'
| 'scout'
| 'sg552'
| 'suicide'
| 'train'
| 'ump45'
| 'usp-silencer'
| 'vault'
| 'wtf'
| 'yg1265'
| ''
export type CS2Weapon =
| 'ak47'
| 'ammobox'
| 'ammobox_threepack'
| 'armor'
| 'armor_helmet'
| 'assaultsuit_helmet_only'
| 'assaultsuit'
| 'aug'
| 'awp'
| 'axe'
| 'bayonet'
| 'bizon'
| 'breachcharge'
| 'breachcharge_projectile'
| 'bumpmine'
| 'c4'
| 'clothing_hands'
| 'controldrone'
| 'customplayer'
| 'cz75a'
| 'deagle'
| 'decoy'
| 'defuser'
| 'disconnect'
| 'diversion'
| 'dronegun'
| 'elite'
| 'famas'
| 'firebomb'
| 'fists'
| 'fiveseven'
| 'flair0'
| 'flashbang'
| 'flashbang_assist'
| 'frag_grenade'
| 'g3sg1'
| 'galilar'
| 'glock'
| 'grenadepack'
| 'grenadepack2'
| 'hammer'
| 'healthshot'
| 'heavy_armor'
| 'hegrenade'
| 'helmet'
| 'hkp2000'
| 'incgrenade'
| 'inferno'
| 'kevlar'
| 'knife'
| 'knife_bowie'
| 'knife_butterfly'
| 'knife_canis'
| 'knife_cord'
| 'knife_css'
| 'knife_falchion'
| 'knife_flip'
| 'knife_gut'
| 'knife_gypsy_jackknife'
| 'knife_karambit'
| 'knife_kukri'
| 'knife_m9_bayonet'
| 'knife_push'
| 'knife_skeleton'
| 'knife_stiletto'
| 'knife_survival_bowie'
| 'knife_t'
| 'knife_tactical'
| 'knife_twinblade'
| 'knife_ursus'
| 'knife_widowmaker'
| 'knifegg'
| 'm4a1'
| 'm4a1_silencer'
| 'm4a1_silencer_off'
| 'm249'
| 'mac10'
| 'mag7'
| 'melee'
| 'molotov'
| 'movelinear'
| 'mp5sd'
| 'mp7'
| 'mp9'
| 'negev'
| 'nova'
| 'p90'
| 'p250'
| 'p2000'
| 'planted_c4_survival'
| 'planted_c4'
| 'prop_exploding_barrel'
| 'radarjammer'
| 'revolver'
| 'sawedoff'
| 'scar20'
| 'sg556'
| 'shield'
| 'smokegrenade'
| 'snowball'
| 'spanner'
| 'spray0'
| 'ssg08'
| 'stomp_damage'
| 'tablet'
| 'tagrenade'
| 'taser'
| 'tec9'
| 'tripwirefire_projectile'
| 'tripwirefire'
| 'ump45'
| 'usp_silencer'
| 'usp_silencer_off'
| 'xm1014'
| 'zone_repulsor'
| ''
export type Weapon = CStrikeWeapon | CS2Weapon
export const CStrikeWeaponValues: CStrikeWeapon[] = [
'228-compact',
'550-commando',
'ak47',
'aug',
'awp',
'cs-grenade',
'd3_au-1',
'deagle',
'dual-elites',
'famas-1',
'famas',
'flare',
'gauge-super',
'glock',
'he-grenade',
'idf-defender',
'knife',
'krieg-552',
'law-grenade',
'm249-2',
'm249',
'mac10-1',
'mac10',
'melee',
'mp5',
'nade',
'p90',
'rifle',
'scout',
'sg552',
'suicide',
'train',
'ump45',
'usp-silencer',
'vault',
'wtf',
'yg1265',
]
export const CS2WeaponValues: CS2Weapon[] = [
'ak47',
'ammobox',
'ammobox_threepack',
'armor',
'armor_helmet',
'assaultsuit_helmet_only',
'assaultsuit',
'aug',
'awp',
'axe',
'bayonet',
'bizon',
'breachcharge',
'breachcharge_projectile',
'bumpmine',
'c4',
'clothing_hands',
'controldrone',
'customplayer',
'cz75a',
'deagle',
'decoy',
'defuser',
'disconnect',
'diversion',
'dronegun',
'elite',
'famas',
'firebomb',
'fists',
'fiveseven',
'flair0',
'flashbang',
'flashbang_assist',
'frag_grenade',
'g3sg1',
'galilar',
'glock',
'grenadepack',
'grenadepack2',
'hammer',
'healthshot',
'heavy_armor',
'hegrenade',
'helmet',
'hkp2000',
'incgrenade',
'inferno',
'kevlar',
'knife',
'knife_bowie',
'knife_butterfly',
'knife_canis',
'knife_cord',
'knife_css',
'knife_falchion',
'knife_flip',
'knife_gut',
'knife_gypsy_jackknife',
'knife_karambit',
'knife_kukri',
'knife_m9_bayonet',
'knife_push',
'knife_skeleton',
'knife_stiletto',
'knife_survival_bowie',
'knife_t',
'knife_tactical',
'knife_twinblade',
'knife_ursus',
'knife_widowmaker',
'knifegg',
'm4a1',
'm4a1_silencer',
'm4a1_silencer_off',
'm249',
'mac10',
'mag7',
'melee',
'molotov',
'movelinear',
'mp5sd',
'mp7',
'mp9',
'negev',
'nova',
'p90',
'p250',
'p2000',
'planted_c4_survival',
'planted_c4',
'prop_exploding_barrel',
'radarjammer',
'revolver',
'sawedoff',
'scar20',
'sg556',
'shield',
'smokegrenade',
'snowball',
'spanner',
'spray0',
'ssg08',
'stomp_damage',
'tablet',
'tagrenade',
'taser',
'tec9',
'tripwirefire_projectile',
'tripwirefire',
'ump45',
'usp_silencer',
'usp_silencer_off',
'xm1014',
'zone_repulsor',
]
export const CStrikeWeaponMap: Record<string, string> = {
'228-compact': '228-compact',
'550-commando': '550-commando',
ak47: 'ak47',
aug: 'aug',
awp: 'awp',
'cs-grenade': 'CS手雷',
'd3_au-1': 'D3/AU-1',
deagle: 'deagle',
'dual-elites': 'dual-elites',
'famas-1': 'famas-1',
famas: 'famas',
flare: 'flare',
'gauge-super': 'gauge-super',
glock: 'glock',
'he-grenade': 'HE手雷',
'idf-defender': 'idf-defender',
knife: 'knife',
'krieg-552': 'krieg-552',
'law-grenade': 'Law手雷',
'm249-2': 'm249-2',
m249: 'm249',
'mac10-1': 'mac10-1',
mac10: 'mac10',
melee: 'melee',
mp5: 'mp5',
nade: 'nade',
p90: 'p90',
rifle: 'rifle',
scout: 'scout',
sg552: 'sg552',
suicide: '自杀',
train: 'train',
ump45: 'ump45',
'usp-silencer': 'usp-silencer',
vault: 'vault',
wtf: 'wtf',
yg1265: 'yg1265',
}
export const CS2WeaponMap: Record<string, string> = {
ak47: 'AK47',
ammobox: '弹药箱',
ammobox_threepack: '弹药箱三件套',
armor: '护甲',
armor_helmet: '护甲头盔',
assaultsuit_helmet_only: '突击套装头盔',
assaultsuit: '突击套装',
aug: 'AUG',
awp: 'AWP',
axe: '斧头',
bayonet: '刺刀',
bizon: 'PP野牛',
breachcharge: '遥控炸弹',
breachcharge_projectile: '遥控炸弹投射物',
bumpmine: '弹射地雷',
c4: 'C4炸弹',
clothing_hands: '服装手',
controldrone: '无人机',
customplayer: '自定义玩家',
cz75a: 'CZ75',
deagle: '沙漠之鹰',
decoy: '诱饵弹',
defuser: '拆弹器',
disconnect: '断开连接',
diversion: '分散注意',
dronegun: '无人机枪',
elite: '精英',
famas: '法玛斯',
firebomb: '燃烧弹',
fists: '拳头',
fiveseven: 'FN57',
flair0: '天赋',
flashbang: '闪光弹',
flashbang_assist: '闪光弹助攻',
frag_grenade: '破片手榴弹',
g3sg1: 'G3SG1',
galilar: '加利尔',
glock: '格洛克',
grenadepack: '手榴弹包',
grenadepack2: '手榴弹包2',
hammer: '锤子',
healthshot: '医疗针',
heavy_armor: '重装甲',
hegrenade: '高爆手榴弹',
helmet: '头盔',
hkp2000: 'P2000',
incgrenade: '燃烧手榴弹',
inferno: '地狱火',
kevlar: '凯夫拉',
knife: '刀',
knife_bowie: '鲍伊猎刀',
knife_butterfly: '蝴蝶刀',
knife_canis: '求生匕首',
knife_cord: '系绳匕首',
knife_css: '海豹短刀',
knife_falchion: '弯刀',
knife_flip: '折叠刀',
knife_gut: '穿肠刀',
knife_gypsy_jackknife: '折刀',
knife_karambit: '爪子刀',
knife_kukri: '廓尔喀弯刀',
knife_m9_bayonet: 'M9刺刀',
knife_push: '暗影双匕',
knife_skeleton: '骷髅匕首',
knife_stiletto: '短剑',
knife_survival_bowie: '求生鲍伊猎刀',
knife_t: 'T刀',
knife_tactical: '猎杀者匕首',
knife_twinblade: '双刃匕首',
knife_ursus: '熊刀',
knife_widowmaker: '锯齿爪刀',
knifegg: 'gg刀',
m4a1: 'M4A4',
m4a1_silencer: 'M4A1消音版',
m4a1_silencer_off: 'M4A1无消音器',
m249: 'M249',
mac10: 'MAC-10',
mag7: 'MAG-7',
melee: '幽灵之刃',
molotov: '燃烧弹',
movelinear: '拳击',
mp5sd: 'MP5',
mp7: 'MP7',
mp9: 'MP9',
negev: '内格夫',
nova: '新星',
p90: 'P90',
p250: 'P250',
p2000: 'P2000',
planted_c4_survival: '放置C4生存',
planted_c4: '放置C4',
prop_exploding_barrel: '爆炸桶',
radarjammer: '雷达干扰器',
revolver: '左轮手枪',
sawedoff: '匪喷',
scar20: 'SCAR',
sg556: 'SG553',
shield: '盾牌',
smokegrenade: '烟雾弹',
snowball: '雪球',
spanner: '扳手',
spray0: '喷漆',
ssg08: 'SSG08',
stomp_damage: '踩踏伤害',
tablet: '平板',
tagrenade: '标记手榴弹',
taser: '电击枪',
tec9: 'Tec-9',
tripwirefire_projectile: '绊网火投射物',
tripwirefire: '绊网火',
ump45: 'UMP45',
usp_silencer: 'USP消音版',
usp_silencer_off: 'USP无消音器',
xm1014: 'XM1014连喷',
zone_repulsor: '区域排斥装置',
}

View File

@@ -0,0 +1,530 @@
import { H2, H3 } from '@/components/Heading'
import { useAutoAnimate } from '@formkit/auto-animate/react'
import {
DeathMsg,
DefaultDeathMsg,
CS2PrefixIconValues,
CS2SuffixIconValues,
CS2Weapon,
CS2WeaponMap,
CS2WeaponValues,
CStrikeWeaponMap,
CStrikeWeaponValues,
CStrikePrefixIconValues,
CStrikeSuffixIconValues,
Camp,
CampValues,
} from './dmsg'
import useDMStore from './store'
import { Save, RefreshCcw, Loader, RotateCcw, Plus, Sparkles, Trash2, Square } from 'lucide-react'
import { Key, useState } from 'react'
import {
Button,
Chip,
Input,
NumberField,
Switch,
Tabs,
cn,
Autocomplete,
useFilter,
ListBox,
SearchField,
EmptyState,
} from '@heroui/react'
import { IoSwapHorizontal } from 'react-icons/io5'
export default function Page() {
return (
<div className="flex flex-col items-center w-full max-w-screen-lg gap-8 px-8 py-6 mx-auto">
<H2></H2>
<GameTypeTabs />
{/* <p className="text-zinc-900 dark:text-zinc-100">施工中... 功能尚未完善</p> */}
<div className="flex flex-row flex-wrap gap-6 pt-4">
<SettingPanel />
<PreviewPanel />
<DeathNoticePanel />
</div>
<DeathNoticeCanvas />
</div>
)
}
function GameTypeTabs() {
const { gameType, setGameType } = useDMStore()
return (
<Tabs selectedKey={gameType} onSelectionChange={(key: Key) => setGameType(key.toString())} aria-label="Game Type Variants" variant="secondary">
<Tabs.ListContainer>
<Tabs.List>
<Tabs.Tab id="cs2">
CS2 & GO
<Tabs.Indicator />
</Tabs.Tab>
{/* <Tab key="csgo" title="CSGO" /> */}
<Tabs.Tab id="cstrike">
<div className="flex items-center space-x-2 px-2">
<span>CStrike</span>
<Chip size="sm">
<Chip.Label></Chip.Label>
</Chip>
</div>
<Tabs.Indicator />
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
)
}
function SettingPanel() {
const { w, h, hidpi, prefix, mockLayout, setW, setH, setHidpi, setPrefix, setMockLayout, reset } = useDMStore()
return (
<section className="flex gap-6 w-full md:w-auto p-6 border border-zinc-300 bg-white/[.01] dark:border-zinc-600 rounded-md text-zinc-900 dark:text-zinc-100">
<div className="flex flex-col flex-wrap grow gap-3">
<div className="flex gap-4">
<H3></H3>
<Button size="sm" variant="secondary" onPress={() => reset()} className="font-semibold gap-1">
<RotateCcw size={14} />
</Button>
</div>
<div className="flex flex-row items-center gap-2">
<a className="min-w-24 text-sm"></a>
<NumberField value={w} onChange={e => setW(e)} aria-label="宽度">
<NumberField.Group>
<NumberField.Input />
</NumberField.Group>
</NumberField>
</div>
<div className="flex flex-row items-center gap-2">
<a className="min-w-24 text-sm"></a>
<NumberField value={h} onChange={h => setH(h)} aria-label="高度">
<NumberField.Group>
<NumberField.Input />
</NumberField.Group>
</NumberField>
</div>
<div className="flex flex-row items-center gap-2">
<a className="min-w-24 text-sm"></a>
<NumberField value={hidpi} onChange={hidpi => setHidpi(hidpi)} aria-label="渲染倍率">
<NumberField.Group>
<NumberField.Input />
</NumberField.Group>
</NumberField>
</div>
<div className="flex flex-row items-center gap-2">
<a className="min-w-24 text-sm"></a>
<Input value={prefix} onChange={e => setPrefix(e.target.value)} aria-label="文件名前缀" />
</div>
<div className="flex flex-row items-center gap-2">
<a className="min-w-24 text-sm"></a>
<Switch size="sm" isSelected={mockLayout} onChange={mockLayout => setMockLayout(mockLayout)} aria-label="模拟游戏布局">
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</div>
</div>
</section>
)
}
function PreviewPanel() {
return (
<section className="flex flex-col grow gap-6 p-6 border border-zinc-300 dark:border-zinc-600 rounded-md dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100">
<H3></H3>
<DeathNoticeRender />
</section>
)
}
function DeathNoticePanel() {
const { dNotices, setDNotice, cstrikeDNotices, saveDNotices, loadDNotices, resetDNotices, addDNotice, generateDNotice, gameType } = useDMStore()
const [parent /* , enableAnimations */] = useAutoAnimate(/* optional config */)
return (
<section className="flex flex-col w-full gap-6 p-6 border border-zinc-300 bg-white/[.01] dark:border-zinc-600 rounded-md text-zinc-900 dark:text-zinc-100">
<div className="flex flex-wrap items-start gap-2">
<H3></H3>
<Button size="sm" variant="secondary" onPress={() => saveDNotices()} className="font-semibold gap-1">
<Save size={14} />
</Button>
<Button size="sm" variant="secondary" onPress={() => loadDNotices('')} className="font-semibold gap-1">
<Loader size={14} />
</Button>
<Button size="sm" variant="secondary" onPress={() => resetDNotices()} className="font-semibold gap-1">
<RefreshCcw size={14} />
</Button>
<Button size="sm" variant="secondary" onPress={() => addDNotice(DefaultDeathMsg)} className="ml-auto font-semibold gap-1">
<Plus size={14} />
</Button>
<Button size="sm" variant="secondary" onPress={() => generateDNotice()} className="font-semibold gap-1">
<Sparkles size={14} />
</Button>
</div>
<ul className="flex flex-col gap-6" ref={parent}>
{(gameType === 'cs2' ? dNotices : cstrikeDNotices).map((dNotice: DeathMsg, i: number) => (
<DeathNoticeItem key={i} index={i} deathNotice={dNotice} setDNotice={setDNotice} />
))}
</ul>
</section>
)
}
type DeathNoticeItemProps = {
index: number
deathNotice: DeathMsg
setDNotice: (index: number, dNotice: DeathMsg) => void
}
function DeathNoticeItem({ index, deathNotice, setDNotice }: DeathNoticeItemProps) {
const { removeDNotice, gameType } = useDMStore()
const PrefixIconValues = gameType == 'cs2' ? CS2PrefixIconValues : CStrikePrefixIconValues
const SuffixIconValues = gameType == 'cs2' ? CS2SuffixIconValues : CStrikeSuffixIconValues
return (
<ul className="grid items-center grid-cols-1 gap-4 p-4 border dark:border-zinc-800 rounded-md md:grid-cols-6 dark:bg-zinc-900/50">
<li className="col-span-2 flex flex-col gap-1.5 grow">
<p></p>
<div className="flex gap-2">
<Input
value={deathNotice.attacker}
onChange={e => setDNotice(index, { ...deathNotice, attacker: e.target.value })}
aria-label="击杀者"
className="grow"
/>
<CampButton value={deathNotice.attackerCamp} onChange={camp => setDNotice(index, { ...deathNotice, attackerCamp: camp })} />
</div>
</li>
<li className="col-span-2 flex flex-col gap-1.5 grow">
<p className="w-full flex items-center justify-between"></p>
<div className="flex gap-2">
<Input
value={deathNotice.victim}
onChange={e => setDNotice(index, { ...deathNotice, victim: e.target.value })}
aria-label="受害者"
className="grow"
/>
<CampButton value={deathNotice.victimCamp} onChange={camp => setDNotice(index, { ...deathNotice, victimCamp: camp })} />
</div>
</li>
<li className="col-span-2 flex flex-col gap-1.5 grow">
<p></p>
<div className="flex gap-3">
<SelectSearch
value={deathNotice.weapon}
values={gameType == 'cs2' ? CS2WeaponValues : CStrikeWeaponValues}
valueMap={gameType == 'cs2' ? CS2WeaponMap : CStrikeWeaponMap}
onChange={(value: string) => setDNotice(index, { ...deathNotice, weapon: value as CS2Weapon })}
/>
</div>
</li>
<li className="col-span-4 flex flex-col gap-1.5">
<p></p>
<ul className="flex gap-2 rounded-lg">
{PrefixIconValues.map(item => {
const isSelected = deathNotice.prefixIcons.includes(item)
return (
<li key={item}>
<button
type="button"
onClick={() =>
isSelected
? setDNotice(index, { ...deathNotice, prefixIcons: deathNotice.prefixIcons.filter(i => i !== item) })
: setDNotice(index, { ...deathNotice, prefixIcons: [...deathNotice.prefixIcons, item] })
}
aria-label={`切换前缀图标 ${item}`}
aria-pressed={isSelected}
className={cn(
'w-8 h-8 p-1.5 rounded-lg cursor-pointer transition active:scale-95 border',
isSelected
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700'
: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-800/50 hover:bg-blue-100 dark:hover:bg-blue-900/40'
)}
>
<img
src={`/cs2/deathnotice/${item}.svg`}
alt={`前缀图标 ${item}`}
className="w-full h-full transition-all"
style={
isSelected
? {
filter: 'brightness(0) saturate(100%) invert(45%) sepia(60%) saturate(800%) hue-rotate(180deg) brightness(95%) grayscale(20%)',
}
: {
filter:
'brightness(0) saturate(100%) invert(45%) sepia(60%) saturate(800%) hue-rotate(180deg) brightness(95%) grayscale(20%) opacity(0.2)',
}
}
/>
</button>
</li>
)
})}
{SuffixIconValues.map(item => {
const isSelected = deathNotice.suffixIcons.includes(item)
return (
<li key={item}>
<button
type="button"
onClick={() =>
isSelected
? setDNotice(index, { ...deathNotice, suffixIcons: deathNotice.suffixIcons.filter(i => i !== item) })
: setDNotice(index, { ...deathNotice, suffixIcons: [...deathNotice.suffixIcons, item] })
}
aria-label={`切换后缀图标 ${item}`}
aria-pressed={isSelected}
className={cn(
'w-8 h-8 p-1.5 rounded-lg cursor-pointer transition active:scale-95 border',
isSelected
? 'bg-orange-100 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700'
: 'bg-orange-50 dark:bg-orange-950/30 border-orange-100 dark:border-orange-800/50 hover:bg-orange-100 dark:hover:bg-orange-900/40'
)}
>
<img
src={`/cs2/deathnotice/${item}.svg`}
alt={`后缀图标 ${item}`}
className="w-full h-full transition-all"
style={
isSelected
? {
filter:
'brightness(0) saturate(100%) invert(55%) sepia(50%) saturate(700%) hue-rotate(350deg) brightness(90%) contrast(98%) grayscale(25%)',
}
: {
filter:
'brightness(0) saturate(100%) invert(55%) sepia(50%) saturate(700%) hue-rotate(350deg) brightness(90%) contrast(98%) grayscale(25%) opacity(0.25)',
}
}
/>
</button>
</li>
)
})}
</ul>
</li>
<li className="col-span-2 mt-auto ml-auto space-x-2 flex">
<Button variant="secondary" onPress={() => removeDNotice(index)} size="sm" className="font-semibold hover:bg-red-300 hover:text-white gap-1">
<Trash2 size={14} />
</Button>
<Button
variant="secondary"
size="sm"
className=" gap-1 font-semibold"
onPress={() => setDNotice(index, { ...deathNotice, attacker: deathNotice.victim, victim: deathNotice.attacker })}
>
<IoSwapHorizontal size={14} />
</Button>
{gameType == 'cs2' && (
<Button
variant="secondary"
size="sm"
onPress={() => setDNotice(index, { ...deathNotice, redBorder: !deathNotice.redBorder })}
className={cn('font-semibold gap-1', deathNotice.redBorder && 'border-red-500 border text-red-400 bg-red-100 dark:bg-red-900/30')}
>
<Square size={14} />
</Button>
)}
</li>
</ul>
)
}
type CampButtonProps = {
value: Camp
onChange: (value: Camp) => void
label?: string
}
function CampButton({ value, onChange, label }: CampButtonProps) {
const { gameType } = useDMStore()
const getNextCamp = (current: Camp): Camp => {
return current === 'CT' ? 'T' : 'CT'
}
const getCampColor = (camp: Camp) => {
if (camp === '') return 'border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400'
if (gameType === 'cs2') {
return camp === 'CT' ? 'border-[#6F9CE6] text-[#6F9CE6]' : 'border-[#EABE54] text-[#EABE54]'
} else {
return camp === 'CT' ? 'border-[#a8d5fe] text-[#a8d5fe]' : 'border-[#f84444] text-[#f84444]'
}
}
const displayValue = value || '—'
return (
<Button
size="sm"
variant="outline"
onPress={() => onChange(getNextCamp(value))}
className={cn('font-semibold min-w-12 border-[1.5px]', getCampColor(value))}
aria-label={`切换阵营,当前:${value === 'CT' ? '反恐精英' : value === 'T' ? '恐怖分子' : '无'}`}
>
{displayValue}
</Button>
)
}
type SelectSearchProps = {
value: string
onChange: (value: string) => void
values: string[]
valueMap: Record<string, string>
}
function SelectSearch({ value, onChange, values, valueMap }: SelectSearchProps) {
const { contains } = useFilter({ sensitivity: 'base' })
const { gameType } = useDMStore()
// Prepare items for Autocomplete
const items = values.map(v => ({ id: v, name: valueMap[v] || v }))
return (
<Autocomplete
className="w-full"
placeholder="选择武器"
selectedKey={value}
onSelectionChange={key => onChange((key as string) || '')}
aria-label="选择武器"
>
<Autocomplete.Trigger>
<Autocomplete.Value>
{({ selectedItem, isPlaceholder }) => {
const v = value
if (isPlaceholder || !v) return <span className="text-gray-500"></span>
return (
<div className="flex items-center gap-2">
<img
src={gameType === 'cs2' ? `/cs2/weapon/${v}.svg` : `/cstrike/weapon/${v}.png`}
alt={v ? `武器 ${valueMap[v] || v}` : '未选择武器'}
className={cn(gameType === 'cs2' ? 'h-6 p-0.5 invert-[0.7] dark:invert-[0.2]' : 'h-6 p-0', 'rounded')}
/>
<span>{valueMap[v] || v}</span>
</div>
)
}}
</Autocomplete.Value>
<Autocomplete.Indicator />
</Autocomplete.Trigger>
<Autocomplete.Popover>
<Autocomplete.Filter filter={contains}>
<SearchField autoFocus aria-label="Search weapons">
<SearchField.Group>
<SearchField.Input placeholder="搜索武器装备..." />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<ListBox renderEmptyState={() => <EmptyState></EmptyState>}>
{items.map(item => (
<ListBox.Item key={item.id} id={item.id} textValue={item.name}>
<div className="flex items-center gap-2">
<img
src={gameType === 'cs2' ? `/cs2/weapon/${item.id}.svg` : `/cstrike/weapon/${item.id}.png`}
alt="weapon"
className={cn(gameType === 'cs2' ? 'h-6 max-w-10 p-0.5 invert-[0.7] dark:invert-[0.2]' : 'h-6 w-16 p-0', 'bg-contain rounded ')}
/>
{item.name}
</div>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
)
}
type DeathNoticeRenderProps = {
hide?: boolean
}
function DeathNoticeRender({ hide = false }: DeathNoticeRenderProps) {
const { dNotices, cstrikeDNotices, gameType } = useDMStore()
const [parent /* , enableAnimations */] = useAutoAnimate(/* optional config */)
return (
<ul className="flex flex-col items-end gap-1 pr-2.5 transition-transform" ref={parent}>
{(gameType === 'cs2' ? dNotices : cstrikeDNotices).map((dNotice: DeathMsg, index: number) =>
gameType === 'cs2' ? (
<CS2DeathNoticeItem key={index} dNotice={dNotice} index={index} hide={hide} />
) : (
<CStrikeDeathNoticeItem key={index} dNotice={dNotice} index={index} hide={hide} />
)
)}
</ul>
)
}
function CS2DeathNoticeItem({ dNotice, index, hide }: { dNotice: DeathMsg; index: number; hide: boolean }) {
return (
<li
key={index}
className={cn(
'flex flex-row items-center justify-center gap-1 px-2 py-2 h-8 text-sm leading-6 backdrop-blur font-bold font-[Stratum2] rounded text-white bg-black/65',
dNotice.redBorder && 'border-2 border-[#e10000]',
hide && dNotice.hide && 'invisible'
)}
>
<p className={cn(dNotice.attackerCamp === 'CT' ? 'text-[#6F9CE6]' : 'text-[#EABE54]')}>{dNotice.attacker}</p>
{dNotice.prefixIcons &&
dNotice.prefixIcons.map((prefixIcon: string, i: number) => <img src={`/cs2/deathnotice/${prefixIcon}.svg`} alt="prefix" className="h-6" key={i} />)}
<img src={`/cs2/weapon/${dNotice.weapon}.svg`} alt="weapon" className="h-6" />
{dNotice.suffixIcons &&
dNotice.suffixIcons.map((suffixIcon: string, i: number) => <img src={`/cs2/deathnotice/${suffixIcon}.svg`} alt="suffix" className="h-6" key={i} />)}
<p className={cn(dNotice.victimCamp === 'CT' ? 'text-[#6F9CE6]' : 'text-[#EABE54]')}>{dNotice.victim}</p>
</li>
)
}
function CStrikeDeathNoticeItem({ dNotice, index, hide }: { dNotice: DeathMsg; index: number; hide: boolean }) {
// TODO 图片后处理
return (
<li
key={index}
className={cn(
'flex flex-row items-center justify-center gap-1 px-2 py-1 h-8 text-sm leading-6 backdrop-blur font-light font-[Verdana] rounded text-white',
hide && dNotice.hide && 'invisible'
)}
>
{dNotice.prefixIcons &&
dNotice.prefixIcons.map((prefixIcon: string, i: number) => (
<img src={`/cstrike/deathnotice/${prefixIcon}.png`} alt="prefix" className="h-6 sepia" key={i} />
))}
<p className={cn('drop-shadow-[1px_0.5px_0_rgba(0,0,0,1)]', dNotice.attackerCamp === 'CT' ? 'text-[#a8d5fe]' : 'text-[#f84444]')}>{dNotice.attacker}</p>
<img src={`/cstrike/weapon/${dNotice.weapon}.png`} alt="weapon" className="h-6 py-0.5 sepia" />
{dNotice.suffixIcons &&
dNotice.suffixIcons.map((suffixIcon: string, i: number) => (
<img src={`/cstrike/deathnotice/${suffixIcon}.png`} alt="suffix" className="h-6 py-0.5 sepia" key={i} />
))}
<p className={cn('drop-shadow-[1px_0.5px_0_rgba(0,0,0,1)]', dNotice.victimCamp === 'CT' ? 'text-[#a8d5fe]' : 'text-[#f84444]')}>{dNotice.victim}</p>
</li>
)
}
function DeathNoticeCanvas() {
const { w, h } = useDMStore()
const dw = `${w}px`
const dh = `${h}px`
return (
<section className={`pt-[72px] pr-[10px] pointer-events-none absolute bottom-full render-fix`} style={{ width: dw, height: dh }} id="deathnotice">
<DeathNoticeRender hide={true} />
</section>
)
}

View File

@@ -0,0 +1,179 @@
import { create } from 'zustand'
import { CStrikeDefaultDeathMsgs, DeathMsg, DefaultDeathMsgs } from './dmsg'
import { persist, createJSONStorage } from 'zustand/middleware'
import { Canvas2Image } from './canvas'
import { toast } from '@heroui/react'
interface DMState {
w: number
h: number
hidpi: number
prefix: string
gameType: string
dNotices: DeathMsg[]
cstrikeDNotices: DeathMsg[]
mockLayout: boolean
setW: (w: number) => void
setH: (h: number) => void
setHidpi: (hidpi: number) => void
setPrefix: (prefix: string) => void
setGameType: (gameType: string) => void
reset: () => void
setDNotices: (dNotices: DeathMsg[]) => void
setDNotice: (index: number, dNotice: DeathMsg) => void
addDNotice: (dNotice: DeathMsg) => void
removeDNotice: (index: number) => void
resetDNotices: () => void
saveDNotices: () => void
loadDNotices: (json: string) => void
generateDNotice: () => void
setMockLayout: (mockLayout: boolean) => void
}
// 创建 store
const useDMStore = create<DMState>()(
persist(
(set, get) => ({
w: 1920,
h: 1080,
hidpi: 2,
prefix: '击杀生成',
gameType: 'cs2',
dNotices: DefaultDeathMsgs,
cstrikeDNotices: CStrikeDefaultDeathMsgs,
mockLayout: true,
setW: (w: number) => set({ w }),
setH: (h: number) => set({ h }),
setHidpi: (hidpi: number) => set({ hidpi }),
setPrefix: (prefix: string) => set({ prefix }),
setGameType: (gameType: string) => set({ gameType }),
reset: () => set({ w: 1920, h: 1080, hidpi: 2, prefix: '击杀生成', dNotices: DefaultDeathMsgs, mockLayout: true }),
setDNotices: (dNotices: DeathMsg[]) => {
get().gameType === 'cs2' ? set({ dNotices: dNotices }) : set({ cstrikeDNotices: dNotices })
},
setDNotice: (index: number, dNotice: DeathMsg) => {
get().gameType === 'cs2'
? set((state: DMState) => ({ dNotices: [...state.dNotices.slice(0, index), dNotice, ...state.dNotices.slice(index + 1)] }))
: set((state: DMState) => ({ cstrikeDNotices: [...state.cstrikeDNotices.slice(0, index), dNotice, ...state.cstrikeDNotices.slice(index + 1)] }))
},
addDNotice: (dNotice: DeathMsg) => {
get().gameType === 'cs2'
? set((state: DMState) => ({ dNotices: [...state.dNotices, dNotice] }))
: set((state: DMState) => ({ cstrikeDNotices: [...state.cstrikeDNotices, dNotice] }))
},
removeDNotice: (index: number) => {
get().gameType === 'cs2'
? set((state: DMState) => ({ dNotices: state.dNotices.filter((_, i: number) => i !== index) }))
: set((state: DMState) => ({ cstrikeDNotices: state.cstrikeDNotices.filter((_, i: number) => i !== index) }))
},
resetDNotices: () => {
get().gameType === 'cs2' ? set({ dNotices: DefaultDeathMsgs }) : set({ cstrikeDNotices: CStrikeDefaultDeathMsgs })
},
saveDNotices: () => {
const jsonData = JSON.stringify({ dNotices: get().gameType === 'cs2' ? get().dNotices : get().cstrikeDNotices })
// 弹出下载
const blob = new Blob([jsonData], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = get().prefix + '_dNotices.json'
a.style.display = 'none'
document.body.appendChild(a)
a.click()
// 等待一小段时间确保下载已触发,然后再移除元素
setTimeout(() => {
if (a.parentNode) {
document.body.removeChild(a)
}
URL.revokeObjectURL(url)
}, 100)
},
loadDNotices: (json: string) => {
// 上传文件并读取json
// 弹出文件上传对话框
// set({ dNotices: JSON.parse(json).dNotices })
},
generateDNotice: async () => {
const { w, h, hidpi, prefix, dNotices, cstrikeDNotices, gameType } = get()
const currentDNotices = gameType === 'cs2' ? dNotices : cstrikeDNotices
// html2canvas获取元素、生成图片、并跳转下载
const e = document.getElementById('deathnotice')
if (e === null) return
// 生产环境适配
const style = document.createElement('style')
if (process.env.NODE_ENV === 'production') {
document.head.appendChild(style)
style.sheet?.insertRule('body > div:last-child img { display: inline-block; }')
}
const dpi = hidpi ? hidpi : 1 // 缩放倍率,不随浏览器缩放改变
const mockLayoutGeneration = async () => {
// 生成过程 模拟游戏布局
for (let i = 0; i < currentDNotices.length; i++) {
// 隐藏其他击杀条
for (let j = 0; j < currentDNotices.length; j++) {
const dNotice = currentDNotices[j]
get().setDNotice(j, { ...dNotice, hide: j !== i })
}
await sleep(50)
await Canvas2Image(e, prefix + '-' + (i + 1), dpi)
}
for (let j = 0; j < currentDNotices.length; j++) {
const dNotice = currentDNotices[j]
get().setDNotice(j, { ...dNotice, hide: false })
}
}
const simpleGeneration = async () => {
// 分别渲染 DeathNoticeRender 的所有 li组件并下载png
for (let i = 0; i < currentDNotices.length; i++) {
get().setDNotice(i, { ...currentDNotices[i], hide: false })
}
const dnList = e.getElementsByTagName('li')
let i = 1
for (const dn of dnList) {
await sleep(10)
await Canvas2Image(dn, prefix + '-' + i, dpi)
i++
}
}
try {
if (get().mockLayout) {
await mockLayoutGeneration()
} else {
await simpleGeneration()
}
} finally {
// 确保 style 元素被移除
if (style.parentNode) {
style.remove()
}
}
toast('生成成功', {
description: '请查看下载文件夹',
})
},
setMockLayout: (mockLayout: boolean) => set({ mockLayout }),
}),
{
name: 'deathmsg',
storage: createJSONStorage(() => sessionStorage),
}
)
)
function sleep(time: number) {
return new Promise(function (resolve) {
setTimeout(resolve, time)
})
}
export default useDMStore

View File

@@ -0,0 +1,7 @@
export default function HudLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return <main className="flex flex-col items-center justify-start min-h-screen gap-20 py-24 mx-auto">{children}</main>
}

View File

@@ -6,6 +6,7 @@ import { CreateResourceModal } from '../components/CreateResourceModal'
import { EditResourceModal } from '../components/EditResourceModal'
import { useSession } from '../lib/auth-client'
import { Card } from '../components/Card'
import { CardSkeleton } from '../components/CardSkeleton'
import { ResourceCardList } from '../components/ResourceCardList'
export default function HomePage() {
@@ -25,7 +26,11 @@ export default function HomePage() {
<section className="mb-20">
<h2 className="mb-12 text-center text-3xl font-bold"></h2>
{isPortalsLoading ? (
<div className="py-20 text-center">...</div>
<ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</ul>
) : (
<ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
{portals?.map((portal) => (
@@ -49,7 +54,7 @@ export default function HomePage() {
{session && (
<Button
onPress={() => setIsModalOpen(true)}
variant="flat"
variant="ghost"
className="absolute right-0"
>

View File

@@ -11,7 +11,7 @@ import {
FieldError,
} from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
import { signIn } from '../../lib/auth-client'
import { signIn } from '@/lib/auth-client'
export default function LoginPage() {
const [email, setEmail] = useState('')

View File

@@ -11,11 +11,28 @@ export default function PostDetailPage() {
return (
<div className="pb-20">
<main className="container mx-auto px-4 py-8">
<Card className="mx-auto max-w-4xl space-y-4 p-6">
<Skeleton className="h-8 w-3/4 rounded-lg" />
<Skeleton className="h-4 w-1/4 rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
</Card>
<div className="mx-auto max-w-4xl">
<div className="mb-4">
<Skeleton className="h-6 w-32 rounded-lg dark:bg-zinc-700" />
</div>
<Card className="mb-8 p-6">
<Card.Header className="flex flex-col items-start gap-4">
<Skeleton className="h-10 w-3/4 rounded-lg dark:bg-zinc-700" />
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-full dark:bg-zinc-700" />
<Skeleton className="h-4 w-48 rounded-lg dark:bg-zinc-700" />
</div>
</Card.Header>
<Separator className="my-4" />
<Card.Content className="space-y-3">
<Skeleton className="h-4 w-full rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-4 w-full rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-4 w-full rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-4 w-2/3 rounded-lg dark:bg-zinc-700" />
<Skeleton className="mt-4 h-40 w-full rounded-lg dark:bg-zinc-700" />
</Card.Content>
</Card>
</div>
</main>
</div>
)

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { usePosts, Post } from '../../hooks/useApi'
import { Link as RouterLink } from 'react-router-dom'
import { PostSkeleton } from '../../components/PostSkeleton'
import {
Card,
Button,
@@ -12,7 +13,7 @@ import {
Label,
FieldError,
} from '@heroui/react'
import { useSession } from '../../lib/auth-client'
import { useSession } from '@/lib/auth-client'
import { api } from '../../api/client'
export default function PostsPage() {
@@ -51,7 +52,11 @@ export default function PostsPage() {
<div className="flex-1">
<h2 className="mb-6 text-2xl font-bold"></h2>
{isLoading ? (
<div>...</div>
<div className="flex flex-col gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<PostSkeleton key={i} />
))}
</div>
) : (
<div className="flex flex-col gap-4">
{posts?.map((post: Post) => (

View File

@@ -1,4 +1,4 @@
import { useSession, signOut, user } from '../../lib/auth-client'
import { useSession, signOut, user } from '@/lib/auth-client'
import {
Avatar,
Button,
@@ -12,6 +12,7 @@ import {
} from '@heroui/react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ProfileSkeleton } from '../../components/ProfileSkeleton'
export default function ProfilePage() {
const { data: session, isPending } = useSession()
@@ -29,11 +30,7 @@ export default function ProfilePage() {
}, [session, isPending, navigate])
if (isPending || !session) {
return (
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center">
...
</div>
)
return <ProfileSkeleton />
}
const handleUpdateProfile = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -85,7 +82,7 @@ export default function ProfilePage() {
<div className="space-y-4">
<TextField isReadOnly>
<Label></Label>
<Input value={session.user.email} variant="flat" />
<Input value={session.user.email} variant="secondary" />
</TextField>
<TextField
@@ -95,13 +92,13 @@ export default function ProfilePage() {
isReadOnly={!isEditing}
>
<Label></Label>
<Input variant={isEditing ? 'secondary' : 'flat'} />
<Input variant={isEditing ? 'primary' : 'secondary'} />
<FieldError />
</TextField>
<TextField isReadOnly>
<Label>ID</Label>
<Input value={session.user.id} variant="flat" />
<Input value={session.user.id} variant="secondary" />
</TextField>
</div>

View File

@@ -11,7 +11,7 @@ import {
FieldError,
} from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
import { signUp } from '../../lib/auth-client'
import { signUp } from '@/lib/auth-client'
export default function RegisterPage() {
const [email, setEmail] = useState('')

View File

@@ -0,0 +1,42 @@
import { Skeleton } from "@heroui/react";
export function AuthSkeleton() {
return (
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center p-4">
{/* Card Skeleton */}
<div className="w-full max-w-md flex flex-col gap-6 rounded-xl bg-zinc-100 dark:bg-zinc-800 p-6 shadow-sm border border-zinc-200 dark:border-zinc-800">
{/* Header */}
<div className="flex justify-center mb-2">
<Skeleton className="h-8 w-48 rounded-lg dark:bg-zinc-700" />
</div>
{/* Form Fields */}
<div className="flex flex-col gap-6">
{/* Email */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-12 rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
</div>
{/* Password */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-12 rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
</div>
</div>
{/* Buttons */}
<div className="mt-4 flex flex-col gap-3">
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
</div>
{/* Footer Link */}
<div className="flex justify-center mt-2">
<Skeleton className="h-4 w-32 rounded-lg dark:bg-zinc-700" />
</div>
</div>
</div>
);
}

View File

@@ -123,7 +123,7 @@ export function Card({
</div>
</div>
<p className="text-zinc-500 flex-grow dark:text-[#9e9e9e]">
<p className="text-zinc-500 grow dark:text-[#9e9e9e]">
{description}
</p>

View File

@@ -0,0 +1,31 @@
import { Skeleton } from "@heroui/react";
export function CardSkeleton() {
return (
<li className="flex h-full flex-col gap-3.5 rounded-xl bg-zinc-100 bg-opacity-90 p-5 dark:bg-zinc-800">
<div className="flex items-center gap-1">
{/* Icon Skeleton */}
<Skeleton className="mr-2 h-12 w-12 shrink-0 rounded-xl dark:bg-zinc-700" />
<div className="flex flex-col gap-2">
{/* Title Skeleton */}
<Skeleton className="h-5 w-32 rounded-lg dark:bg-zinc-700" />
{/* Version Skeleton */}
<Skeleton className="h-4 w-16 rounded-lg dark:bg-zinc-700" />
</div>
</div>
{/* Description Skeleton */}
<div className="flex flex-col gap-2 grow">
<Skeleton className="h-4 w-full rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-4 w-4/5 rounded-lg dark:bg-zinc-700" />
</div>
{/* Buttons Skeleton */}
<div className="flex gap-2 mt-auto">
<Skeleton className="h-8 w-24 rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-8 w-24 rounded-lg dark:bg-zinc-700" />
</div>
</li>
);
}

View File

@@ -0,0 +1,9 @@
import { cn } from "@heroui/react";
export function H2({ children, className }: { children: React.ReactNode; className?: string }) {
return <h2 className={cn("text-2xl font-bold", className)}>{children}</h2>;
}
export function H3({ children, className }: { children: React.ReactNode; className?: string }) {
return <h3 className={cn("text-xl font-bold", className)}>{children}</h3>;
}

View File

@@ -0,0 +1,20 @@
import { useLocation } from "react-router-dom";
import { AuthSkeleton } from "./AuthSkeleton";
import { PageSkeleton } from "./PageSkeleton";
export function LoadingFallback() {
const location = useLocation();
const path = location.pathname;
if (path === "/login" || path === "/register") {
return <AuthSkeleton />;
}
// For Kill Generation (DeathMsg) or other HUD tools
if (path.startsWith("/hud") || path.startsWith("/demo")) {
return <PageSkeleton />;
}
// Default fallback (Home, About, etc.)
return <PageSkeleton />;
}

View File

@@ -0,0 +1,27 @@
import { Skeleton } from "@heroui/react";
export function PageSkeleton() {
return (
<div className="flex flex-col items-center w-full max-w-screen-lg gap-8 px-8 py-28 mx-auto">
{/* Header */}
<Skeleton className="h-10 w-48 rounded-lg dark:bg-zinc-700" />
{/* Tabs / Controls */}
<div className="flex gap-4 w-full justify-center">
<Skeleton className="h-10 w-32 rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-10 w-32 rounded-lg dark:bg-zinc-700" />
</div>
{/* Main Content Area */}
<div className="flex flex-row flex-wrap gap-6 w-full">
{/* Panel 1 */}
<Skeleton className="flex-grow h-64 rounded-xl dark:bg-zinc-700" />
{/* Panel 2 */}
<Skeleton className="flex-grow h-64 rounded-xl dark:bg-zinc-700" />
</div>
{/* Bottom Area */}
<Skeleton className="w-full h-32 rounded-xl dark:bg-zinc-700" />
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Card, Skeleton } from "@heroui/react";
export function PostSkeleton() {
return (
<Card className="p-4" radius="lg">
<div className="flex flex-col gap-3">
{/* Header area */}
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-1/3 rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-3 w-1/4 rounded-lg dark:bg-zinc-700" />
</div>
{/* Content area */}
<div className="space-y-2">
<Skeleton className="h-3 w-full rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-3 w-full rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-3 w-2/3 rounded-lg dark:bg-zinc-700" />
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,36 @@
import { Card, Skeleton } from "@heroui/react";
export function ProfileSkeleton() {
return (
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<Card.Header>
<Skeleton className="h-8 w-32 rounded-lg dark:bg-zinc-700" />
</Card.Header>
<Card.Content className="flex flex-col gap-8 md:flex-row">
<div className="flex flex-col items-center gap-4">
{/* Avatar */}
<Skeleton className="h-32 w-32 rounded-full dark:bg-zinc-700" />
<Skeleton className="h-10 w-24 rounded-lg dark:bg-zinc-700" />
</div>
<div className="flex-1 space-y-6">
<div className="space-y-4">
{/* Inputs */}
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-12 rounded-lg dark:bg-zinc-700" />
<Skeleton className="h-10 w-full rounded-lg dark:bg-zinc-700" />
</div>
))}
</div>
<div className="mt-6 flex justify-end gap-3">
<Skeleton className="h-10 w-24 rounded-lg dark:bg-zinc-700" />
</div>
</div>
</Card.Content>
</Card>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import useSWR from 'swr'
import { Card, CardProps } from './Card'
import { CardSkeleton } from './CardSkeleton'
import { useResources, Resource } from '../hooks/useApi'
import { fetchResourceReleaseData, LatestRelease } from '../lib/github'
import { useSession } from '../lib/auth-client'
@@ -38,7 +39,13 @@ export function ResourceCardList({ onEdit }: ResourceCardListProps) {
}
if (isLoading) {
return <div className="py-20 text-center">...</div>
return (
<ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</ul>
)
}
if (!resources?.length) {
@@ -76,7 +83,7 @@ export function ResourceCardList({ onEdit }: ResourceCardListProps) {
session && onEdit ? (
<Button
isIconOnly
variant="flat"
variant="ghost"
size="sm"
onPress={() => onEdit(item)}
className="absolute right-2 top-2 z-10 opacity-0 transition-opacity group-hover:opacity-100"

View File

@@ -37,7 +37,7 @@ export function SiteNavbar() {
const menuItems = [
{ name: '主页', href: '/' },
{ name: '击杀生成', href: '/demo' },
{ name: '击杀生成', href: '/hud/deathmsg' },
{ name: '关于', href: '/about' },
]
@@ -47,10 +47,8 @@ export function SiteNavbar() {
<>
<nav
className={twMerge(
'sticky top-0 z-30 w-full bg-white/75 dark:bg-black/70 backdrop-blur-xl transition-all duration-300',
shouldShow
? 'opacity-100 translate-y-0 border-b border-zinc-100 dark:border-zinc-800 h-auto'
: 'opacity-0 h-0 -translate-y-1/4 pointer-events-none'
'sticky top-0 z-30 w-full bg-white/75 dark:bg-black/70 backdrop-blur-xl opacity-0 h-0 transition -translate-y-1/4 hover:opacity-100 duration-300',
shouldShow && 'opacity-100 translate-y-0 border-b border-zinc-100 dark:border-zinc-800 h-auto'
)}
>
<div className="flex items-center justify-between w-full max-w-screen-lg gap-4 px-8 py-4 mx-auto">

Binary file not shown.

BIN
web/src/fonts/GeistVF.woff Normal file

Binary file not shown.

BIN
web/src/fonts/Stratum2.ttf Normal file

Binary file not shown.

BIN
web/src/fonts/Verdana.ttf Normal file

Binary file not shown.

BIN
web/src/fonts/Verdanab.ttf Normal file

Binary file not shown.

View File

@@ -5,6 +5,8 @@ import { Providers } from './providers'
import RootLayout from './layout'
import routes from '~react-pages'
import { LoadingFallback } from './components/LoadingFallback'
export function App() {
useEffect(() => {
const storedTheme = localStorage.getItem('theme')
@@ -23,7 +25,7 @@ export function App() {
document.documentElement.setAttribute('data-theme', theme)
}, [])
return <Suspense fallback={<p>Loading...</p>}>{useRoutes(routes)}</Suspense>
return <Suspense fallback={<LoadingFallback />}>{useRoutes(routes)}</Suspense>
}
const app = createRoot(document.getElementById('root')!)