keya: initial commit
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>tetris</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "static"
|
||||||
|
}
|
||||||
Generated
+1294
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "tetris",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
|
"oxlint": "^1.75.0",
|
||||||
|
"vite": "^8.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import Board from './components/Board'
|
||||||
|
import Controls from './components/Controls'
|
||||||
|
import Overlay from './components/Overlay'
|
||||||
|
import Preview from './components/Preview'
|
||||||
|
import Stats from './components/Stats'
|
||||||
|
import { useTetris } from './hooks/useTetris'
|
||||||
|
|
||||||
|
const CELL_SIZE = 30
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { state, actions, ghost } = useTetris()
|
||||||
|
const { status, current, board, queue, score, level, lines } = state
|
||||||
|
const running = status === 'playing'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
const key = e.key
|
||||||
|
if (key === ' ' || key === 'ArrowDown' || key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
switch (key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
actions.left()
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
actions.right()
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
actions.soft()
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'x':
|
||||||
|
case 'X':
|
||||||
|
actions.rotate()
|
||||||
|
break
|
||||||
|
case ' ':
|
||||||
|
actions.hard()
|
||||||
|
break
|
||||||
|
case 'p':
|
||||||
|
case 'P':
|
||||||
|
case 'Escape':
|
||||||
|
actions.pause()
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
if (status === 'idle') actions.start()
|
||||||
|
else if (status === 'over') actions.restart()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [actions, status])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<h1>Tetris</h1>
|
||||||
|
<p className="app-subtitle">React ile klasik blok bulmacası</p>
|
||||||
|
</header>
|
||||||
|
<main className="layout">
|
||||||
|
<section className="panel">
|
||||||
|
<Stats label="Panel" score={score} lines={lines} level={level} status={status} />
|
||||||
|
<div className="board-wrap">
|
||||||
|
<Board board={board} current={current} ghost={ghost} cellSize={CELL_SIZE} />
|
||||||
|
<Overlay
|
||||||
|
status={status}
|
||||||
|
onStart={actions.start}
|
||||||
|
actions={actions}
|
||||||
|
score={score}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside className="side">
|
||||||
|
<div className="side-card">
|
||||||
|
<h3>Sıradaki</h3>
|
||||||
|
<Preview queue={queue} count={3} />
|
||||||
|
</div>
|
||||||
|
<div className="side-card side-actions">
|
||||||
|
<button className="ghost-button" onClick={actions.pause} disabled={!running && status !== 'paused'}>
|
||||||
|
{status === 'paused' ? 'Devam' : 'Duraklat'}
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" onClick={actions.restart}>
|
||||||
|
Yeniden Başlat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="side-card keys-card">
|
||||||
|
<h3>Kontroller</h3>
|
||||||
|
<ul className="keys">
|
||||||
|
<li><kbd>←</kbd><kbd>→</kbd> hareket</li>
|
||||||
|
<li><kbd>↓</kbd> yavaş bırak</li>
|
||||||
|
<li><kbd>↑</kbd> / <kbd>X</kbd> döndür</li>
|
||||||
|
<li><kbd>Space</kbd> hızlı bırak</li>
|
||||||
|
<li><kbd>P</kbd> / <kbd>Esc</kbd> duraklat</li>
|
||||||
|
<li><kbd>Enter</kbd> başlat / yeniden</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<Controls actions={actions} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,55 @@
|
|||||||
|
import { COLS, COLOR, ROWS, currentCells } from '../game/tetris'
|
||||||
|
|
||||||
|
export default function Board({ board, current, ghost, cellSize }) {
|
||||||
|
const cells = []
|
||||||
|
const pieceCells = new Map()
|
||||||
|
const ghostSet = new Set()
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
currentCells(current).forEach(([x, y]) => pieceCells.set(`${x},${y}`, current.type))
|
||||||
|
}
|
||||||
|
if (ghost) {
|
||||||
|
ghost.forEach(([x, y]) => {
|
||||||
|
if (x >= 0 && x < COLS && y >= 0 && y < ROWS && !pieceCells.has(`${x},${y}`)) {
|
||||||
|
ghostSet.add(`${x},${y}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
const key = `${x},${y}`
|
||||||
|
const type = pieceCells.get(key) ?? board[y][x]
|
||||||
|
const isGhost = ghostSet.has(key)
|
||||||
|
cells.push(
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={isGhost ? 'cell ghost' : type ? 'cell filled' : 'cell'}
|
||||||
|
style={{
|
||||||
|
width: cellSize,
|
||||||
|
height: cellSize,
|
||||||
|
background: isGhost
|
||||||
|
? `rgba(255,255,255,0.08)`
|
||||||
|
: type
|
||||||
|
? COLOR[type]
|
||||||
|
: undefined,
|
||||||
|
boxShadow: type && !isGhost ? `inset 0 0 0 1px rgba(0,0,0,0.25)` : undefined,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="board"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${COLS}, ${cellSize}px)`,
|
||||||
|
gridTemplateRows: `repeat(${ROWS}, ${cellSize}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export default function Controls({ actions }) {
|
||||||
|
return (
|
||||||
|
<div className="controls">
|
||||||
|
<div className="control-row">
|
||||||
|
<button onClick={actions.left} aria-label="Sola">
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
<button onClick={actions.rotate} aria-label="Döndür">
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
<button onClick={actions.right} aria-label="Sağa">
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<button onClick={actions.soft} aria-label="Yavaş bırak">
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
<button onClick={actions.hard} aria-label="Hızlı bırak">
|
||||||
|
⏬
|
||||||
|
</button>
|
||||||
|
<button onClick={actions.pause} aria-label="Duraklat">
|
||||||
|
⏸
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export default function Overlay({ status, onStart, actions, score }) {
|
||||||
|
if (status === 'playing') return null
|
||||||
|
|
||||||
|
let title
|
||||||
|
let subtitle
|
||||||
|
let buttonLabel
|
||||||
|
let onButton
|
||||||
|
|
||||||
|
if (status === 'idle') {
|
||||||
|
title = 'Tetris'
|
||||||
|
subtitle = 'Puzzle populemizi toplayın'
|
||||||
|
buttonLabel = 'Başlat'
|
||||||
|
onButton = onStart
|
||||||
|
} else if (status === 'paused') {
|
||||||
|
title = 'Duraklatıldı'
|
||||||
|
subtitle = 'Devam etmek için tekrar başlatın'
|
||||||
|
buttonLabel = 'Devam Et'
|
||||||
|
onButton = actions.pause
|
||||||
|
} else {
|
||||||
|
title = 'Oyun Bitti'
|
||||||
|
subtitle = `Skorunuz: ${score}`
|
||||||
|
buttonLabel = 'Yeniden Oyna'
|
||||||
|
onButton = actions.restart
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay">
|
||||||
|
<div className="overlay-card">
|
||||||
|
<h2 className="overlay-title">{title}</h2>
|
||||||
|
{subtitle && <p className="overlay-subtitle">{subtitle}</p>}
|
||||||
|
<button className="overlay-button" onClick={onButton ?? onStart}>
|
||||||
|
{buttonLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { COLOR, rotationCells } from '../game/tetris'
|
||||||
|
|
||||||
|
export default function Preview({ queue, count }) {
|
||||||
|
const previews = queue.slice(0, count ?? 3)
|
||||||
|
return (
|
||||||
|
<div className="preview-list">
|
||||||
|
{previews.map((type, index) => (
|
||||||
|
<MiniPiece key={index} type={type} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniPiece({ type }) {
|
||||||
|
const cells = rotationCells(type, 0)
|
||||||
|
const minX = Math.min(...cells.map(([x]) => x))
|
||||||
|
const minY = Math.min(...cells.map(([, y]) => y))
|
||||||
|
const maxX = Math.max(...cells.map(([x]) => x))
|
||||||
|
const maxY = Math.max(...cells.map(([, y]) => y))
|
||||||
|
const width = maxX - minX + 1
|
||||||
|
const height = maxY - minY + 1
|
||||||
|
const size = 22
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mini"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${width}, ${size}px)`,
|
||||||
|
gridTemplateRows: `repeat(${height}, ${size}px)`,
|
||||||
|
width: width * size,
|
||||||
|
height: height * size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells.map(([x, y], i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="mini-mask"
|
||||||
|
style={{
|
||||||
|
gridColumn: x - minX + 1,
|
||||||
|
gridRow: y - minY + 1,
|
||||||
|
background: COLOR[type],
|
||||||
|
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.25)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export default function Stats({ score, lines, level, status }) {
|
||||||
|
return (
|
||||||
|
<div className="stats">
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">Skor</span>
|
||||||
|
<span className="stat-value">{score}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">Seviye</span>
|
||||||
|
<span className="stat-value">{level}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">Satır</span>
|
||||||
|
<span className="stat-value">{lines}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">Durum</span>
|
||||||
|
<span className="stat-value status-text">
|
||||||
|
{status === 'idle' ? '—' : status === 'playing' ? 'Oyun' : status === 'paused' ? 'Duraklatıldı' : 'Bitti'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
export const COLS = 10
|
||||||
|
export const ROWS = 20
|
||||||
|
|
||||||
|
// Pieces in local coordinates (normalized so min x/y = 0).
|
||||||
|
const SHAPES = {
|
||||||
|
I: [[0, 0], [1, 0], [2, 0], [3, 0]],
|
||||||
|
O: [[0, 0], [1, 0], [0, 1], [1, 1]],
|
||||||
|
T: [[1, 0], [0, 1], [1, 1], [2, 1]],
|
||||||
|
S: [[1, 0], [2, 0], [0, 1], [1, 1]],
|
||||||
|
Z: [[0, 0], [1, 0], [1, 1], [2, 1]],
|
||||||
|
J: [[0, 0], [0, 1], [1, 1], [2, 1]],
|
||||||
|
L: [[2, 0], [0, 1], [1, 1], [2, 1]],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR = {
|
||||||
|
I: '#22d3ee',
|
||||||
|
O: '#facc15',
|
||||||
|
T: '#c084fc',
|
||||||
|
S: '#4ade80',
|
||||||
|
Z: '#f87171',
|
||||||
|
J: '#60a5fa',
|
||||||
|
L: '#fb923c',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PIECE_TYPES = Object.keys(SHAPES)
|
||||||
|
|
||||||
|
// Precompute all 4 rotations of each piece (local coords).
|
||||||
|
const ROTATIONS = {}
|
||||||
|
for (const type of PIECE_TYPES) {
|
||||||
|
let cells = [...SHAPES[type]]
|
||||||
|
const list = []
|
||||||
|
for (let r = 0; r < 4; r++) {
|
||||||
|
const minX = Math.min(...cells.map((c) => c[0]))
|
||||||
|
const minY = Math.min(...cells.map((c) => c[1]))
|
||||||
|
list.push(cells.map(([x, y]) => [x - minX, y - minY]))
|
||||||
|
cells = cells.map(([x, y]) => [-y, x])
|
||||||
|
}
|
||||||
|
ROTATIONS[type] = list
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotationCells(type, rotation, x = 0, y = 0) {
|
||||||
|
return ROTATIONS[type][((rotation % 4) + 4) % 4].map(([rx, ry]) => [rx + x, ry + y])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal offset chosen so each piece is centered on a 10-wide board.
|
||||||
|
export function spawnPiece(type) {
|
||||||
|
const w = Math.max(...SHAPES[type].map(([x]) => x)) + 1
|
||||||
|
const x = Math.floor((COLS - w) / 2)
|
||||||
|
return { type, rotation: 0, x, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyBoard() {
|
||||||
|
return Array.from({ length: ROWS }, () => Array(COLS).fill(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collides(board, cells) {
|
||||||
|
for (const [x, y] of cells) {
|
||||||
|
if (x < 0 || x >= COLS || y >= ROWS) return true
|
||||||
|
if (y >= 0 && board[y][x] != null) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePiece(board, cells, type) {
|
||||||
|
const next = board.map((row) => row.slice())
|
||||||
|
for (const [x, y] of cells) {
|
||||||
|
if (y >= 0 && y < ROWS && x >= 0 && x < COLS) next[y][x] = type
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLines(board) {
|
||||||
|
const remaining = board.filter((row) => row.some((cell) => cell == null))
|
||||||
|
const cleared = ROWS - remaining.length
|
||||||
|
while (remaining.length < ROWS) {
|
||||||
|
remaining.unshift(Array(COLS).fill(null))
|
||||||
|
}
|
||||||
|
return { board: remaining, cleared }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentCells(piece) {
|
||||||
|
return rotationCells(piece.type, piece.rotation, piece.x, piece.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectedCells(piece, dx = 0, dy = 0, rotation = piece.rotation) {
|
||||||
|
return rotationCells(piece.type, rotation, piece.x + dx, piece.y + dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ghostCells(piece, board) {
|
||||||
|
const cells = rotationCells(piece.type, piece.rotation, piece.x, piece.y)
|
||||||
|
let y = 0
|
||||||
|
while (!collides(board, cells.map(([cx, cy]) => [cx, cy + y + 1]))) {
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
return cells.map(([cx, cy]) => [cx, cy + y])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCORE_TABLE = { 1: 100, 2: 300, 3: 500, 4: 800 }
|
||||||
|
|
||||||
|
export function lineScore(cleared, level) {
|
||||||
|
return (SCORE_TABLE[cleared] ?? 0) * level
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dropSpeed(level) {
|
||||||
|
return Math.max(70, Math.round(1000 * Math.pow(0.85, Math.min(Math.max(level - 1, 0), 10))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelAfterLines(lines) {
|
||||||
|
return Math.floor(lines / 10) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shuffleBag() {
|
||||||
|
const bag = [...PIECE_TYPES]
|
||||||
|
for (let i = bag.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[bag[i], bag[j]] = [bag[j], bag[i]]
|
||||||
|
}
|
||||||
|
return bag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard rotation points for wall kicks. Key: "fromTo".
|
||||||
|
const KICKS = {
|
||||||
|
I: {
|
||||||
|
'0>1': [[-2, 0], [1, 0], [-2, -1], [1, 2]],
|
||||||
|
'1>0': [[2, 0], [-1, 0], [2, 1], [-1, -2]],
|
||||||
|
'1>2': [[-1, 0], [2, 0], [-1, 2], [2, -1]],
|
||||||
|
'2>1': [[1, 0], [-2, 0], [1, -2], [-2, 1]],
|
||||||
|
'2>3': [[2, 0], [-1, 0], [2, 1], [-1, -2]],
|
||||||
|
'3>2': [[-2, 0], [1, 0], [-2, -1], [1, 2]],
|
||||||
|
'3>0': [[1, 0], [-2, 0], [1, -2], [-2, 1]],
|
||||||
|
'0>3': [[-1, 0], [2, 0], [-1, 2], [2, -1]],
|
||||||
|
},
|
||||||
|
O: {},
|
||||||
|
standard: {
|
||||||
|
'0>1': [[-1, 0], [-1, 1], [0, -2], [-1, -2]],
|
||||||
|
'1>0': [[1, 0], [1, -1], [0, 2], [1, 2]],
|
||||||
|
'1>2': [[1, 0], [1, -1], [0, 2], [1, 2]],
|
||||||
|
'2>1': [[-1, 0], [-1, 1], [0, -2], [-1, -2]],
|
||||||
|
'2>3': [[1, 0], [1, 1], [0, -2], [1, -2]],
|
||||||
|
'3>2': [[-1, 0], [-1, -1], [0, 2], [-1, 2]],
|
||||||
|
'3>0': [[-1, 0], [-1, -1], [0, 2], [-1, 2]],
|
||||||
|
'0>3': [[1, 0], [1, 1], [0, -2], [1, -2]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kickOffsets(type, from, to) {
|
||||||
|
const table = type === 'I' ? KICKS.I : type === 'O' ? {} : KICKS.standard
|
||||||
|
return table[`${from}>${to}`] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryRotation(board, piece, toRotation) {
|
||||||
|
const kicks = kickOffsets(piece.type, piece.rotation, toRotation)
|
||||||
|
const candidates = [{ dx: 0, dy: 0 }, ...kicks.map(([dx, dy]) => ({ dx, dy }))]
|
||||||
|
for (const { dx, dy } of candidates) {
|
||||||
|
if (!collides(board, rotationCells(piece.type, toRotation, piece.x + dx, piece.y + dy))) {
|
||||||
|
return { ...piece, rotation: toRotation, x: piece.x + dx, y: piece.y + dy }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useCallback, useEffect, useReducer, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
clearLines,
|
||||||
|
collides,
|
||||||
|
createEmptyBoard,
|
||||||
|
currentCells,
|
||||||
|
dropSpeed,
|
||||||
|
ghostCells,
|
||||||
|
levelAfterLines,
|
||||||
|
lineScore,
|
||||||
|
mergePiece,
|
||||||
|
projectedCells,
|
||||||
|
spawnPiece,
|
||||||
|
shuffleBag,
|
||||||
|
tryRotation,
|
||||||
|
} from '../game/tetris'
|
||||||
|
|
||||||
|
const QUEUE_LENGTH = 5
|
||||||
|
const LOCK_DELAY = 3
|
||||||
|
|
||||||
|
function refillQueue(queue) {
|
||||||
|
let q = [...queue]
|
||||||
|
while (q.length < QUEUE_LENGTH) q = [...q, ...shuffleBag()]
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialState() {
|
||||||
|
const queue = refillQueue([])
|
||||||
|
return {
|
||||||
|
board: createEmptyBoard(),
|
||||||
|
current: spawnPiece(queue[0]),
|
||||||
|
queue: queue.slice(1),
|
||||||
|
score: 0,
|
||||||
|
lines: 0,
|
||||||
|
level: 1,
|
||||||
|
status: 'idle',
|
||||||
|
lastClear: 0,
|
||||||
|
rests: LOCK_DELAY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPiece(state) {
|
||||||
|
const merged = mergePiece(state.board, currentCells(state.current), state.current.type)
|
||||||
|
const { board, cleared } = clearLines(merged)
|
||||||
|
const lines = state.lines + cleared
|
||||||
|
const level = levelAfterLines(lines)
|
||||||
|
const current = spawnPiece(state.queue[0])
|
||||||
|
const next = {
|
||||||
|
...state,
|
||||||
|
board,
|
||||||
|
current,
|
||||||
|
queue: refillQueue(state.queue.slice(1)),
|
||||||
|
score: state.score + lineScore(cleared, state.level),
|
||||||
|
lines,
|
||||||
|
level,
|
||||||
|
lastClear: cleared,
|
||||||
|
rests: LOCK_DELAY,
|
||||||
|
}
|
||||||
|
return collides(board, currentCells(current)) ? { ...next, status: 'over' } : next
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(state, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'START': {
|
||||||
|
if (state.status === 'playing' || state.status === 'paused') return state
|
||||||
|
return { ...initialState(), status: 'playing' }
|
||||||
|
}
|
||||||
|
case 'TICK': {
|
||||||
|
if (state.status !== 'playing') return state
|
||||||
|
if (collides(state.board, projectedCells(state.current, 0, 1))) {
|
||||||
|
if (state.rests > 0) return { ...state, rests: state.rests - 1 }
|
||||||
|
return lockPiece(state)
|
||||||
|
}
|
||||||
|
return move(state, 0, 1)
|
||||||
|
}
|
||||||
|
case 'LEFT':
|
||||||
|
return state.status === 'playing' ? move(state, -1, 0) : state
|
||||||
|
case 'RIGHT':
|
||||||
|
return state.status === 'playing' ? move(state, 1, 0) : state
|
||||||
|
case 'ROTATE': {
|
||||||
|
if (state.status !== 'playing') return state
|
||||||
|
const rotated = tryRotation(state.board, state.current, (state.current.rotation + 1) % 4)
|
||||||
|
return rotated ? { ...state, current: rotated, rests: LOCK_DELAY } : state
|
||||||
|
}
|
||||||
|
case 'SOFT_DROP':
|
||||||
|
return state.status === 'playing' ? move(state, 0, 1) : state
|
||||||
|
case 'HARD_DROP': {
|
||||||
|
if (state.status !== 'playing') return state
|
||||||
|
const drop = dropDistance(state.board, state.current)
|
||||||
|
return drop > 0 ? lockPiece({ ...state, current: { ...state.current, y: state.current.y + drop } }) : lockPiece(state)
|
||||||
|
}
|
||||||
|
case 'TOGGLE_PAUSE': {
|
||||||
|
if (state.status === 'playing') return { ...state, status: 'paused' }
|
||||||
|
if (state.status === 'paused') return { ...state, status: 'playing' }
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
case 'RESTART':
|
||||||
|
return initialState()
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(state, dx, dy) {
|
||||||
|
if (!collides(state.board, projectedCells(state.current, dx, dy))) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
current: { ...state.current, x: state.current.x + dx, y: state.current.y + dy },
|
||||||
|
rests: LOCK_DELAY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropDistance(board, piece) {
|
||||||
|
let distance = 0
|
||||||
|
while (!collides(board, projectedCells(piece, 0, distance + 1))) {
|
||||||
|
distance++
|
||||||
|
}
|
||||||
|
return distance
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTetris() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, undefined, initialState)
|
||||||
|
const speed = dropSpeed(state.level)
|
||||||
|
const interval = state.status === 'playing' ? speed : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (interval == null) return
|
||||||
|
const id = setInterval(() => dispatch({ type: 'TICK' }), interval)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [interval])
|
||||||
|
|
||||||
|
const actions = useRef({})
|
||||||
|
actions.current.start = useCallback(() => dispatch({ type: 'START' }), [])
|
||||||
|
actions.current.left = useCallback(() => dispatch({ type: 'LEFT' }), [])
|
||||||
|
actions.current.right = useCallback(() => dispatch({ type: 'RIGHT' }), [])
|
||||||
|
actions.current.rotate = useCallback(() => dispatch({ type: 'ROTATE' }), [])
|
||||||
|
actions.current.soft = useCallback(() => dispatch({ type: 'SOFT_DROP' }), [])
|
||||||
|
actions.current.hard = useCallback(() => dispatch({ type: 'HARD_DROP' }), [])
|
||||||
|
actions.current.pause = useCallback(() => dispatch({ type: 'TOGGLE_PAUSE' }), [])
|
||||||
|
actions.current.restart = useCallback(() => dispatch({ type: 'RESTART' }), [])
|
||||||
|
|
||||||
|
const ghost =
|
||||||
|
state.status === 'playing' && state.current ? ghostCells(state.current, state.board) : null
|
||||||
|
|
||||||
|
return { state, actions: actions.current, ghost }
|
||||||
|
}
|
||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--panel: #161a24;
|
||||||
|
--panel-2: #1d2330;
|
||||||
|
--border: #2a3142;
|
||||||
|
--text: #e6e9f0;
|
||||||
|
--muted: #8b93a7;
|
||||||
|
--accent: #7c6cff;
|
||||||
|
--accent-2: #22d3ee;
|
||||||
|
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 44px;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
background: linear-gradient(120deg, var(--accent-2), var(--accent));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
background: #0a0c12;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.filled {
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.ghost {
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(10, 12, 18, 0.82);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
background: linear-gradient(120deg, var(--accent-2), var(--accent));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-subtitle {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(120deg, var(--accent-2), var(--accent));
|
||||||
|
box-shadow: 0 10px 24px rgba(108, 108, 255, 0.35);
|
||||||
|
transition: transform 0.12s ease, filter 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 184px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 22px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 14px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.layout {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 64px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:active {
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.app-subtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user