duqing2026 commited on
Commit
9fb8186
·
1 Parent(s): ca86369

feat: enhance UI/UX with glassmorphism, sound effects, and language filter

Browse files
Files changed (3) hide show
  1. app.py +105 -14
  2. static/script.js +229 -54
  3. templates/index.html +107 -41
app.py CHANGED
@@ -1,12 +1,12 @@
1
  import os
2
  import random
3
- from flask import Flask, render_template, jsonify
4
 
5
  app = Flask(__name__)
6
 
7
- # 示例代码片段 (Python, JavaScript, Go, SQL)
8
- # 包含中文注释,模拟真实开发场景
9
  SNIPPETS = [
 
10
  {
11
  "language": "Python",
12
  "code": """# 二分查找算法实现
@@ -24,6 +24,32 @@ def binary_search(arr, target):
24
 
25
  return -1 # 未找到"""
26
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  {
28
  "language": "JavaScript",
29
  "code": """/**
@@ -44,17 +70,24 @@ function debounce(func, wait) {
44
  }"""
45
  },
46
  {
47
- "language": "Python",
48
- "code": """# Flask 路由示例
49
- @app.route('/api/users', methods=['GET'])
50
- def get_users():
51
- # 从数据库获取用户列表
52
- users = db.session.query(User).all()
53
- return jsonify({
54
- "count": len(users),
55
- "data": [u.to_dict() for u in users]
56
- })"""
 
 
 
 
 
57
  },
 
 
58
  {
59
  "language": "Go",
60
  "code": """// 并发处理示例
@@ -81,6 +114,8 @@ func main() {
81
  fmt.Println("All workers done")
82
  }"""
83
  },
 
 
84
  {
85
  "language": "SQL",
86
  "code": """-- 查询高价值用户
@@ -95,8 +130,10 @@ GROUP BY u.id
95
  HAVING total_spent > 1000
96
  ORDER BY total_spent DESC;"""
97
  },
 
 
98
  {
99
- "language": "React (JSX)",
100
  "code": """// 简单的计数器组件
101
  import React, { useState } from 'react';
102
 
@@ -115,6 +152,46 @@ export default function Counter() {
115
  </div>
116
  );
117
  }"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  }
119
  ]
120
 
@@ -124,9 +201,23 @@ def index():
124
 
125
  @app.route('/api/snippet')
126
  def get_snippet():
 
 
 
 
 
 
 
 
127
  snippet = random.choice(SNIPPETS)
128
  return jsonify(snippet)
129
 
 
 
 
 
 
 
130
  @app.route('/health')
131
  def health():
132
  return "OK", 200
 
1
  import os
2
  import random
3
+ from flask import Flask, render_template, jsonify, request
4
 
5
  app = Flask(__name__)
6
 
7
+ # 示例代码片段
 
8
  SNIPPETS = [
9
+ # --- Python ---
10
  {
11
  "language": "Python",
12
  "code": """# 二分查找算法实现
 
24
 
25
  return -1 # 未找到"""
26
  },
27
+ {
28
+ "language": "Python",
29
+ "code": """# Flask 路由示例
30
+ @app.route('/api/users', methods=['GET'])
31
+ def get_users():
32
+ # 从数据库获取用户列表
33
+ users = db.session.query(User).all()
34
+ return jsonify({
35
+ "count": len(users),
36
+ "data": [u.to_dict() for u in users]
37
+ })"""
38
+ },
39
+ {
40
+ "language": "Python",
41
+ "code": """# 快速排序 (Quick Sort)
42
+ def quick_sort(arr):
43
+ if len(arr) <= 1:
44
+ return arr
45
+ pivot = arr[len(arr) // 2]
46
+ left = [x for x in arr if x < pivot]
47
+ middle = [x for x in arr if x == pivot]
48
+ right = [x for x in arr if x > pivot]
49
+ return quick_sort(left) + middle + quick_sort(right)"""
50
+ },
51
+
52
+ # --- JavaScript ---
53
  {
54
  "language": "JavaScript",
55
  "code": """/**
 
70
  }"""
71
  },
72
  {
73
+ "language": "JavaScript",
74
+ "code": """// Promise 链式调用示例
75
+ fetch('https://api.example.com/data')
76
+ .then(response => {
77
+ if (!response.ok) {
78
+ throw new Error('Network response was not ok');
79
+ }
80
+ return response.json();
81
+ })
82
+ .then(data => {
83
+ console.log('Success:', data);
84
+ })
85
+ .catch(error => {
86
+ console.error('Error:', error);
87
+ });"""
88
  },
89
+
90
+ # --- Go ---
91
  {
92
  "language": "Go",
93
  "code": """// 并发处理示例
 
114
  fmt.Println("All workers done")
115
  }"""
116
  },
117
+
118
+ # --- SQL ---
119
  {
120
  "language": "SQL",
121
  "code": """-- 查询高价值用户
 
130
  HAVING total_spent > 1000
131
  ORDER BY total_spent DESC;"""
132
  },
133
+
134
+ # --- React ---
135
  {
136
+ "language": "React",
137
  "code": """// 简单的计数器组件
138
  import React, { useState } from 'react';
139
 
 
152
  </div>
153
  );
154
  }"""
155
+ },
156
+
157
+ # --- Java ---
158
+ {
159
+ "language": "Java",
160
+ "code": """// 单例模式 (Double Checked Locking)
161
+ public class Singleton {
162
+ private static volatile Singleton instance;
163
+
164
+ private Singleton() {}
165
+
166
+ public static Singleton getInstance() {
167
+ if (instance == null) {
168
+ synchronized (Singleton.class) {
169
+ if (instance == null) {
170
+ instance = new Singleton();
171
+ }
172
+ }
173
+ }
174
+ return instance;
175
+ }
176
+ }"""
177
+ },
178
+
179
+ # --- HTML/CSS ---
180
+ {
181
+ "language": "HTML",
182
+ "code": """<!-- 简单的卡片布局 -->
183
+ <div class="card">
184
+ <img src="avatar.jpg" alt="User Avatar" class="card-img">
185
+ <div class="card-body">
186
+ <h5 class="card-title">Card Title</h5>
187
+ <p class="card-text">Some quick example text to build on the card title.</p>
188
+ <a href="#" class="btn btn-primary">Go somewhere</a>
189
+ </div>
190
+ </div>
191
+ <style>
192
+ .card { border: 1px solid #ccc; border-radius: 8px; padding: 16px; }
193
+ .card-img { width: 100%; height: auto; border-radius: 4px; }
194
+ </style>"""
195
  }
196
  ]
197
 
 
201
 
202
  @app.route('/api/snippet')
203
  def get_snippet():
204
+ lang = request.args.get('lang')
205
+
206
+ if lang and lang != 'All':
207
+ filtered = [s for s in SNIPPETS if s['language'] == lang]
208
+ if filtered:
209
+ return jsonify(random.choice(filtered))
210
+
211
+ # 默认随机返回
212
  snippet = random.choice(SNIPPETS)
213
  return jsonify(snippet)
214
 
215
+ @app.route('/api/languages')
216
+ def get_languages():
217
+ # 获取所有唯一的语言列表
218
+ languages = sorted(list(set(s['language'] for s in SNIPPETS)))
219
+ return jsonify(['All'] + languages)
220
+
221
  @app.route('/health')
222
  def health():
223
  return "OK", 200
static/script.js CHANGED
@@ -1,14 +1,27 @@
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;
@@ -16,20 +29,54 @@ 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) {
@@ -50,7 +97,6 @@ function setupGame(code, language) {
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%';
@@ -59,6 +105,9 @@ function setupGame(code, language) {
59
  // Render Code
60
  renderCode();
61
 
 
 
 
62
  hiddenInput.value = '';
63
  hiddenInput.focus();
64
  }
@@ -73,7 +122,7 @@ function renderCode() {
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);
@@ -84,6 +133,7 @@ function startTimer() {
84
  if (!startTime) {
85
  startTime = new Date();
86
  timer = setInterval(updateStats, 1000);
 
87
  }
88
  }
89
 
@@ -93,9 +143,10 @@ function updateStats() {
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
 
@@ -111,50 +162,173 @@ function finishGame() {
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();
@@ -164,16 +338,18 @@ window.addEventListener('keydown', (e) => {
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
  }
@@ -189,9 +365,9 @@ window.addEventListener('keydown', (e) => {
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
 
@@ -200,23 +376,22 @@ window.addEventListener('keydown', (e) => {
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();
 
1
  const codeDisplay = document.getElementById('code-display');
2
+ const codeContainer = document.getElementById('code-container');
3
  const hiddenInput = document.getElementById('hidden-input');
4
  const wpmDisplay = document.getElementById('wpm');
5
  const accuracyDisplay = document.getElementById('accuracy');
6
  const progressDisplay = document.getElementById('progress');
7
+ const langSelect = document.getElementById('lang-select');
8
  const resultOverlay = document.getElementById('result-overlay');
9
  const finalWpmDisplay = document.getElementById('final-wpm');
10
  const finalAccuracyDisplay = document.getElementById('final-accuracy');
11
  const restartBtn = document.getElementById('restart-btn');
12
+ const soundToggleBtn = document.getElementById('sound-toggle');
13
+ const soundIcon = document.getElementById('sound-icon');
14
+ const focusHint = document.getElementById('focus-hint');
15
+ const bestWpmDisplay = document.getElementById('best-wpm');
16
 
17
+ // Custom Code Elements
18
+ const customCodeBtn = document.getElementById('custom-code-btn');
19
+ const customModal = document.getElementById('custom-modal');
20
+ const customInput = document.getElementById('custom-input');
21
+ const customConfirm = document.getElementById('custom-confirm');
22
+ const customCancel = document.getElementById('custom-cancel');
23
+
24
+ // State
25
  let currentCode = "";
26
  let currentIndex = 0;
27
  let startTime = null;
 
29
  let mistakes = 0;
30
  let totalTyped = 0;
31
  let isFinished = false;
32
+ let soundEnabled = localStorage.getItem('code-typing-sound-enabled') !== 'false'; // Default true
33
+ let audioCtx = null;
34
+ let bestWpm = parseInt(localStorage.getItem('code-typing-best-wpm') || '0');
35
 
36
  // Initialize
37
+ async function init() {
38
+ updateSoundIcon();
39
+ bestWpmDisplay.textContent = bestWpm;
40
+
41
+ await fetchLanguages();
42
+
43
+ // Check if we just finished a custom game? No, always fetch new or use custom logic if implemented later.
44
+ // For now, default to fetch snippet.
45
  fetchSnippet();
46
+
47
+ setupEventListeners();
48
+ }
49
+
50
+ async function fetchLanguages() {
51
+ try {
52
+ const response = await fetch('/api/languages');
53
+ const languages = await response.json();
54
+
55
+ // Preserve selection if possible
56
+ const currentSelection = langSelect.value;
57
+
58
+ langSelect.innerHTML = '';
59
+ languages.forEach(lang => {
60
+ const option = document.createElement('option');
61
+ option.value = lang;
62
+ option.textContent = lang;
63
+ // Style options (black text on white background for visibility in dropdown)
64
+ option.className = "text-slate-900 bg-slate-200";
65
+ langSelect.appendChild(option);
66
+ });
67
+
68
+ if (languages.includes(currentSelection)) {
69
+ langSelect.value = currentSelection;
70
+ }
71
+ } catch (e) {
72
+ console.error("Failed to fetch languages", e);
73
+ }
74
  }
75
 
76
  async function fetchSnippet() {
77
+ const lang = langSelect.value;
78
  try {
79
+ const response = await fetch(`/api/snippet?lang=${lang}`);
80
  const data = await response.json();
81
  setupGame(data.code, data.language);
82
  } catch (e) {
 
97
  if (timer) clearInterval(timer);
98
 
99
  // Update UI
 
100
  wpmDisplay.textContent = '0';
101
  accuracyDisplay.textContent = '100%';
102
  progressDisplay.textContent = '0%';
 
105
  // Render Code
106
  renderCode();
107
 
108
+ // Scroll to top
109
+ codeContainer.scrollTop = 0;
110
+
111
  hiddenInput.value = '';
112
  hiddenInput.focus();
113
  }
 
122
  // Visual tweak for spaces/newlines
123
  if (char === '\n') {
124
  span.innerHTML = '↵\n';
125
+ span.classList.add('text-slate-700', 'opacity-50');
126
  }
127
 
128
  codeDisplay.appendChild(span);
 
133
  if (!startTime) {
134
  startTime = new Date();
135
  timer = setInterval(updateStats, 1000);
136
+ initAudio(); // Ensure audio context is ready
137
  }
138
  }
139
 
 
143
  const now = new Date();
144
  const timeDiff = (now - startTime) / 1000 / 60; // in minutes
145
 
146
+ let wpm = 0;
147
  if (timeDiff > 0) {
148
  // WPM = (Characters / 5) / Minutes
149
+ wpm = Math.round((currentIndex / 5) / timeDiff);
150
  wpmDisplay.textContent = wpm;
151
  }
152
 
 
162
  clearInterval(timer);
163
  updateStats();
164
 
165
+ const finalWpm = parseInt(wpmDisplay.textContent);
166
+ finalWpmDisplay.textContent = finalWpm;
167
  finalAccuracyDisplay.textContent = accuracyDisplay.textContent;
168
+
169
+ // Update Best WPM
170
+ if (finalWpm > bestWpm) {
171
+ bestWpm = finalWpm;
172
+ localStorage.setItem('code-typing-best-wpm', bestWpm);
173
+ bestWpmDisplay.textContent = bestWpm;
174
+ // Could play a victory sound here
175
+ }
176
+
177
  resultOverlay.classList.remove('hidden');
178
  restartBtn.focus();
179
  }
180
 
181
+ // Audio System
182
+ function initAudio() {
183
+ if (!audioCtx) {
184
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
185
+ audioCtx = new AudioContext();
186
+ }
187
+ if (audioCtx.state === 'suspended') {
188
+ audioCtx.resume();
189
+ }
190
+ }
191
+
192
+ function playClickSound() {
193
+ if (!soundEnabled || !audioCtx) return;
194
+
195
+ const osc = audioCtx.createOscillator();
196
+ const gain = audioCtx.createGain();
197
+
198
+ // Mechanical switch sound simulation (High pitch short burst)
199
+ osc.type = 'triangle';
200
+ osc.frequency.setValueAtTime(600, audioCtx.currentTime);
201
+ osc.frequency.exponentialRampToValueAtTime(300, audioCtx.currentTime + 0.05);
202
+
203
+ gain.gain.setValueAtTime(0.05, audioCtx.currentTime);
204
+ gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.05);
205
+
206
+ osc.connect(gain);
207
+ gain.connect(audioCtx.destination);
208
+
209
+ osc.start();
210
+ osc.stop(audioCtx.currentTime + 0.05);
211
+ }
212
+
213
+ function playErrorSound() {
214
+ if (!soundEnabled || !audioCtx) return;
215
 
216
+ const osc = audioCtx.createOscillator();
217
+ const gain = audioCtx.createGain();
 
 
218
 
219
+ // Low thud
220
+ osc.type = 'sine';
221
+ osc.frequency.setValueAtTime(150, audioCtx.currentTime);
222
+ osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.1);
223
 
224
+ gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
225
+ gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.1);
226
+
227
+ osc.connect(gain);
228
+ gain.connect(audioCtx.destination);
229
+
230
+ osc.start();
231
+ osc.stop(audioCtx.currentTime + 0.1);
232
+ }
233
+
234
+ function updateSoundIcon() {
235
+ soundIcon.textContent = soundEnabled ? '🔊' : '🔇';
236
+ soundToggleBtn.classList.toggle('text-blue-400', soundEnabled);
237
+ soundToggleBtn.classList.toggle('text-slate-500', !soundEnabled);
238
+ }
239
+
240
+ // Event Listeners
241
+ function setupEventListeners() {
242
+
243
+ // Focus management
244
+ document.addEventListener('click', (e) => {
245
+ // Don't autofocus if clicking on buttons or modal
246
+ if (e.target.closest('button') || e.target.closest('select') || e.target.closest('textarea') || e.target.closest('#custom-modal')) return;
247
+
248
+ if (!isFinished) {
249
+ hiddenInput.focus();
250
+ initAudio(); // Initialize audio on first user interaction
251
+ }
252
+ });
253
+
254
+ hiddenInput.addEventListener('blur', () => {
255
+ if (!isFinished) focusHint.classList.remove('opacity-0');
256
+ });
257
+
258
+ hiddenInput.addEventListener('focus', () => {
259
+ focusHint.classList.add('opacity-0');
260
+ });
261
+
262
+ // Language Change
263
+ langSelect.addEventListener('change', () => {
264
+ fetchSnippet();
265
+ hiddenInput.focus();
266
+ });
267
+
268
+ // Sound Toggle
269
+ soundToggleBtn.addEventListener('click', () => {
270
+ soundEnabled = !soundEnabled;
271
+ localStorage.setItem('code-typing-sound-enabled', soundEnabled);
272
+ updateSoundIcon();
273
+ hiddenInput.focus();
274
+ });
275
+
276
+ // Custom Code Logic
277
+ customCodeBtn.addEventListener('click', () => {
278
+ customModal.classList.remove('hidden');
279
+ customInput.focus();
280
+ });
281
+
282
+ customCancel.addEventListener('click', () => {
283
+ customModal.classList.add('hidden');
284
+ hiddenInput.focus();
285
+ });
286
+
287
+ customConfirm.addEventListener('click', () => {
288
+ const code = customInput.value.trim();
289
+ if (code) {
290
+ setupGame(code, "Custom");
291
+ customModal.classList.add('hidden');
292
+ }
293
+ });
294
+
295
+ // Restart
296
+ restartBtn.addEventListener('click', () => {
297
+ if (langSelect.value === 'Custom') {
298
+ // Rerun the custom code
299
+ setupGame(currentCode, "Custom");
300
+ } else {
301
+ fetchSnippet();
302
+ }
303
+ });
304
+
305
+ // Typing Logic
306
+ window.addEventListener('keydown', handleKeydown);
307
+ }
308
+
309
+ function handleKeydown(e) {
310
+ if (customModal.classList.contains('hidden') === false) return; // Don't type if modal is open
311
 
 
312
  if (isFinished) {
313
  if (e.key === 'Enter') {
314
+ restartBtn.click();
315
  }
316
  return;
317
  }
318
 
319
+ // Shortcuts
 
 
 
 
320
  if (e.key === 'Tab') {
321
  e.preventDefault();
322
+ fetchSnippet(); // Quick restart
323
  return;
324
  }
325
 
326
+ // Prevent default scrolling for Space
327
+ if (e.key === ' ' && e.target === document.body) {
328
+ e.preventDefault();
329
+ }
330
+
331
+ // Ignore non-character keys
332
  if (e.key.length > 1 && e.key !== 'Enter' && e.key !== 'Backspace') return;
333
 
334
  hiddenInput.focus();
 
338
  const spans = codeDisplay.querySelectorAll('span');
339
 
340
  if (e.key === 'Backspace') {
 
 
 
341
  if (currentIndex > 0) {
342
  currentIndex--;
343
  const span = spans[currentIndex];
344
  span.className = ''; // Reset classes
 
345
 
346
+ // Restore visual tweak for newline
347
+ if (span.innerText.includes('↵')) {
348
+ span.classList.add('text-slate-700', 'opacity-50');
349
+ }
350
+
351
+ span.classList.add('cursor');
352
+
353
  if (currentIndex + 1 < spans.length) {
354
  spans[currentIndex + 1].classList.remove('cursor');
355
  }
 
365
  const currentSpan = spans[currentIndex];
366
 
367
  if (typedChar === charToType) {
368
+ playClickSound();
369
+ currentSpan.classList.add('text-emerald-400', 'opacity-100');
370
+ currentSpan.classList.remove('text-red-500', 'bg-red-900/50', 'text-slate-700', 'opacity-50', 'cursor');
371
 
372
  currentIndex++;
373
 
 
376
  } else {
377
  spans[currentIndex].classList.add('cursor');
378
 
379
+ // Auto-scroll logic
380
  const cursorRect = spans[currentIndex].getBoundingClientRect();
381
+ const containerRect = codeContainer.getBoundingClientRect();
382
+
383
+ // Keep cursor in the middle third of the screen vertically
384
+ const relativeTop = cursorRect.top - containerRect.top;
385
+ if (relativeTop > containerRect.height * 0.6) {
386
+ codeContainer.scrollTop += 30; // Smooth scroll handled by CSS
387
  }
388
  }
389
  } else {
390
  mistakes++;
391
+ playErrorSound();
392
+ currentSpan.classList.add('text-red-500', 'bg-red-900/50', 'opacity-100');
 
 
393
  }
394
+ }
 
 
395
 
396
  // Start
397
  init();
templates/index.html CHANGED
@@ -17,80 +17,146 @@
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>
 
17
  @keyframes blink {
18
  50% { border-color: transparent; }
19
  }
 
20
  .no-ligatures {
21
  font-variant-ligatures: none;
22
  }
23
+ /* Custom scrollbar */
24
+ ::-webkit-scrollbar {
25
+ width: 8px;
26
+ height: 8px;
27
+ }
28
+ ::-webkit-scrollbar-track {
29
+ background: #1e293b;
30
+ }
31
+ ::-webkit-scrollbar-thumb {
32
+ background: #475569;
33
+ border-radius: 4px;
34
+ }
35
+ ::-webkit-scrollbar-thumb:hover {
36
+ background: #64748b;
37
+ }
38
  </style>
39
  </head>
40
+ <body class="bg-slate-900 text-slate-200 min-h-screen flex flex-col items-center justify-center p-4 selection:bg-blue-500/30">
41
 
42
  <!-- Header -->
43
+ <header class="w-full max-w-5xl flex flex-col md:flex-row justify-between items-center mb-6 gap-4">
44
+ <div class="flex items-baseline gap-2">
45
+ <h1 class="text-2xl font-bold text-blue-400 tracking-tight">Code Typing</h1>
46
+ <span class="text-slate-500 text-xs font-mono">v1.1</span>
47
+ </div>
48
+
49
+ <div class="flex flex-wrap items-center gap-4 text-sm">
50
+ <!-- Language Selector -->
51
+ <div class="flex items-center gap-2 bg-slate-800 px-3 py-1.5 rounded border border-slate-700">
52
+ <label for="lang-select" class="text-slate-400 text-xs uppercase">Language</label>
53
+ <select id="lang-select" class="bg-transparent text-yellow-400 font-bold focus:outline-none cursor-pointer">
54
+ <option value="All">All</option>
55
+ <!-- Populated by JS -->
56
+ </select>
57
+ </div>
58
+
59
+ <!-- Sound Toggle -->
60
+ <button id="sound-toggle" class="flex items-center gap-2 px-3 py-1.5 rounded border border-slate-700 bg-slate-800 hover:bg-slate-700 transition-colors" title="开关音效">
61
+ <span id="sound-icon">🔊</span>
62
+ </button>
63
+
64
+ <!-- Custom Code Button -->
65
+ <button id="custom-code-btn" class="px-3 py-1.5 rounded border border-slate-700 bg-slate-800 hover:bg-slate-700 transition-colors text-slate-300">
66
+ 自定义代码
67
+ </button>
68
  </div>
69
  </header>
70
 
71
  <!-- Main Typing Area -->
72
+ <main class="w-full max-w-5xl relative bg-slate-800/80 backdrop-blur-sm rounded-xl shadow-2xl overflow-hidden border border-slate-700/50 ring-1 ring-white/5">
73
 
74
  <!-- Stats Bar -->
75
+ <div class="bg-slate-900/50 p-4 flex flex-wrap justify-around border-b border-slate-700/50 gap-4">
76
+ <div class="text-center min-w-[80px]">
77
+ <div class="text-[10px] text-slate-500 uppercase tracking-widest font-bold">WPM</div>
78
+ <div id="wpm" class="text-3xl font-bold text-emerald-400 tabular-nums">0</div>
79
  </div>
80
+ <div class="text-center min-w-[80px]">
81
+ <div class="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Accuracy</div>
82
+ <div id="accuracy" class="text-3xl font-bold text-blue-400 tabular-nums">100%</div>
83
  </div>
84
+ <div class="text-center min-w-[80px]">
85
+ <div class="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Progress</div>
86
+ <div id="progress" class="text-3xl font-bold text-purple-400 tabular-nums">0%</div>
87
+ </div>
88
+ <!-- Best WPM (Local Storage) -->
89
+ <div class="text-center min-w-[80px] border-l border-slate-700 pl-4 hidden md:block">
90
+ <div class="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Today's Best</div>
91
+ <div id="best-wpm" class="text-3xl font-bold text-yellow-500/80 tabular-nums">-</div>
92
  </div>
93
  </div>
94
 
95
  <!-- Code Container -->
96
+ <div class="relative group">
97
+ <!-- Hidden Input -->
98
+ <input type="text" id="hidden-input" class="absolute opacity-0 top-0 left-0 h-full w-full cursor-default z-0" autocomplete="off" spellcheck="false">
99
 
100
  <!-- Code Display -->
101
+ <div id="code-container" class="h-[400px] overflow-y-auto p-8 relative z-0 scroll-smooth">
102
+ <div id="code-display" class="whitespace-pre no-ligatures select-none text-lg leading-relaxed text-slate-500" onclick="document.getElementById('hidden-input').focus()">
103
+ <div class="flex items-center justify-center h-full animate-pulse">Loading snippets...</div>
104
+ </div>
105
  </div>
106
 
107
+ <!-- Focus Hint -->
108
+ <div id="focus-hint" class="absolute top-4 right-6 text-xs text-slate-500 bg-slate-900/80 px-2 py-1 rounded opacity-0 transition-opacity duration-300 pointer-events-none">
109
+ Click to focus
110
+ </div>
111
+
112
+ <!-- Result Overlay -->
113
+ <div id="result-overlay" class="absolute inset-0 bg-slate-900/95 flex flex-col items-center justify-center hidden z-20 backdrop-blur-md transition-all duration-300">
114
+ <div class="text-center transform transition-all duration-500 scale-100">
115
+ <h2 class="text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-emerald-400 mb-8">Completed!</h2>
116
+
117
+ <div class="grid grid-cols-2 gap-12 mb-10">
118
+ <div class="text-center">
119
+ <div class="text-sm text-slate-400 uppercase tracking-widest mb-2">WPM</div>
120
+ <div id="final-wpm" class="text-6xl font-bold text-emerald-400 drop-shadow-lg">0</div>
121
+ </div>
122
+ <div class="text-center">
123
+ <div class="text-sm text-slate-400 uppercase tracking-widest mb-2">Accuracy</div>
124
+ <div id="final-accuracy" class="text-6xl font-bold text-blue-400 drop-shadow-lg">0%</div>
125
+ </div>
126
  </div>
127
+
128
+ <div class="flex gap-4 justify-center">
129
+ <button id="restart-btn" class="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-bold transition-all transform hover:scale-105 shadow-lg shadow-blue-900/50">
130
+ Again (Enter)
131
+ </button>
132
  </div>
133
  </div>
 
 
 
 
 
 
134
  </div>
135
  </div>
136
  </main>
137
 
138
  <!-- Footer -->
139
+ <footer class="mt-8 text-slate-500 text-xs text-center">
140
+ <p class="mb-2">
141
+ <span class="inline-block bg-slate-800 px-2 py-1 rounded border border-slate-700 mx-1">Tab</span> 重置
142
+ <span class="mx-2">|</span>
143
+ <span class="inline-block bg-slate-800 px-2 py-1 rounded border border-slate-700 mx-1">Space</span> 缩进
144
+ </p>
145
+ <p class="opacity-50">推荐使用 Chrome / Edge 浏览器获得最佳体验</p>
146
  </footer>
147
 
148
+ <!-- Custom Code Modal -->
149
+ <div id="custom-modal" class="fixed inset-0 bg-black/80 flex items-center justify-center hidden z-50 backdrop-blur-sm">
150
+ <div class="bg-slate-800 p-6 rounded-xl shadow-2xl w-full max-w-2xl border border-slate-700">
151
+ <h3 class="text-xl font-bold text-white mb-4">粘贴自定义代码</h3>
152
+ <textarea id="custom-input" class="w-full h-64 bg-slate-900 text-slate-300 p-4 rounded-lg font-mono text-sm border border-slate-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none resize-none" placeholder="在此处粘贴代码..."></textarea>
153
+ <div class="flex justify-end gap-4 mt-4">
154
+ <button id="custom-cancel" class="px-4 py-2 text-slate-400 hover:text-white transition-colors">取消</button>
155
+ <button id="custom-confirm" class="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-bold transition-colors">开始练习</button>
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
  <script src="/static/script.js"></script>
161
  </body>
162
  </html>