feat: update character list UI with vibe coded design
- Update `CharacterService` to include generation counts (`_count.generations`) in the list response. - Update `CharacterAPI` to map `_count.generations` to a flat `looksCount` field. - Add new CSS classes (`clip-path-slant`, `clip-path-hex`, `scanline`, `font-display`) to `src/ui/index.css`. - Revamp `src/ui/pages/Characters.tsx` to use the new card design with color bars, looks count, and improved visuals. - Remove `src/ui/CharacterListDemo.tsx`.
This commit is contained in:
1 parent
f825e4db02
commit
3d4caeb971
5 files changed
+139
-129
No files matched your search
@@ -26,7 +26,13 @@ const route = app
|
||||
.get('/', zValidator('query', listSchema), async (c) => {
|
||||
const { page, limit } = c.req.valid('query')
|
||||
const result = await characterService.listCharacters({ page, limit })
|
||||
return c.json(result)
|
||||
return c.json({
|
||||
...result,
|
||||
items: result.items.map((item) => ({
|
||||
...item,
|
||||
looksCount: item._count.generations,
|
||||
})),
|
||||
})
|
||||
})
|
||||
.post('/', zValidator('json', createSchema), async (c) => {
|
||||
const { name, description, imageId } = c.req.valid('json')
|
||||
|
||||
@@ -24,6 +24,9 @@ export class CharacterService {
|
||||
take: limit,
|
||||
include: {
|
||||
image: true,
|
||||
_count: {
|
||||
select: { generations: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Hexagon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
interface Character {
|
||||
id: string
|
||||
name: string
|
||||
role: 'Vanguard' | 'Duelist' | 'Controller' | 'Sentinel'
|
||||
difficulty: 'Low' | 'Medium' | 'High'
|
||||
description: string
|
||||
shortBio: string
|
||||
imageUrl: string
|
||||
themeColor: string // Hex code
|
||||
accentColor: string // Hex code
|
||||
}
|
||||
|
||||
interface CharacterSelectorProps {
|
||||
characters: Character[]
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const CharacterSelector: React.FC<CharacterSelectorProps> = ({ characters, onSelect }) => {
|
||||
// Hardcoded blue shadow for the outer glow
|
||||
const blueShadow = 'rgba(59,130,246,0.6)'
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen p-4 md:p-8 flex flex-col font-sans text-slate-900 relative">
|
||||
<div className="mb-8 animate-fade-in-down">
|
||||
<div className="flex items-center gap-2 text-cyan-600 mb-2 tracking-[0.3em] text-xs font-mono uppercase">
|
||||
<Hexagon size={12} className="animate-spin-slow" />
|
||||
System // Operator_Select
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
|
||||
Select{' '}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
|
||||
Operator
|
||||
</span>
|
||||
</h1>
|
||||
<span className="font-mono text-slate-400 text-sm hidden sm:block">
|
||||
UNITS: {characters.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-6 max-w-[1920px] mx-auto w-full pb-10">
|
||||
{characters.map((char, index) => (
|
||||
<div
|
||||
key={char.id}
|
||||
onClick={() => onSelect(char.id)}
|
||||
className="group relative h-64 cursor-pointer"
|
||||
style={{ animation: `fadeIn 0.5s ease-out ${Math.min(index * 0.03, 0.5)}s backwards` }}
|
||||
>
|
||||
{/* Wrapper for the Drop Shadow (Outer Glow) */}
|
||||
<div className={`absolute inset-0`}>
|
||||
{/* 1. Base Border (Static Slate - Always visible as the thin border) */}
|
||||
<div className="absolute inset-0 bg-slate-200 clip-path-slant" />
|
||||
|
||||
{/* 2. Highlight Border (Blue - Fades in on hover, pre-scaled to avoid growth animation) */}
|
||||
{/* Scale 1.035 is approx 70% of the previous 1.05 scale. */}
|
||||
<div className="absolute inset-0 bg-blue-500 clip-path-slant opacity-0 group-hover:opacity-100 transition-opacity duration-0 scale-[1.035]" />
|
||||
|
||||
{/* 3. Content Layer - Stays fixed size relative to parent, creating the cutout effect */}
|
||||
<div className="absolute inset-[1px] bg-slate-100 clip-path-slant overflow-hidden z-10">
|
||||
<div className="absolute inset-0 bg-slate-100">
|
||||
<img
|
||||
src={char.imageUrl}
|
||||
alt={char.name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 grayscale group-hover:grayscale-0 opacity-80 group-hover:opacity-100 mix-blend-multiply"
|
||||
/>
|
||||
{/* Gradient overlay for text readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/80 via-slate-900/20 to-transparent opacity-90 group-hover:opacity-80 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 p-3 flex flex-col justify-end">
|
||||
<div className="transform translate-y-2 group-hover:translate-y-0 transition-transform duration-200">
|
||||
<div
|
||||
className="h-0.5 w-6 mb-2 transition-all duration-300 group-hover:w-12"
|
||||
style={{ backgroundColor: char.themeColor }}
|
||||
/>
|
||||
<h2 className="text-lg font-display font-bold uppercase text-white mb-0.5 tracking-wider truncate drop-shadow-sm">
|
||||
{char.name}
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono text-slate-200 uppercase tracking-widest block font-semibold">
|
||||
{char.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<style>{`@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CharacterSelector
|
||||
@@ -80,6 +80,55 @@ body {
|
||||
animation: fade-in-up 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Character List Demo Styles */
|
||||
.font-display {
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'Noto Sans',
|
||||
sans-serif;
|
||||
font-weight: 800;
|
||||
}
|
||||
.clip-path-slant {
|
||||
clip-path: polygon(
|
||||
20px 0,
|
||||
100% 0,
|
||||
100% calc(100% - 20px),
|
||||
calc(100% - 20px) 100%,
|
||||
0 100%,
|
||||
0 20px
|
||||
);
|
||||
}
|
||||
.clip-path-hex {
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
}
|
||||
@keyframes scanline {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
.scanline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, transparent 50%, rgba(0, 0, 0, 0.03) 51%, transparent 51%);
|
||||
background-size: 100% 4px;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
:root {
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
|
||||
+80
-29
@@ -1,15 +1,27 @@
|
||||
import { InferResponseType } from 'hono/client'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Hexagon, Plus } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { client } from '../client'
|
||||
import { CreateCharacterDialog } from '../components/CreateCharacterDialog'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar'
|
||||
import { Button } from '../components/ui/button'
|
||||
|
||||
type CharacterResponse = InferResponseType<typeof client.characters.$get>
|
||||
type Character = CharacterResponse['items'][number]
|
||||
|
||||
const getCharacterColor = (name: string) => {
|
||||
const colors = [
|
||||
'#ef4444', // Red
|
||||
'#f97316', // Orange
|
||||
'#f59e0b', // Amber
|
||||
'#22c55e', // Green
|
||||
'#3b82f6', // Blue
|
||||
'#a855f7', // Purple
|
||||
]
|
||||
const charCode = name.charCodeAt(0) || 0
|
||||
return colors[charCode % colors.length]
|
||||
}
|
||||
|
||||
export default function Characters() {
|
||||
const [characters, setCharacters] = useState<Character[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -43,14 +55,28 @@ export default function Characters() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 animate-fade-in-up">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-black text-stone-800 uppercase tracking-tighter">
|
||||
Characters
|
||||
</h1>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Character
|
||||
</Button>
|
||||
<div className="w-full min-h-screen p-4 md:p-8 flex flex-col font-sans text-slate-900 relative">
|
||||
<div className="mb-8 animate-fade-in-down flex justify-between items-end">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-cyan-600 mb-2 tracking-[0.3em] text-xs font-mono uppercase">
|
||||
<Hexagon size={12} className="animate-spin-slow" />
|
||||
System // Operator_Select
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
|
||||
Select{' '}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
|
||||
Operator
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-mono text-slate-400 text-sm hidden sm:block">
|
||||
UNITS: {characters.length}
|
||||
</span>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Character
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
@@ -58,29 +84,53 @@ export default function Characters() {
|
||||
<p className="text-stone-500">No characters found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
|
||||
{characters.map((char) => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-6 max-w-[1920px] mx-auto w-full pb-10">
|
||||
{characters.map((char, index) => (
|
||||
<div
|
||||
key={char.id}
|
||||
className="border-transparent cursor-pointer"
|
||||
onClick={() => navigate(`/characters/${char.id}/looks`)}
|
||||
className="group relative h-64 cursor-pointer"
|
||||
style={{
|
||||
animation: `fadeIn 0.5s ease-out ${Math.min(index * 0.03, 0.5)}s backwards`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center p-6 gap-4">
|
||||
<Avatar className="w-32 h-32 border-4 border-stone-100 shadow-inner">
|
||||
<AvatarImage
|
||||
src={char.image?.path ? `/files/${char.image.path}` : ''}
|
||||
alt={char.name}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="text-3xl font-bold bg-stone-200 text-stone-400">
|
||||
{char.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-center">
|
||||
<h3 className="font-bold text-stone-800 text-lg leading-tight">{char.name}</h3>
|
||||
{char.description && (
|
||||
<p className="text-xs text-stone-400 mt-1 line-clamp-2">{char.description}</p>
|
||||
)}
|
||||
{/* Wrapper for the Drop Shadow (Outer Glow) */}
|
||||
<div className={`absolute inset-0`}>
|
||||
{/* 1. Base Border (Static Slate - Always visible as the thin border) */}
|
||||
<div className="absolute inset-0 bg-slate-200 clip-path-slant" />
|
||||
|
||||
{/* 2. Highlight Border (Blue - Fades in on hover, pre-scaled to avoid growth animation) */}
|
||||
{/* Scale 1.035 is approx 70% of the previous 1.05 scale. */}
|
||||
<div className="absolute inset-0 bg-blue-500 clip-path-slant opacity-0 group-hover:opacity-100 transition-opacity duration-0 scale-[1.035]" />
|
||||
|
||||
{/* 3. Content Layer - Stays fixed size relative to parent, creating the cutout effect */}
|
||||
<div className="absolute inset-[1px] bg-slate-100 clip-path-slant overflow-hidden z-10">
|
||||
<div className="absolute inset-0 bg-slate-100">
|
||||
<img
|
||||
src={char.image?.path ? `/files/${char.image.path}` : ''}
|
||||
alt={char.name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 grayscale group-hover:grayscale-0 opacity-80 group-hover:opacity-100 mix-blend-multiply"
|
||||
/>
|
||||
{/* Gradient overlay for text readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/80 via-slate-900/20 to-transparent opacity-90 group-hover:opacity-80 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 p-3 flex flex-col justify-end">
|
||||
<div className="transform translate-y-2 group-hover:translate-y-0 transition-transform duration-200">
|
||||
<div
|
||||
className="h-0.5 w-6 mb-2 transition-all duration-300 group-hover:w-12"
|
||||
style={{ backgroundColor: getCharacterColor(char.name) }}
|
||||
/>
|
||||
<h2 className="text-lg font-display font-bold uppercase text-white mb-0.5 tracking-wider truncate drop-shadow-sm">
|
||||
{char.name}
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono text-slate-200 uppercase tracking-widest block font-semibold">
|
||||
{/* @ts-ignore */}
|
||||
{char.looksCount} LOOKS
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,6 +143,7 @@ export default function Characters() {
|
||||
onOpenChange={setCreateOpen}
|
||||
onSuccess={fetchCharacters}
|
||||
/>
|
||||
<style>{`@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user