Skip to content

Commit dca0f16

Browse files
Merge pull request #115 from KhushiVadadoriya/feature/spotthedifference
Add: spot the difference game in web app
2 parents 6aea799 + 5a78e23 commit dca0f16

4 files changed

Lines changed: 1090 additions & 98 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,16 @@ Test your memory with an ever-growing sequence!
263263
python games/Simon-Says/Simon-Says.py
264264
```
265265

266+
</td>
267+
<td width="50%">
268+
269+
#### 🔍 Spot the Difference
270+
Find all the hidden differences between two interactive canvases!
271+
- 🎨 Programmatically drawn dynamic scenes
272+
- 🌟 Three distinct difficulty levels
273+
- ⏱️ Built-in timer and hint system
274+
- 🌐 *Web App Exclusive Project*
275+
266276
</td>
267277
</tr>
268278
</table>

web-app/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ An interactive web application showcasing Python mini projects with beautiful vi
2020
- **Hangman** - Classic word game
2121
- **FLAMES** - Relationship calculator
2222
- **Simon Says** - Memory Game
23+
- **Spot the Difference** - Visual puzzle game with dynamic scenes
2324

2425
### 🔢 Math Tools
2526
- **Fibonacci Series** - Visual sequence with golden spiral

web-app/js/main.js

Lines changed: 289 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,295 @@
44
- ensures Try It / Play buttons open modal even if project rendering fails
55
*/
66

7-
const prefersReducedMotion = () => window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
7+
function prefersReducedMotion() {
8+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
9+
}
10+
11+
function syncThemeColor(theme) {
12+
if (!themeColorMeta) return;
13+
themeColorMeta.setAttribute('content', theme === 'light' ? '#f8fafc' : '#0f172a');
14+
}
15+
16+
function updateThemeToggleAria(isLightTheme) {
17+
themeToggle.setAttribute(
18+
'aria-label',
19+
isLightTheme ? 'Switch to dark mode' : 'Switch to light mode'
20+
);
21+
}
22+
23+
themeToggle.addEventListener('click', () => {
24+
const currentTheme = html.getAttribute('data-theme');
25+
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
26+
27+
html.setAttribute('data-theme', newTheme);
28+
localStorage.setItem('theme', newTheme);
29+
syncThemeColor(newTheme);
30+
31+
themeToggle.innerHTML =
32+
newTheme === 'light'
33+
? '<i class="fas fa-sun" aria-hidden="true"></i>'
34+
: '<i class="fas fa-moon" aria-hidden="true"></i>';
35+
updateThemeToggleAria(newTheme === 'light');
36+
});
37+
38+
const savedTheme = localStorage.getItem('theme') || 'dark';
39+
html.setAttribute('data-theme', savedTheme);
40+
syncThemeColor(savedTheme);
41+
themeToggle.innerHTML =
42+
savedTheme === 'light'
43+
? '<i class="fas fa-sun" aria-hidden="true"></i>'
44+
: '<i class="fas fa-moon" aria-hidden="true"></i>';
45+
updateThemeToggleAria(savedTheme === 'light');
46+
47+
48+
// Back to Top Button
49+
const backToTopButton = document.getElementById('backToTop');
50+
51+
const toggleBackToTopButton = () => {
52+
backToTopButton.classList.toggle('visible', window.scrollY > 300);
53+
};
54+
55+
window.addEventListener('scroll', toggleBackToTopButton, { passive: true });
56+
toggleBackToTopButton();
57+
58+
backToTopButton.addEventListener('click', () => {
59+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
60+
window.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
61+
});
62+
63+
// Category Filtering
64+
const tabs = document.querySelectorAll('.tab');
65+
const projectCards = document.querySelectorAll('.project-card');
66+
67+
let recentSearches = JSON.parse(localStorage.getItem('recentSearches') || '[]');
68+
let currentSearchQuery = '';
69+
let selectedSuggestionIndex = -1;
70+
let currentCategory = 'all';
71+
72+
// Category Filtering (tabs)
73+
const searchInput = document.getElementById('projectSearch');
74+
const searchClear = document.getElementById('searchClear');
75+
const searchDropdown = document.getElementById('searchDropdown');
76+
const searchShortcut = document.getElementById('searchShortcut');
77+
const searchLoader = document.getElementById('searchLoader');
78+
const emptyState = document.getElementById('emptyState');
79+
const resultsList = document.getElementById('resultsList');
80+
const resultsSection = document.getElementById('resultsSection');
81+
const recentSearchesList = document.getElementById('recentSearchesList');
82+
const recentSearchesSection = document.getElementById('recentSearchesSection');
83+
const tipsSection = document.getElementById('tipsSection');
84+
85+
// Debounce function for smooth search performance
86+
function debounce(func, delay) {
87+
let timeoutId;
88+
return function (...args) {
89+
clearTimeout(timeoutId);
90+
timeoutId = setTimeout(() => func(...args), delay);
91+
};
92+
}
93+
94+
// Get all matching projects for search query
95+
function getMatchingProjects(query) {
96+
if (!query) return [];
97+
98+
const matches = [];
99+
projectCards.forEach(card => {
100+
const category = card.getAttribute('data-category');
101+
const title = card.querySelector('h3').textContent.toLowerCase();
102+
const description = card.querySelector('p').textContent.toLowerCase();
103+
const tags = (card.getAttribute('data-tags') || '').toLowerCase();
104+
105+
const categoryMatch = currentCategory === 'all' || category === currentCategory;
106+
const searchMatch = title.includes(query) ||
107+
description.includes(query) ||
108+
tags.includes(query);
109+
110+
if (categoryMatch && searchMatch) {
111+
const project = {
112+
card: card,
113+
title: card.querySelector('h3').textContent,
114+
tags: card.getAttribute('data-tags') || '',
115+
category: category
116+
};
117+
matches.push(project);
118+
}
119+
});
120+
121+
return matches;
122+
}
123+
124+
// Render autocomplete suggestions
125+
function renderSuggestions(query) {
126+
if (!query) {
127+
renderRecentSearches();
128+
return;
129+
}
130+
131+
const matches = getMatchingProjects(query);
132+
133+
if (matches.length === 0) {
134+
resultsSection.style.display = 'none';
135+
recentSearchesSection.style.display = 'none';
136+
tipsSection.style.display = 'block';
137+
return;
138+
}
139+
140+
resultsList.innerHTML = '';
141+
matches.slice(0, 8).forEach((project, index) => {
142+
const item = document.createElement('div');
143+
item.className = 'dropdown-item' + (index === selectedSuggestionIndex ? ' selected' : '');
144+
item.innerHTML = `
145+
<div class="dropdown-item-icon">
146+
${project.card.querySelector('.card-icon').textContent}
147+
</div>
148+
<div class="dropdown-item-text">${highlightMatch(project.title, query)}</div>
149+
<span class="dropdown-item-tag">${project.category}</span>
150+
`;
151+
item.addEventListener('click', () => selectSuggestion(project.title));
152+
item.addEventListener('mouseenter', () => {
153+
selectedSuggestionIndex = index;
154+
updateSuggestionHighlight();
155+
});
156+
resultsList.appendChild(item);
157+
});
158+
159+
resultsSection.style.display = 'block';
160+
recentSearchesSection.style.display = 'none';
161+
tipsSection.style.display = 'none';
162+
selectedSuggestionIndex = -1;
163+
}
164+
165+
// Highlight matching text in suggestions
166+
function highlightMatch(text, query) {
167+
const parts = text.split(new RegExp(`(${query})`, 'gi'));
168+
return parts.map(part =>
169+
part.toLowerCase() === query.toLowerCase()
170+
? `<mark style="background: rgba(99, 102, 241, 0.3); color: var(--primary-color); font-weight: 600;">${part}</mark>`
171+
: part
172+
).join('');
173+
}
174+
175+
// Render recent searches
176+
function renderRecentSearches() {
177+
if (!recentSearchesSection) return;
178+
179+
if (recentSearches.length === 0) {
180+
recentSearchesSection.style.display = 'none';
181+
if (tipsSection) tipsSection.style.display = 'block';
182+
if (resultsSection) resultsSection.style.display = 'none';
183+
return;
184+
}
185+
186+
recentSearchesList.innerHTML = '';
187+
recentSearches.slice(0, 5).forEach((search) => {
188+
const item = document.createElement('div');
189+
item.className = 'dropdown-recent-item';
190+
item.innerHTML = `
191+
<div class="dropdown-recent-text">
192+
<i class="fas fa-history" style="opacity: 0.5; font-size: 0.9rem;"></i>
193+
<span style="flex: 1; cursor: pointer; color: var(--text-secondary);">${search}</span>
194+
</div>
195+
<button class="dropdown-recent-remove" aria-label="Remove search">
196+
<i class="fas fa-x"></i>
197+
</button>
198+
`;
199+
200+
const textElement = item.querySelector('span');
201+
const removeBtn = item.querySelector('.dropdown-recent-remove');
202+
203+
textElement.addEventListener('click', () => {
204+
searchInput.value = search;
205+
currentSearchQuery = search;
206+
performSearch();
207+
closeDropdown();
208+
});
209+
210+
removeBtn.addEventListener('click', (e) => {
211+
e.stopPropagation();
212+
recentSearches = recentSearches.filter(s => s !== search);
213+
localStorage.setItem('recentSearches', JSON.stringify(recentSearches));
214+
renderRecentSearches();
215+
});
216+
217+
recentSearchesList.appendChild(item);
218+
});
219+
220+
recentSearchesSection.style.display = 'block';
221+
resultsSection.style.display = 'none';
222+
tipsSection.style.display = 'block';
223+
}
224+
225+
function applyCategoryFilter(category) {
226+
projectCards.forEach((card) => {
227+
if (category === 'all' || card.getAttribute('data-category') === category) {
228+
card.style.display = 'block';
229+
if (!prefersReducedMotion()) {
230+
card.style.animation = 'fadeIn 0.6s ease';
231+
} else {
232+
card.style.animation = 'none';
233+
}
234+
} else {
235+
card.style.display = 'none';
236+
}
237+
});
238+
}
239+
240+
function moveTabFocus(fromIndex, delta) {
241+
const len = tabs.length;
242+
const next = (fromIndex + delta + len) % len;
243+
tabs.forEach((t, i) => {
244+
const selected = i === next;
245+
t.classList.toggle('active', selected);
246+
t.setAttribute('aria-selected', selected ? 'true' : 'false');
247+
t.setAttribute('tabindex', selected ? '0' : '-1');
248+
});
249+
tabs[next].focus();
250+
applyCategoryFilter(tabs[next].getAttribute('data-category'));
251+
}
252+
253+
tabs.forEach((tab, index) => {
254+
tab.addEventListener('click', () => {
255+
tabs.forEach((t, i) => {
256+
const selected = t === tab;
257+
t.classList.toggle('active', selected);
258+
t.setAttribute('aria-selected', selected ? 'true' : 'false');
259+
t.setAttribute('tabindex', selected ? '0' : '-1');
260+
});
261+
applyCategoryFilter(tab.getAttribute('data-category'));
262+
});
263+
264+
tab.addEventListener('keydown', (e) => {
265+
let handled = false;
266+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
267+
moveTabFocus(index, 1);
268+
handled = true;
269+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
270+
moveTabFocus(index, -1);
271+
handled = true;
272+
} else if (e.key === 'Home') {
273+
moveTabFocus(index, -index);
274+
handled = true;
275+
} else if (e.key === 'End') {
276+
moveTabFocus(index, tabs.length - 1 - index);
277+
handled = true;
278+
}
279+
if (handled) {
280+
e.preventDefault();
281+
}
282+
});
283+
});
284+
285+
// Initialize
286+
renderRecentSearches();
287+
288+
// Modal Management
289+
const modal = document.getElementById('projectModal');
290+
const modalClose = document.getElementById('modalClose');
291+
const modalBody = document.getElementById('modalBody');
292+
const modalDialogTitle = document.getElementById('modalDialogTitle');
293+
294+
let lastFocusedElement = null;
295+
let modalTabTrapHandler = null;
8296

9297
function getFocusableElements(root) {
10298
const selector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

0 commit comments

Comments
 (0)