const App = {
state: 'home',
currentCategoryId: 'recyclable',
recognition: null,
isRecording: false,
longPressTimer: null,
longPressDelay: 300,
els: {},
async init() {
this.cacheElements();
await I18n.load('zh');
await DataStore.load();
I18n.applyToDOM();
this.initLangDropdown();
this.renderTabs();
this.selectCategory('recyclable');
this.bindEvents();
this.initVoice();
this.showWelcomeIfNeeded();
history.replaceState({ view: 'home' }, '');
},
cacheElements() {
this.els.searchInput = document.getElementById('search-input');
this.els.btnQuery = document.getElementById('btn-query');
this.els.btnVoice = document.getElementById('btn-voice');
this.els.btnHome = document.getElementById('btn-home');
this.els.btnLang = document.getElementById('btn-lang');
this.els.langMenu = document.getElementById('lang-menu');
this.els.langDropdown = document.getElementById('lang-dropdown');
this.els.tabs = document.getElementById('tabs');
this.els.tabDetail = document.getElementById('tab-detail');
this.els.tabDesc = document.getElementById('tab-desc');
this.els.btnStandard = document.getElementById('btn-standard');
this.els.itemList = document.getElementById('item-list');
this.els.browseView = document.getElementById('browse-view');
this.els.searchView = document.getElementById('search-view');
this.els.searchResults = document.getElementById('search-results');
this.els.welcomeModal = document.getElementById('welcome-modal');
this.els.btnWelcomeConfirm = document.getElementById('btn-welcome-confirm');
this.els.standardModal = document.getElementById('standard-modal');
this.els.standardModalCard = document.getElementById('standard-modal-card');
this.els.btnStandardClose = document.getElementById('btn-standard-close');
this.els.standardTitle = document.getElementById('standard-title');
this.els.standardBody = document.getElementById('standard-body');
this.els.itemModal = document.getElementById('item-modal');
this.els.itemModalCard = document.getElementById('item-modal-card');
this.els.itemModalSuggest = document.getElementById('item-modal-suggest');
this.els.itemModalName = document.getElementById('item-modal-name');
this.els.itemModalIcon = document.getElementById('item-modal-icon');
this.els.itemModalLine1 = document.getElementById('item-modal-line1');
this.els.itemModalLine2 = document.getElementById('item-modal-line2');
this.els.itemModalTips = document.getElementById('item-modal-tips');
this.els.btnItemClose = document.getElementById('btn-item-close');
this.els.noResults = document.getElementById('no-results');
},
initLangDropdown() {
const current = I18n.languages.find(l => l.code === I18n.currentLang);
this.els.btnLang.textContent = I18n.t(current.labelKey);
const onLangSelect = async (code) => {
await I18n.load(code);
I18n.applyToDOM();
this.els.btnLang.textContent = I18n.t(I18n.languages.find(l => l.code === code).labelKey);
I18n.renderLangMenu(this.els.langMenu, onLangSelect);
this.els.langMenu.classList.add('hidden');
this.renderTabs();
this.selectCategory(this.currentCategoryId);
if (this.state === 'searchFuzzy') {
const q = this.els.searchInput.value.trim();
if (q) this.search(q, false);
}
};
I18n.renderLangMenu(this.els.langMenu, onLangSelect);
},
bindEvents() {
this.els.btnQuery.addEventListener('click', () => {
const q = this.els.searchInput.value.trim();
if (q) this.search(q);
});
this.els.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const q = this.els.searchInput.value.trim();
if (q) this.search(q);
}
});
this.els.btnHome.addEventListener('click', () => this.goHome());
this.els.btnLang.addEventListener('click', (e) => {
e.stopPropagation();
this.els.langMenu.classList.toggle('hidden');
});
document.addEventListener('click', () => {
this.els.langMenu.classList.add('hidden');
});
this.els.btnStandard.addEventListener('click', () => this.openStandardModal());
this.els.btnStandardClose.addEventListener('click', () => this.closeStandardModal());
this.els.standardModal.addEventListener('click', (e) => {
if (e.target === this.els.standardModal) this.closeStandardModal();
});
this.els.btnItemClose.addEventListener('click', () => this.closeItemModal());
this.els.itemModal.addEventListener('click', (e) => {
if (e.target === this.els.itemModal) this.closeItemModal();
});
this.els.btnWelcomeConfirm.addEventListener('click', () => {
Storage.dismissWelcome();
this.els.welcomeModal.classList.add('hidden');
});
window.addEventListener('popstate', (e) => {
if (!e.state || e.state.view === 'home') {
this.goHome(false);
}
});
},
showWelcomeIfNeeded() {
if (!Storage.isWelcomeDismissed()) {
this.els.welcomeModal.classList.remove('hidden');
}
},
renderTabs() {
this.els.tabs.innerHTML = '';
DataStore.categories.forEach(cat => {
const tab = document.createElement('div');
tab.className = 'tab';
tab.dataset.categoryId = cat.id;
tab.innerHTML =
'' +
'
';
tab.addEventListener('click', () => {
if (this.state === 'searchFuzzy') return;
this.selectCategory(cat.id);
});
this.els.tabs.appendChild(tab);
});
},
updateTabStates(categoryId) {
this.els.tabs.querySelectorAll('.tab').forEach(tab => {
const cat = DataStore.getCategory(tab.dataset.categoryId);
if (!cat) return;
const isActive = tab.dataset.categoryId === categoryId;
tab.classList.toggle('active', isActive);
tab.style.backgroundColor = isActive ? cat.color : '';
const iconEl = tab.querySelector('.tab-icon');
if (iconEl) {
iconEl.src = isActive ? cat.icon : (cat.iconInactive || cat.icon);
}
});
},
selectCategory(categoryId) {
this.currentCategoryId = categoryId;
const cat = DataStore.getCategory(categoryId);
if (!cat) return;
this.updateTabStates(categoryId);
this.els.tabDetail.style.backgroundColor = cat.color;
this.els.tabDesc.textContent = I18n.t(cat.descKey);
this.renderItemList(DataStore.getItemsByCategory(categoryId), this.els.itemList, false);
},
renderItemList(items, container, isSearch) {
container.innerHTML = '';
container.classList.toggle('item-list-browse', !isSearch);
container.classList.toggle('item-list-search-wrap', isSearch);
items.forEach((item, index) => {
const row = document.createElement('div');
if (isSearch) {
row.className = 'list-item list-item-search' + (index % 2 === 0 ? ' list-item-even' : ' list-item-odd');
const cat = DataStore.getCategory(item.categoryId);
row.innerHTML =
'' + DataStore.getLocalizedName(item) + '' +
'' +
I18n.t(cat.nameKey) + '';
} else {
row.className = 'list-item' + (index % 2 === 0 ? ' list-item-even' : ' list-item-odd');
row.textContent = DataStore.getLocalizedName(item);
}
row.addEventListener('click', () => this.openItemModal(item));
container.appendChild(row);
});
},
openStandardModal() {
const cat = DataStore.getCategory(this.currentCategoryId);
if (!cat) return;
this.els.standardModalCard.style.backgroundColor = cat.color;
this.els.standardTitle.textContent = I18n.t(cat.nameKey) + ' - ' + I18n.t('btn.standard');
this.els.standardBody.textContent = I18n.t(cat.standardKey);
this.els.standardModal.classList.remove('hidden');
},
closeStandardModal() {
this.els.standardModal.classList.add('hidden');
},
openItemModal(item) {
const cat = DataStore.getCategory(item.categoryId);
if (!cat) return;
const tpl = cat.disposalTemplate;
this.els.itemModalName.textContent = DataStore.getLocalizedName(item);
this.els.itemModalCard.style.backgroundColor = cat.color;
this.els.itemModalIcon.src = cat.icon;
this.els.itemModalLine1.textContent = I18n.t(tpl.line1Key);
this.els.itemModalLine2.textContent = I18n.t(tpl.line2Key);
this.els.itemModalTips.innerHTML = '';
tpl.tipsKeys.forEach((key, i) => {
const li = document.createElement('li');
li.textContent = (i + 1) + '. ' + I18n.t(key);
this.els.itemModalTips.appendChild(li);
});
this.els.itemModal.classList.remove('hidden');
},
closeItemModal() {
this.els.itemModal.classList.add('hidden');
},
search(query, pushHistory) {
if (pushHistory === undefined) pushHistory = true;
const q = query.trim().toLowerCase();
if (!q) return;
this.els.noResults.classList.add('hidden');
const allItems = DataStore.getAllItemsLocalized();
const exact = allItems.filter(i => i.localName.toLowerCase() === q);
if (exact.length === 1) {
this.state = 'searchExact';
this.els.btnHome.classList.add('hidden');
this.openItemModal(exact[0]);
return;
}
const fuzzy = allItems.filter(i => i.localName.toLowerCase().includes(q));
if (fuzzy.length === 0) {
this.els.browseView.classList.remove('hidden');
this.els.searchView.classList.add('hidden');
this.els.btnHome.classList.add('hidden');
this.els.noResults.classList.remove('hidden');
this.state = 'home';
return;
}
this.state = 'searchFuzzy';
this.els.browseView.classList.add('hidden');
this.els.searchView.classList.remove('hidden');
this.els.btnHome.classList.remove('hidden');
this.renderItemList(fuzzy, this.els.searchResults, true);
if (pushHistory) {
if (this.state === 'searchFuzzy') {
history.replaceState({ view: 'search' }, '');
} else {
history.pushState({ view: 'search' }, '');
}
}
},
goHome(fromHistory) {
if (fromHistory === undefined) fromHistory = true;
this.state = 'home';
this.els.searchInput.value = '';
this.els.browseView.classList.remove('hidden');
this.els.searchView.classList.add('hidden');
this.els.btnHome.classList.add('hidden');
this.els.noResults.classList.add('hidden');
this.closeItemModal();
this.closeStandardModal();
this.selectCategory('recyclable');
if (fromHistory && history.state && history.state.view === 'search') {
history.back();
}
},
initVoice() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
this.els.btnVoice.classList.add('hidden');
return;
}
this.recognition = new SpeechRecognition();
this.recognition.lang = I18n.getLocale();
this.recognition.interimResults = false;
this.recognition.maxAlternatives = 1;
this.recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
this.els.searchInput.value = transcript;
this.search(transcript);
};
this.recognition.onerror = () => {
this.stopRecording();
};
this.recognition.onend = () => {
this.stopRecording();
};
const startRecording = (e) => {
e.preventDefault();
this.longPressTimer = setTimeout(() => {
this.startRecording();
}, this.longPressDelay);
};
const cancelLongPress = () => {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
if (this.isRecording) {
this.recognition.stop();
}
};
this.els.btnVoice.addEventListener('mousedown', startRecording);
this.els.btnVoice.addEventListener('mouseup', cancelLongPress);
this.els.btnVoice.addEventListener('mouseleave', cancelLongPress);
this.els.btnVoice.addEventListener('touchstart', startRecording, { passive: false });
this.els.btnVoice.addEventListener('touchend', cancelLongPress);
this.els.btnVoice.addEventListener('touchcancel', cancelLongPress);
},
startRecording() {
if (this.isRecording) return;
this.isRecording = true;
this.recognition.lang = I18n.getLocale();
this.els.btnVoice.classList.add('recording');
try {
this.recognition.start();
} catch {
this.stopRecording();
}
},
stopRecording() {
this.isRecording = false;
this.els.btnVoice.classList.remove('recording');
}
};
document.addEventListener('DOMContentLoaded', () => App.init());