duqing2026 commited on
Commit
1bd2bb8
·
0 Parent(s):

Initial commit: Code Typing Practice

Browse files
Files changed (5) hide show
  1. Dockerfile +15 -0
  2. app.py +81 -0
  3. requirements.txt +2 -0
  4. static/script.js +222 -0
  5. templates/index.html +96 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create a non-root user for security (and HF Spaces compatibility)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV PATH="/home/user/.local/bin:$PATH"
14
+
15
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ from flask import Flask, render_template, jsonify
4
+
5
+ app = Flask(__name__)
6
+
7
+ # Sample Code Snippets (Python, JavaScript, Go, HTML/CSS)
8
+ SNIPPETS = [
9
+ {
10
+ "language": "Python",
11
+ "code": """def fibonacci(n):
12
+ if n <= 1:
13
+ return n
14
+ else:
15
+ return fibonacci(n-1) + fibonacci(n-2)
16
+
17
+ print([fibonacci(i) for i in range(10)])"""
18
+ },
19
+ {
20
+ "language": "JavaScript",
21
+ "code": """function debounce(func, wait) {
22
+ let timeout;
23
+ return function(...args) {
24
+ const context = this;
25
+ clearTimeout(timeout);
26
+ timeout = setTimeout(() => func.apply(context, args), wait);
27
+ };
28
+ }"""
29
+ },
30
+ {
31
+ "language": "Python",
32
+ "code": """class Node:
33
+ def __init__(self, data):
34
+ self.data = data
35
+ self.next = None
36
+
37
+ class LinkedList:
38
+ def __init__(self):
39
+ self.head = None"""
40
+ },
41
+ {
42
+ "language": "Go",
43
+ "code": """package main
44
+
45
+ import "fmt"
46
+
47
+ func main() {
48
+ fmt.Println("Hello, World!")
49
+ messages := make(chan string)
50
+ go func() { messages <- "ping" }()
51
+ msg := <-messages
52
+ fmt.Println(msg)
53
+ }"""
54
+ },
55
+ {
56
+ "language": "SQL",
57
+ "code": """SELECT users.name, COUNT(orders.id) as order_count
58
+ FROM users
59
+ LEFT JOIN orders ON users.id = orders.user_id
60
+ GROUP BY users.id
61
+ HAVING order_count > 5
62
+ ORDER BY order_count DESC;"""
63
+ }
64
+ ]
65
+
66
+ @app.route('/')
67
+ def index():
68
+ return render_template('index.html')
69
+
70
+ @app.route('/api/snippet')
71
+ def get_snippet():
72
+ snippet = random.choice(SNIPPETS)
73
+ return jsonify(snippet)
74
+
75
+ @app.route('/health')
76
+ def health():
77
+ return "OK", 200
78
+
79
+ if __name__ == '__main__':
80
+ port = int(os.environ.get('PORT', 7860))
81
+ app.run(host='0.0.0.0', port=port)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask==3.0.0
2
+ gunicorn==21.2.0
static/script.js ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const codeDisplay = document.getElementById('code-display');
2
+ const hiddenInput = document.getElementById('hidden-input');
3
+ const wpmDisplay = document.getElementById('wpm');
4
+ const accuracyDisplay = document.getElementById('accuracy');
5
+ const progressDisplay = document.getElementById('progress');
6
+ const langDisplay = document.getElementById('lang-display');
7
+ const resultOverlay = document.getElementById('result-overlay');
8
+ const finalWpmDisplay = document.getElementById('final-wpm');
9
+ const finalAccuracyDisplay = document.getElementById('final-accuracy');
10
+ const restartBtn = document.getElementById('restart-btn');
11
+
12
+ let currentCode = "";
13
+ let currentIndex = 0;
14
+ let startTime = null;
15
+ let timer = null;
16
+ let mistakes = 0;
17
+ let totalTyped = 0;
18
+ let isFinished = false;
19
+
20
+ // Initialize
21
+ function init() {
22
+ fetchSnippet();
23
+ hiddenInput.focus();
24
+ // Keep focus on hidden input
25
+ document.addEventListener('click', () => {
26
+ if (!isFinished) hiddenInput.focus();
27
+ });
28
+ }
29
+
30
+ async function fetchSnippet() {
31
+ try {
32
+ const response = await fetch('/api/snippet');
33
+ const data = await response.json();
34
+ setupGame(data.code, data.language);
35
+ } catch (e) {
36
+ console.error("Failed to fetch snippet", e);
37
+ setupGame("print('Hello World')", "Python");
38
+ }
39
+ }
40
+
41
+ function setupGame(code, language) {
42
+ // Reset state
43
+ currentCode = code.replace(/\t/g, " "); // Replace tabs with spaces
44
+ currentIndex = 0;
45
+ startTime = null;
46
+ mistakes = 0;
47
+ totalTyped = 0;
48
+ isFinished = false;
49
+
50
+ if (timer) clearInterval(timer);
51
+
52
+ // Update UI
53
+ langDisplay.textContent = language;
54
+ wpmDisplay.textContent = '0';
55
+ accuracyDisplay.textContent = '100%';
56
+ progressDisplay.textContent = '0%';
57
+ resultOverlay.classList.add('hidden');
58
+
59
+ // Render Code
60
+ renderCode();
61
+
62
+ hiddenInput.value = '';
63
+ hiddenInput.focus();
64
+ }
65
+
66
+ function renderCode() {
67
+ codeDisplay.innerHTML = '';
68
+ currentCode.split('').forEach((char, index) => {
69
+ const span = document.createElement('span');
70
+ span.innerText = char;
71
+ if (index === 0) span.classList.add('cursor');
72
+
73
+ // Visual tweak for spaces/newlines
74
+ if (char === '\n') {
75
+ span.innerHTML = '↵\n';
76
+ span.classList.add('text-slate-700');
77
+ }
78
+
79
+ codeDisplay.appendChild(span);
80
+ });
81
+ }
82
+
83
+ function startTimer() {
84
+ if (!startTime) {
85
+ startTime = new Date();
86
+ timer = setInterval(updateStats, 1000);
87
+ }
88
+ }
89
+
90
+ function updateStats() {
91
+ if (!startTime) return;
92
+
93
+ const now = new Date();
94
+ const timeDiff = (now - startTime) / 1000 / 60; // in minutes
95
+
96
+ if (timeDiff > 0) {
97
+ // WPM = (Characters / 5) / Minutes
98
+ const wpm = Math.round((currentIndex / 5) / timeDiff);
99
+ wpmDisplay.textContent = wpm;
100
+ }
101
+
102
+ const accuracy = totalTyped === 0 ? 100 : Math.round(((totalTyped - mistakes) / totalTyped) * 100);
103
+ accuracyDisplay.textContent = accuracy + '%';
104
+
105
+ const progress = Math.round((currentIndex / currentCode.length) * 100);
106
+ progressDisplay.textContent = progress + '%';
107
+ }
108
+
109
+ function finishGame() {
110
+ isFinished = true;
111
+ clearInterval(timer);
112
+ updateStats();
113
+
114
+ finalWpmDisplay.textContent = wpmDisplay.textContent;
115
+ finalAccuracyDisplay.textContent = accuracyDisplay.textContent;
116
+ resultOverlay.classList.remove('hidden');
117
+ restartBtn.focus();
118
+ }
119
+
120
+ // Input Handling
121
+ hiddenInput.addEventListener('input', (e) => {
122
+ if (isFinished) return;
123
+
124
+ // We don't actually use the input value, we just catch the event
125
+ // But to support backspace on mobile properly, we might need more complex logic.
126
+ // For this desktop-first version, we'll listen to 'keydown' for control and just check logic here if needed.
127
+ // Actually, 'input' event is safer for mobile software keyboards.
128
+
129
+ const inputChar = e.data;
130
+
131
+ // Reset input to empty to avoid scrolling or overflow
132
+ // BUT, we need to handle the character.
133
+ // This approach is tricky with 'input' event because 'data' can be null on some actions.
134
+ // Let's rely on keydown for desktop/precision, and input for backup?
135
+ // Let's stick to keydown for a "Pro" tool.
136
+ });
137
+
138
+ window.addEventListener('keydown', (e) => {
139
+ if (isFinished) {
140
+ if (e.key === 'Enter') {
141
+ init();
142
+ }
143
+ return;
144
+ }
145
+
146
+ // Prevent default scrolling for Space
147
+ if (e.key === ' ' && e.target === document.body) {
148
+ e.preventDefault();
149
+ }
150
+
151
+ if (e.key === 'Tab') {
152
+ e.preventDefault();
153
+ init(); // Quick restart
154
+ return;
155
+ }
156
+
157
+ // Ignore non-character keys (Shift, Ctrl, etc.)
158
+ if (e.key.length > 1 && e.key !== 'Enter' && e.key !== 'Backspace') return;
159
+
160
+ hiddenInput.focus();
161
+ startTimer();
162
+
163
+ const charToType = currentCode[currentIndex];
164
+ const spans = codeDisplay.querySelectorAll('span');
165
+
166
+ if (e.key === 'Backspace') {
167
+ // Optional: Allow going back?
168
+ // For strict practice, maybe not. Or yes.
169
+ // Let's allow simple backspace.
170
+ if (currentIndex > 0) {
171
+ currentIndex--;
172
+ const span = spans[currentIndex];
173
+ span.className = ''; // Reset classes
174
+ span.classList.add('cursor'); // Move cursor back
175
+
176
+ // Remove cursor from next char
177
+ if (currentIndex + 1 < spans.length) {
178
+ spans[currentIndex + 1].classList.remove('cursor');
179
+ }
180
+ }
181
+ return;
182
+ }
183
+
184
+ let typedChar = e.key;
185
+ if (typedChar === 'Enter') typedChar = '\n';
186
+
187
+ totalTyped++;
188
+
189
+ const currentSpan = spans[currentIndex];
190
+
191
+ if (typedChar === charToType) {
192
+ currentSpan.classList.add('text-emerald-400'); // Correct
193
+ currentSpan.classList.remove('text-red-500', 'bg-red-900/50', 'text-slate-500');
194
+ currentSpan.classList.remove('cursor');
195
+
196
+ currentIndex++;
197
+
198
+ if (currentIndex >= currentCode.length) {
199
+ finishGame();
200
+ } else {
201
+ spans[currentIndex].classList.add('cursor');
202
+
203
+ // Auto-scroll if cursor is near bottom
204
+ const cursorRect = spans[currentIndex].getBoundingClientRect();
205
+ const containerRect = codeDisplay.parentElement.getBoundingClientRect();
206
+ if (cursorRect.bottom > containerRect.bottom - 40) {
207
+ codeDisplay.parentElement.scrollTop += 30; // Scroll down
208
+ }
209
+ }
210
+ } else {
211
+ mistakes++;
212
+ currentSpan.classList.add('text-red-500', 'bg-red-900/50'); // Incorrect
213
+ // We don't advance on mistake, user must type correct key
214
+ // Or should we?
215
+ // "Stop on error" is better for learning code.
216
+ }
217
+ });
218
+
219
+ restartBtn.addEventListener('click', init);
220
+
221
+ // Start
222
+ init();
templates/index.html ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>程序员代码打字练习 | Code Typing Practice</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ body {
11
+ font-family: 'Fira Code', monospace;
12
+ }
13
+ .cursor {
14
+ border-right: 2px solid #3b82f6;
15
+ animation: blink 1s step-end infinite;
16
+ }
17
+ @keyframes blink {
18
+ 50% { border-color: transparent; }
19
+ }
20
+ /* Disable ligatures for clearer character distinction */
21
+ .no-ligatures {
22
+ font-variant-ligatures: none;
23
+ }
24
+ </style>
25
+ </head>
26
+ <body class="bg-slate-900 text-slate-200 min-h-screen flex flex-col items-center justify-center p-4">
27
+
28
+ <!-- Header -->
29
+ <header class="w-full max-w-4xl flex justify-between items-center mb-8">
30
+ <h1 class="text-2xl font-bold text-blue-400">Code Typing <span class="text-slate-500 text-sm">beta</span></h1>
31
+ <div class="flex gap-4 text-sm text-slate-400">
32
+ <div>语言: <span id="lang-display" class="text-yellow-400 font-bold">...</span></div>
33
+ </div>
34
+ </header>
35
+
36
+ <!-- Main Typing Area -->
37
+ <main class="w-full max-w-4xl relative bg-slate-800 rounded-lg shadow-2xl overflow-hidden border border-slate-700">
38
+
39
+ <!-- Stats Bar -->
40
+ <div class="bg-slate-900/50 p-4 flex justify-around border-b border-slate-700">
41
+ <div class="text-center">
42
+ <div class="text-xs text-slate-500 uppercase tracking-wider">速度 (WPM)</div>
43
+ <div id="wpm" class="text-3xl font-bold text-emerald-400">0</div>
44
+ </div>
45
+ <div class="text-center">
46
+ <div class="text-xs text-slate-500 uppercase tracking-wider">准确率</div>
47
+ <div id="accuracy" class="text-3xl font-bold text-blue-400">100%</div>
48
+ </div>
49
+ <div class="text-center">
50
+ <div class="text-xs text-slate-500 uppercase tracking-wider">进度</div>
51
+ <div id="progress" class="text-3xl font-bold text-purple-400">0%</div>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Code Container -->
56
+ <div class="relative p-8 text-lg leading-relaxed overflow-hidden" style="min-height: 300px;">
57
+ <!-- Hidden Input for Mobile/IME support (though we focus on keydown) -->
58
+ <input type="text" id="hidden-input" class="absolute opacity-0 top-0 left-0 h-full w-full cursor-default" autocomplete="off" spellcheck="false">
59
+
60
+ <!-- Code Display -->
61
+ <div id="code-display" class="whitespace-pre no-ligatures select-none" onclick="document.getElementById('hidden-input').focus()">
62
+ <!-- Characters will be injected here -->
63
+ <div class="flex items-center justify-center h-48 text-slate-500">加载中...</div>
64
+ </div>
65
+
66
+ <!-- Result Overlay (Hidden by default) -->
67
+ <div id="result-overlay" class="absolute inset-0 bg-slate-900/90 flex flex-col items-center justify-center hidden z-10">
68
+ <h2 class="text-4xl font-bold text-white mb-4">练习完成!</h2>
69
+ <div class="flex gap-8 mb-8">
70
+ <div class="text-center">
71
+ <div class="text-sm text-slate-400">WPM</div>
72
+ <div id="final-wpm" class="text-5xl font-bold text-emerald-400">0</div>
73
+ </div>
74
+ <div class="text-center">
75
+ <div class="text-sm text-slate-400">准确率</div>
76
+ <div id="final-accuracy" class="text-5xl font-bold text-blue-400">0%</div>
77
+ </div>
78
+ </div>
79
+ <div class="flex gap-4">
80
+ <button id="restart-btn" class="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-bold transition-colors">
81
+ 再练一次 (Enter)
82
+ </button>
83
+ <!-- Space for future sharing features -->
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </main>
88
+
89
+ <!-- Footer -->
90
+ <footer class="mt-8 text-slate-500 text-sm text-center">
91
+ <p>按 <kbd class="px-2 py-1 bg-slate-700 rounded text-slate-300 font-mono text-xs">Tab</kbd> 重置 | 缩进请用空格键 | 推荐使用 PC 端练习</p>
92
+ </footer>
93
+
94
+ <script src="/static/script.js"></script>
95
+ </body>
96
+ </html>