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:
google-labs-jules[bot] committed 2026-02-10 13:22:35 +00:00
1 parent f825e4db02
commit 3d4caeb971
5 files changed
+139 -129

No files matched your search

+7 -1
View File
@@ -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')
+3
View File
@@ -24,6 +24,9 @@ export class CharacterService {
take: limit,
include: {
image: true,
_count: {
select: { generations: true },
},
},
orderBy: {
createdAt: 'desc',
-99
View File
@@ -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
+49
View File
@@ -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
View File
@@ -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>
)
}