Add Chamber page with interactive voidlings and localStorage persistence
- Set up React Router for navigation between landing and Chamber pages - Create Chamber page with floating animated voidlings - Add ability to summon, name, rename, and release voidlings - Implement mood system (idle, happy, curious, sleepy) - Add localStorage persistence to remember voidlings between sessions - Add float animation and visual effects
This commit is contained in:
21
WORKLOG.md
21
WORKLOG.md
@ -9,6 +9,10 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
|
|||||||
- [x] Project initialized with Vite + React + TypeScript + TailwindCSS
|
- [x] Project initialized with Vite + React + TypeScript + TailwindCSS
|
||||||
- [x] Basic project structure in place
|
- [x] Basic project structure in place
|
||||||
- [x] First "Hello World" page running
|
- [x] First "Hello World" page running
|
||||||
|
- [x] React Router added for navigation
|
||||||
|
- [x] Main Chamber view created with floating voidlings
|
||||||
|
- [x] Ability to name and rename voidlings
|
||||||
|
- [x] localStorage persistence for voidlings
|
||||||
|
|
||||||
### In Progress
|
### In Progress
|
||||||
(None yet)
|
(None yet)
|
||||||
@ -17,14 +21,19 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
|
|||||||
- Initialized Vite + React + TypeScript project
|
- Initialized Vite + React + TypeScript project
|
||||||
- Added TailwindCSS configuration
|
- Added TailwindCSS configuration
|
||||||
- Created "The Void Chamber" landing page with interactive voidlings
|
- Created "The Void Chamber" landing page with interactive voidlings
|
||||||
|
- Added React Router for navigation between landing and Chamber pages
|
||||||
|
- Created Chamber page with floating animated voidlings
|
||||||
|
- Added ability to summon, name, rename, and release voidlings
|
||||||
|
- Added localStorage persistence to remember voidlings between sessions
|
||||||
|
- Added mood system (idle, happy, curious, sleepy) with click interaction
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
1. Add React Router for navigation between pages
|
1. Add more visual effects (particles, glows)
|
||||||
2. Create a main "Chamber" view where voidlings live
|
2. Add voidling interactions (feeding, playing)
|
||||||
3. Add ability to name and interact with voidlings
|
3. Add sound effects
|
||||||
4. Add persistence (localStorage) to remember your voidlings
|
4. Add voidling types/classes with different behaviors
|
||||||
5. Add animations and visual effects
|
5. Add animations for different moods
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: Session 1 complete - Project initialized*
|
*Last updated: Session 2 complete - Chamber view with interactive voidlings added*
|
||||||
|
|||||||
56
package-lock.json
generated
56
package-lock.json
generated
@ -9,7 +9,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@ -2107,6 +2108,18 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -3230,6 +3243,42 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@ -3297,6 +3346,11 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
42
src/App.tsx
42
src/App.tsx
@ -1,7 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom'
|
||||||
|
import Chamber from './pages/Chamber'
|
||||||
|
|
||||||
function App() {
|
function Landing() {
|
||||||
const [greeted, setGreeted] = useState(false)
|
const [greeted, setGreeted] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const voidlings = [
|
const voidlings = [
|
||||||
{ name: 'Glip', color: 'bg-purple-400' },
|
{ name: 'Glip', color: 'bg-purple-400' },
|
||||||
@ -11,23 +14,28 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||||
<h1 className="text-5xl font-bold mb-2 text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-20 left-20 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-5xl font-bold mb-2 text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400 relative z-10">
|
||||||
The Void Chamber
|
The Void Chamber
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-zinc-400 mb-8 text-lg">
|
<p className="text-zinc-400 mb-8 text-lg relative z-10">
|
||||||
A pocket dimension for your digital companions
|
A pocket dimension for your digital companions
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!greeted ? (
|
{!greeted ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setGreeted(true)}
|
onClick={() => setGreeted(true)}
|
||||||
className="px-8 py-4 bg-zinc-800 hover:bg-zinc-700 rounded-2xl text-xl transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-purple-500/20 border border-zinc-700"
|
className="px-8 py-4 bg-zinc-800 hover:bg-zinc-700 rounded-2xl text-xl transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-purple-500/20 border border-zinc-700 relative z-10"
|
||||||
>
|
>
|
||||||
Awaken the Void
|
Awaken the Void
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-6 mt-4">
|
<div className="flex gap-6 mt-4 relative z-10">
|
||||||
{voidlings.map((v) => (
|
{voidlings.map((v) => (
|
||||||
<div
|
<div
|
||||||
key={v.name}
|
key={v.name}
|
||||||
@ -39,11 +47,33 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-12 text-zinc-500 text-sm">
|
<div className="mt-12 flex gap-4 relative z-10">
|
||||||
|
{greeted && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/chamber')}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 rounded-xl text-lg transition-all duration-300 hover:scale-105 shadow-lg shadow-purple-500/25"
|
||||||
|
>
|
||||||
|
Enter the Chamber →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-12 text-zinc-500 text-sm relative z-10">
|
||||||
{greeted ? 'Your voidlings have awakened!' : 'Click to summon your first companions'}
|
{greeted ? 'Your voidlings have awakened!' : 'Click to summon your first companions'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Landing />} />
|
||||||
|
<Route path="/chamber" element={<Chamber />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0) translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px) translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: 'Courier New', Courier, monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
232
src/pages/Chamber.tsx
Normal file
232
src/pages/Chamber.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface Voidling {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
mood: 'idle' | 'happy' | 'curious' | 'sleepy'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOIDLING_COLORS = [
|
||||||
|
'bg-purple-400',
|
||||||
|
'bg-cyan-400',
|
||||||
|
'bg-pink-400',
|
||||||
|
'bg-emerald-400',
|
||||||
|
'bg-amber-400',
|
||||||
|
'bg-violet-400',
|
||||||
|
]
|
||||||
|
|
||||||
|
const MOOD_EMOJIS = {
|
||||||
|
idle: '◉',
|
||||||
|
happy: '✧',
|
||||||
|
curious: '❓',
|
||||||
|
sleepy: '💤',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomColor() {
|
||||||
|
return VOIDLING_COLORS[Math.floor(Math.random() * VOIDLING_COLORS.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomPosition() {
|
||||||
|
return {
|
||||||
|
x: Math.random() * 80 + 10,
|
||||||
|
y: Math.random() * 60 + 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomMood(): Voidling['mood'] {
|
||||||
|
const moods: Voidling['mood'][] = ['idle', 'happy', 'curious', 'sleepy']
|
||||||
|
return moods[Math.floor(Math.random() * moods.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return Math.random().toString(36).substring(2, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chamber() {
|
||||||
|
const [voidlings, setVoidlings] = useState<Voidling[]>([])
|
||||||
|
const [namingVoidling, setNamingVoidling] = useState<string | null>(null)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('voidlings')
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
setVoidlings(JSON.parse(saved))
|
||||||
|
} catch {
|
||||||
|
createStarterVoidlings()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
createStarterVoidlings()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('voidlings', JSON.stringify(voidlings))
|
||||||
|
}, [voidlings])
|
||||||
|
|
||||||
|
function createStarterVoidlings() {
|
||||||
|
const starters: Voidling[] = [
|
||||||
|
{ id: generateId(), name: 'Glip', color: 'bg-purple-400', mood: 'idle', ...getRandomPosition() },
|
||||||
|
{ id: generateId(), name: 'Floop', color: 'bg-cyan-400', mood: 'happy', ...getRandomPosition() },
|
||||||
|
{ id: generateId(), name: 'Norf', color: 'bg-pink-400', mood: 'curious', ...getRandomPosition() },
|
||||||
|
]
|
||||||
|
setVoidlings(starters)
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnVoidling() {
|
||||||
|
const pos = getRandomPosition()
|
||||||
|
const newVoidling: Voidling = {
|
||||||
|
id: generateId(),
|
||||||
|
name: '???',
|
||||||
|
color: getRandomColor(),
|
||||||
|
mood: getRandomMood(),
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
}
|
||||||
|
setVoidlings([...voidlings, newVoidling])
|
||||||
|
setNamingVoidling(newVoidling.id)
|
||||||
|
setNewName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNaming(id: string) {
|
||||||
|
setNamingVoidling(id)
|
||||||
|
const v = voidlings.find(v => v.id === id)
|
||||||
|
setNewName(v?.name === '???' ? '' : v?.name || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishNaming(id: string) {
|
||||||
|
if (newName.trim()) {
|
||||||
|
setVoidlings(voidlings.map(v =>
|
||||||
|
v.id === id ? { ...v, name: newName.trim() } : v
|
||||||
|
))
|
||||||
|
}
|
||||||
|
setNamingVoidling(null)
|
||||||
|
setNewName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMood(id: string) {
|
||||||
|
const moods: Voidling['mood'][] = ['idle', 'happy', 'curious', 'sleepy']
|
||||||
|
setVoidlings(voidlings.map(v => {
|
||||||
|
if (v.id === id) {
|
||||||
|
const currentIndex = moods.indexOf(v.mood)
|
||||||
|
const nextMood = moods[(currentIndex + 1) % moods.length]
|
||||||
|
return { ...v, mood: nextMood }
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseVoidling(id: string) {
|
||||||
|
setVoidlings(voidlings.filter(v => v.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-8 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-10 left-10 w-2 h-2 bg-purple-400/30 rounded-full animate-ping" />
|
||||||
|
<div className="absolute top-40 right-20 w-3 h-3 bg-cyan-400/30 rounded-full animate-pulse" />
|
||||||
|
<div className="absolute bottom-20 left-1/4 w-2 h-2 bg-pink-400/30 rounded-full animate-ping delay-700" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto relative z-10">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-zinc-500 hover:text-zinc-300 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
← Back to Portal
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400">
|
||||||
|
The Void Chamber
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={spawnVoidling}
|
||||||
|
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-sm transition-all duration-200 hover:scale-105 border border-zinc-700"
|
||||||
|
>
|
||||||
|
+ Summon Voidling
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full h-[70vh] bg-zinc-900/30 rounded-3xl border border-zinc-800 overflow-hidden">
|
||||||
|
{voidlings.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-zinc-500">
|
||||||
|
<p>The chamber feels empty... Summon a voidling to begin.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{voidlings.map((voidling) => (
|
||||||
|
<div
|
||||||
|
key={voidling.id}
|
||||||
|
className="absolute group cursor-pointer transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
left: `${voidling.x}%`,
|
||||||
|
top: `${voidling.y}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
onClick={() => changeMood(voidling.id)}
|
||||||
|
onDoubleClick={() => startNaming(voidling.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
${voidling.color}
|
||||||
|
w-20 h-20 rounded-full
|
||||||
|
flex items-center justify-center
|
||||||
|
text-zinc-900 font-bold text-lg
|
||||||
|
transition-all duration-300
|
||||||
|
hover:scale-110 hover:shadow-xl
|
||||||
|
animate-float
|
||||||
|
border-4 border-zinc-900/20
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
animationDuration: `${2 + Math.random()}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{MOOD_EMOJIS[voidling.mood]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||||
|
{namingVoidling === voidling.id ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onBlur={() => finishNaming(voidling.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') finishNaming(voidling.id)
|
||||||
|
}}
|
||||||
|
className="bg-zinc-800 border border-zinc-600 rounded-lg px-2 py-1 text-xs text-center w-24 focus:outline-none focus:border-purple-400"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-zinc-400 bg-zinc-900/80 px-2 py-1 rounded-lg">
|
||||||
|
{voidling.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
releaseVoidling(voidling.id)
|
||||||
|
}}
|
||||||
|
className="absolute -top-2 -right-2 w-6 h-6 bg-red-900/80 hover:bg-red-700 rounded-full text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-zinc-500 text-sm">
|
||||||
|
<p>Click voidling to change mood • Double-click to rename • Click × to release</p>
|
||||||
|
<p className="mt-2">Your voidlings: {voidlings.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user