Files
pw-generator/assets/js/app.js
T

1319 lines
46 KiB
JavaScript

// Password Generator Frontend Application v4.0
// Complete refactoring with API-driven generator definitions
// Supports programmatic i18n key derivation and dynamic form generation
class PasswordGeneratorApp {
constructor() {
this.currentLang = 'en';
this.translations = {};
this.generatorDefinitions = []; // ✅ NEW: API-driven definitions
this.availableWordlists = {};
this.gridResizeListener = null;
this.gridState = { currentLayout: 'single', cardCount: 0 };
this.resizeTimeout = null;
this.rouletteRuns = new Map();
this.defaultRouletteIntervalSeconds = 3;
this.defaultLanguages = {
'en': 'English',
'de': 'Deutsch',
'de_CH': 'Schwyzerdütsch',
'fr': 'Français',
'it': 'Italiano',
'es': 'Español',
'pt': 'Portugues',
'ja': '日本語',
'zh': '中文'
};
this.init();
}
async init() {
console.log('🚀 Initializing Password Generator App v4.0');
try {
await this.detectLanguage();
await this.loadTranslations();
await this.loadGeneratorsMetadata(); // ✅ NEW: Load from API
await this.loadAvailableWordlists();
this.setupEventListeners();
this.renderGeneratorCards(); // ✅ Now uses API definitions
this.updateLanguageSelector();
this.switchLanguage(this.currentLang);
this.hideLoadingSpinner();
this.updateFormFieldsForScreen();
this.setupGridObserver();
// Add resize listener
window.addEventListener('resize', () => {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.updateFormFieldsForScreen();
}, 250);
});
console.log('✅ Initialization complete');
} catch (error) {
console.error('❌ Initialization failed:', error);
this.showError('Failed to initialize application');
}
}
/**
* ✅ NEW: Load generator metadata from backend API
*/
async loadGeneratorsMetadata() {
try {
console.log('🔄 Loading generator metadata from backend...');
const response = await fetch('/api/v1/passwords/generators');
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
if (data.success && data.data.generators) {
this.generatorDefinitions = data.data.generators;
this.defaultRouletteIntervalSeconds = Math.max(
1,
Math.min(30, parseInt(data.data.settings?.roulette_interval_seconds ?? 3, 10) || 3)
);
console.log(`✅ Loaded ${this.generatorDefinitions.length} generators`);
// Log generator summary
this.generatorDefinitions.forEach(gen => {
console.log(` 📦 ${gen.id} (v${gen.version}): ${gen.options.length} options`);
});
} else {
throw new Error('Invalid API response format for generators');
}
} catch (error) {
console.error('❌ Failed to load generator metadata:', error.message);
this.generatorDefinitions = [];
throw error;
}
}
hideLoadingSpinner() {
const loadingElement = document.getElementById('initial-loading');
const generatorsContainer = document.getElementById('generators-container');
if (loadingElement) {
loadingElement.style.display = 'none';
}
if (generatorsContainer) {
generatorsContainer.style.display = 'grid';
generatorsContainer.classList.add('generator-grid');
console.log('✅ Generator grid now visible');
} else {
console.error('❌ generators-container element not found in DOM');
}
}
async loadAvailableWordlists() {
try {
console.log('🔄 Loading available wordlists from backend...');
const response = await fetch('/api/v1/passwords/wordlists');
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
if (data.success && data.data.wordlists) {
this.availableWordlists = data.data.wordlists;
console.log('✅ Loaded wordlists:', Object.keys(this.availableWordlists));
} else {
throw new Error('Invalid API response format');
}
} catch (error) {
console.warn('⚠️ Failed to load wordlists from API:', error.message);
console.log('🔄 Using fallback wordlist configuration');
this.availableWordlists = {
'en': { name: 'English (Fallback)', description: 'Built-in English word list', available: false },
'de': { name: 'German (Fallback)', description: 'Built-in German word list', available: false }
};
}
}
setupGridObserver() {
if ('IntersectionObserver' in window) {
const gridObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const container = entry.target;
this.updateGridLayout(container);
}
});
}, { rootMargin: '50px' });
const container = document.getElementById('generators-container');
if (container) {
gridObserver.observe(container);
}
}
}
updateGridLayout(container) {
const cardCount = container.querySelectorAll('.generator-card').length;
const containerWidth = container.clientWidth;
let layout = 'single';
if (containerWidth >= 1600 && cardCount >= 4) {
layout = 'quad';
} else if (containerWidth >= 1200 && cardCount >= 3) {
layout = 'triple';
} else if (containerWidth >= 768 && cardCount >= 2) {
layout = 'dual';
}
if (this.gridState.currentLayout !== layout) {
this.gridState.currentLayout = layout;
this.gridState.cardCount = cardCount;
console.log(`📐 Grid layout: ${layout} (${cardCount} cards)`);
}
}
updateFormFieldsForScreen() {
const isMobile = window.innerWidth <= 768;
const formControls = document.querySelectorAll('.form-control');
formControls.forEach(control => {
if (isMobile) {
if (control.type !== 'checkbox' && control.type !== 'radio') {
control.classList.add('form-control--full');
control.classList.remove('form-control--sm', 'form-control--md', 'form-control--lg');
}
} else {
control.classList.remove('form-control--full');
if (control.type === 'number') {
control.classList.add('form-control--sm');
} else if (control.tagName === 'SELECT') {
if (control.dataset.option === 'language' || control.dataset.option === 'wordlist') {
control.classList.add('form-control--language');
} else {
control.classList.add('form-control--md');
}
}
}
});
}
async detectLanguage() {
const browserLang = (navigator.language || 'en').replace('-', '_');
const supportedLanguages = ['en', 'de', 'de_CH', 'fr', 'it', 'es', 'pt', 'ja', 'zh'];
if (supportedLanguages.includes(browserLang)) {
this.currentLang = browserLang;
} else if (supportedLanguages.includes(browserLang.substring(0, 2))) {
this.currentLang = browserLang.substring(0, 2);
} else {
this.currentLang = 'en';
}
console.log(`🌍 Language detected: ${this.currentLang}`);
}
async loadTranslations() {
try {
const response = await fetch(`/api/v1/locales/${this.currentLang}`);
if (response.ok) {
this.translations = await response.json();
console.log(`✅ Loaded translations for ${this.currentLang}`);
} else {
throw new Error(`Failed to load translations: ${response.status}`);
}
} catch (error) {
console.warn(`⚠️ Failed to load ${this.currentLang} translations:`, error.message);
this.translations = this.getFallbackTranslations();
}
}
getFallbackTranslations() {
return {
"app_title": "Password Generator",
"generate": "Generate",
"roulette": "Roulette",
"stop": "Stop",
"roulette_interval": "Interval (s)",
"copy": "Copy",
"copied_to_clipboard": "Copied to clipboard!",
"generating": "Generating...",
"count": "Count",
"loading_generators": "Loading password generators...",
"entropy": "Entropy",
"length": "Length",
"details": "Details",
"security_level": "Security level",
"very_strong": "Very strong",
"strong": "Strong",
"adequate": "Adequate",
"weak": "Weak",
"very_weak": "Very weak",
"error_loading_generators": "Failed to load generators"
};
}
translateSecurityLevel(level) {
const normalized = String(level ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z]+/g, '_')
.replace(/^_+|_+$/g, '');
const translationKeys = {
'very_strong': 'very_strong',
'strong': 'strong',
'adequate': 'adequate',
'weak': 'weak',
'very_weak': 'very_weak',
};
if (translationKeys[normalized]) {
return this.t(translationKeys[normalized]);
}
return level ? String(level) : this.t('security_level');
}
t(key, params = {}) {
if (!key || typeof key !== 'string') {
return '';
}
const candidates = [key];
if (key.includes('_choice_')) {
candidates.push(key.replace('_choice_', '_list_'));
}
if (key.includes('add_number')) {
candidates.push(key.replace('add_number', 'addNumber'));
}
if (key.includes('number_position')) {
candidates.push(key.replace('number_position', 'numberPosition'));
}
if (key.includes('_options_group_')) {
candidates.push('');
}
let translation = '';
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const value = this.translations[candidate];
if (value !== undefined && value !== null) {
translation = value;
break;
}
}
if (!translation && key.includes('_options_group_')) {
return '';
}
if (!translation) {
translation = key;
}
Object.keys(params).forEach(param => {
translation = String(translation).replace(`{{${param}}}`, params[param]);
});
return translation;
}
updateLanguageSelector() {
const languageSelect = document.getElementById('language-select');
if (!languageSelect) {
console.warn('⚠️ Language selector not found');
return;
}
languageSelect.innerHTML = '';
Object.entries(this.defaultLanguages).forEach(([code, name]) => {
const option = document.createElement('option');
option.value = code;
option.textContent = name;
option.selected = code === this.currentLang;
languageSelect.appendChild(option);
});
console.log('✅ Language selector updated');
}
setupEventListeners() {
// Language selector
const languageSelect = document.getElementById('language-select');
if (languageSelect) {
languageSelect.addEventListener('change', (e) => {
this.switchLanguage(e.target.value);
});
}
// Event delegation for dynamic content
document.addEventListener('click', (e) => {
// Generate button handler
const generateBtn = e.target.closest('.generate-btn');
if (generateBtn && !generateBtn.disabled) {
e.preventDefault();
e.stopPropagation();
const type = generateBtn.dataset.type;
if (type) {
this.generatePasswords(type);
}
return;
}
// Roulette button handler
const rouletteBtn = e.target.closest('.roulette-btn');
if (rouletteBtn && !rouletteBtn.disabled) {
e.preventDefault();
e.stopPropagation();
const type = rouletteBtn.dataset.type;
if (type) {
this.toggleRoulette(type);
}
return;
}
const intervalIncBtn = e.target.closest('.roulette-interval-inc-btn');
if (intervalIncBtn) {
e.preventDefault();
e.stopPropagation();
this.adjustRouletteInterval(intervalIncBtn, +1);
return;
}
const intervalDecBtn = e.target.closest('.roulette-interval-dec-btn');
if (intervalDecBtn) {
e.preventDefault();
e.stopPropagation();
this.adjustRouletteInterval(intervalDecBtn, -1);
return;
}
// Copy button handler
const copyBtn = e.target.closest('.copy-btn');
if (copyBtn) {
e.preventDefault();
e.stopPropagation();
const card = copyBtn.closest('.generator-card');
if (card?.dataset?.type) {
this.stopRoulette(card.dataset.type);
}
const text = copyBtn.dataset.text;
if (text) {
this.copyToClipboard(text, copyBtn);
}
return;
}
});
// Form changes
document.addEventListener('change', (e) => {
if (e.target.matches('input, select')) {
this.updateConditionalOptions(e.target);
}
});
console.log('✅ Event listeners set up');
}
async switchLanguage(lang) {
this.currentLang = lang;
await this.loadTranslations();
this.updateUI();
console.log(`🌍 Switched to language: ${lang}`);
}
updateUI() {
// Update translatable elements
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.dataset.i18n;
element.textContent = this.t(key);
});
// Update placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.dataset.i18nPlaceholder;
element.placeholder = this.t(key);
});
// Update titles
document.querySelectorAll('[data-i18n-title]').forEach(element => {
const key = element.dataset.i18nTitle;
element.title = this.t(key);
});
// Update page title
document.title = this.t('app_title');
// Re-render generator cards with new translations
this.renderGeneratorCards();
}
updateConditionalOptions(changedElement) {
const card = changedElement.closest('.generator-card');
if (!card) return;
// Generic conditional option handling based on data attributes
const optionName = changedElement.dataset.option;
if (!optionName) return;
// Find all elements that depend on this option
const dependentElements = card.querySelectorAll(`[data-depends-on="${optionName}"]`);
dependentElements.forEach(dependent => {
const showWhen = dependent.dataset.showWhen;
let shouldShow = false;
if (changedElement.type === 'checkbox') {
shouldShow = showWhen === 'checked' ? changedElement.checked : !changedElement.checked;
} else if (changedElement.tagName === 'SELECT' || changedElement.type === 'text') {
const value = changedElement.value;
if (showWhen) {
shouldShow = showWhen.split(',').includes(value);
} else {
shouldShow = value.trim().length > 0;
}
}
dependent.style.display = shouldShow ? 'block' : 'none';
if (shouldShow) {
dependent.classList.add('option-visible');
} else {
dependent.classList.remove('option-visible');
}
});
}
/**
* ✅ NEW: Render generator cards from API definitions
*/
renderGeneratorCards() {
const container = document.getElementById('generators-container');
if (!container) {
console.error('❌ Container not found');
return;
}
container.innerHTML = '';
if (!this.generatorDefinitions || this.generatorDefinitions.length === 0) {
container.innerHTML = `<div class="error-message">${this.t('error_loading_generators')}</div>`;
return;
}
console.log(`🎨 Rendering ${this.generatorDefinitions.length} generator cards...`);
this.generatorDefinitions.forEach(generator => {
const card = this.createGeneratorCard(generator);
container.appendChild(card);
});
console.log('✅ Generator cards rendered');
}
/**
* ✅ NEW: Create generator card from API definition
*/
createGeneratorCard(generator) {
const card = document.createElement('div');
card.className = 'generator-card';
card.dataset.type = generator.id;
// Header
const header = document.createElement('div');
header.className = 'generator-header';
const title = document.createElement('h3');
title.className = 'generator-title';
const translatedTitle = this.t(generator.nameKey);
title.textContent = translatedTitle && translatedTitle !== generator.nameKey
? translatedTitle
: (generator.name || generator.id || 'Generator');
header.appendChild(title);
const description = document.createElement('p');
description.className = 'generator-description';
const translatedDescription = this.t(generator.descriptionKey);
description.textContent = translatedDescription && translatedDescription !== generator.descriptionKey
? translatedDescription
: (generator.description || '');
header.appendChild(description);
card.appendChild(header);
// Options form
const optionsContainer = document.createElement('div');
optionsContainer.className = 'generator-options';
// Group options by group
const groupedOptions = this.groupOptions(generator.options);
Object.entries(groupedOptions).forEach(([groupName, options]) => {
const groupDiv = document.createElement('div');
groupDiv.className = 'option-group';
groupDiv.dataset.group = groupName;
// Group title
const groupTitle = document.createElement('h4');
groupTitle.className = 'option-group-title';
if (options[0].groupTitleKey) {
const groupTitleKey = options[0].groupTitleKey;
const translatedGroupTitle = this.t(groupTitleKey);
groupTitle.textContent = translatedGroupTitle && translatedGroupTitle !== groupTitleKey
? translatedGroupTitle
: '';
} else {
groupTitle.textContent = '';
}
const resolvedGroupTitle = this.getGroupDisplayTitle(groupName, groupTitle.textContent);
if (this.shouldCollapseGroup(groupName)) {
const collapsible = document.createElement('details');
collapsible.className = 'option-group-collapsible';
const summary = document.createElement('summary');
summary.className = 'option-group-summary';
summary.textContent = resolvedGroupTitle;
collapsible.appendChild(summary);
options.forEach(option => {
const optionElement = this.renderOption(generator.id, option);
collapsible.appendChild(optionElement);
});
groupDiv.appendChild(collapsible);
optionsContainer.appendChild(groupDiv);
return;
}
groupTitle.textContent = resolvedGroupTitle;
groupDiv.appendChild(groupTitle);
// Render each option in group
options.forEach(option => {
const optionElement = this.renderOption(generator.id, option);
groupDiv.appendChild(optionElement);
});
optionsContainer.appendChild(groupDiv);
});
card.appendChild(optionsContainer);
// Generate button and results
const actionsDiv = document.createElement('div');
actionsDiv.className = 'generator-actions';
const generateBtn = document.createElement('button');
generateBtn.className = 'generate-btn';
generateBtn.dataset.type = generator.id;
generateBtn.textContent = this.t('generate');
actionsDiv.appendChild(generateBtn);
const rouletteBtn = document.createElement('button');
rouletteBtn.className = 'roulette-btn';
rouletteBtn.dataset.type = generator.id;
rouletteBtn.textContent = this.t('roulette');
actionsDiv.appendChild(rouletteBtn);
card.appendChild(actionsDiv);
const rouletteStatusRow = document.createElement('div');
rouletteStatusRow.className = 'roulette-status-row';
rouletteStatusRow.dataset.rouletteIntervalSeconds = String(this.defaultRouletteIntervalSeconds);
const rouletteProgressSlot = document.createElement('div');
rouletteProgressSlot.className = 'roulette-progress-slot';
rouletteProgressSlot.setAttribute('aria-hidden', 'true');
const rouletteProgressBar = document.createElement('div');
rouletteProgressBar.className = 'roulette-progress-bar';
rouletteProgressSlot.appendChild(rouletteProgressBar);
rouletteStatusRow.appendChild(rouletteProgressSlot);
const rouletteSpeedControls = document.createElement('div');
rouletteSpeedControls.className = 'roulette-speed-controls';
const incBtn = document.createElement('button');
incBtn.type = 'button';
incBtn.className = 'roulette-speed-btn roulette-interval-inc-btn';
incBtn.dataset.type = generator.id;
incBtn.textContent = '🐌';
incBtn.title = this.t('roulette_slow_down');
rouletteSpeedControls.appendChild(incBtn);
const decBtn = document.createElement('button');
decBtn.type = 'button';
decBtn.className = 'roulette-speed-btn roulette-interval-dec-btn';
decBtn.dataset.type = generator.id;
decBtn.textContent = '🐇';
decBtn.title = this.t('roulette_speed_up');
rouletteSpeedControls.appendChild(decBtn);
rouletteStatusRow.appendChild(rouletteSpeedControls);
card.appendChild(rouletteStatusRow);
// Results container
const resultsDiv = document.createElement('div');
resultsDiv.className = 'password-results';
resultsDiv.id = `${generator.id}-results`;
card.appendChild(resultsDiv);
return card;
}
/**
* ✅ NEW: Group options by their group property
*/
groupOptions(options) {
const grouped = {};
options.forEach(option => {
const group = option.group || 'basic';
if (!grouped[group]) {
grouped[group] = [];
}
grouped[group].push(option);
});
return grouped;
}
shouldCollapseGroup(groupName) {
const collapsibleGroups = ['advanced', 'ai', 'expert'];
return collapsibleGroups.includes((groupName || '').toLowerCase());
}
getGroupDisplayTitle(groupName, fallbackTitle) {
if (fallbackTitle && fallbackTitle.trim().length > 0) {
return fallbackTitle;
}
const groupTitleKeys = {
advanced: 'collapse_group_advanced',
ai: 'collapse_group_ai',
expert: 'collapse_group_expert',
basic: 'collapse_group_basic',
character_set: 'collapse_group_character_set'
};
const titleKey = groupTitleKeys[groupName];
if (titleKey) {
const translated = this.t(titleKey);
if (translated && translated !== titleKey) {
return translated;
}
}
return String(groupName || 'Options')
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* ✅ NEW: Render individual option based on type
*/
renderOption(generatorId, option) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group';
wrapper.dataset.optionName = option.name;
// Handle special case: count option should be prominent
if (option.name === 'count') {
wrapper.classList.add('form-group--count');
}
let input;
switch (option.type) {
case 'integer':
input = this.createIntegerInput(option);
break;
case 'boolean':
input = this.createBooleanInput(option);
break;
case 'string':
input = this.createStringInput(option);
break;
case 'select':
input = this.createSelectInput(option);
break;
default:
console.warn(`Unknown option type: ${option.type}`);
return wrapper;
}
input.id = `${generatorId}-${option.name}`;
input.dataset.option = option.name;
// For boolean inputs, wrap differently
if (option.type === 'boolean') {
const checkboxWrapper = document.createElement('div');
checkboxWrapper.className = 'checkbox-wrapper';
checkboxWrapper.appendChild(input);
const checkboxContent = document.createElement('div');
checkboxContent.className = 'checkbox-content';
const label = document.createElement('label');
label.htmlFor = input.id;
const translatedLabel = this.t(option.labelKey);
label.textContent = translatedLabel && translatedLabel !== option.labelKey
? translatedLabel
: (option.label || option.name || 'Option');
checkboxContent.appendChild(label);
if (option.descriptionKey) {
const help = document.createElement('small');
help.className = 'form-text text-muted';
const translatedHelp = this.t(option.descriptionKey);
help.textContent = translatedHelp && translatedHelp !== option.descriptionKey
? translatedHelp
: (option.description || '');
checkboxContent.appendChild(help);
}
checkboxWrapper.appendChild(checkboxContent);
wrapper.appendChild(checkboxWrapper);
} else {
// Label first for non-checkbox inputs
const label = document.createElement('label');
label.htmlFor = input.id;
const translatedLabel = this.t(option.labelKey);
label.textContent = translatedLabel && translatedLabel !== option.labelKey
? translatedLabel
: (option.label || option.name || 'Option');
wrapper.appendChild(label);
wrapper.appendChild(input);
}
// Description/help text
if (option.descriptionKey && option.type !== 'boolean') {
const help = document.createElement('small');
help.className = 'form-text text-muted';
const translatedHelp = this.t(option.descriptionKey);
help.textContent = translatedHelp && translatedHelp !== option.descriptionKey
? translatedHelp
: (option.description || '');
wrapper.appendChild(help);
}
return wrapper;
}
createIntegerInput(option) {
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control--sm';
input.value = option.default;
if ('min' in option) input.min = option.min;
if ('max' in option) input.max = option.max;
if (option.required) input.required = true;
return input;
}
createBooleanInput(option) {
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'form-check-input';
input.checked = !!option.default;
return input;
}
createStringInput(option) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control--md';
input.value = option.default || '';
if ('maxLength' in option) input.maxLength = option.maxLength;
if ('minLength' in option) input.minLength = option.minLength;
if (option.required) input.required = true;
return input;
}
createSelectInput(option) {
const select = document.createElement('select');
select.className = 'form-control form-control--md';
if (!option.choices || option.choices.length === 0) {
console.warn(`No choices for select option: ${option.name}`);
return select;
}
option.choices.forEach(choice => {
const opt = document.createElement('option');
opt.value = choice.value;
// ✅ Check if choices should be translated
if (option.translateChoices === false || !choice.labelKey) {
// Wordlists and other non-translated choices
opt.textContent = choice.label || choice.value;
} else {
// Translated choices
const translatedChoice = this.t(choice.labelKey);
opt.textContent = translatedChoice && translatedChoice !== choice.labelKey
? translatedChoice
: (choice.label || choice.value);
}
opt.selected = choice.value === option.default;
select.appendChild(opt);
});
return select;
}
/**
* Collect form options for password generation
*/
collectFormOptions(card, type) {
const options = {};
// Get all form inputs in this card
const inputs = card.querySelectorAll('input, select');
inputs.forEach(input => {
const optionName = input.dataset.option;
if (!optionName) return;
switch (input.type) {
case 'checkbox':
options[optionName] = input.checked;
break;
case 'number':
case 'range':
options[optionName] = parseInt(input.value) || 0;
break;
case 'text':
case 'select-one':
options[optionName] = input.value;
break;
}
});
return options;
}
async generatePasswords(type) {
console.log(`🎯 Generating passwords for type: ${type}`);
const card = document.querySelector(`[data-type="${type}"]`);
if (!card) {
console.error('Generator card not found:', type);
return;
}
const generateBtn = card.querySelector('.generate-btn');
if (!generateBtn) {
console.error('Generate button not found');
return;
}
// Disable button and show loading state
generateBtn.disabled = true;
const originalText = generateBtn.textContent;
generateBtn.textContent = this.t('generating');
try {
// Collect options
const options = this.collectFormOptions(card, type);
// Extract count separately
const countInput = card.querySelector(`[data-option="count"]`);
const count = countInput ? parseInt(countInput.value) || 1 : 1;
console.log(`📝 Options for ${type}:`, options);
console.log(`📝 Count: ${count}`);
// Make API request
const response = await fetch('/api/v1/passwords/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: type,
count: count,
options: options
})
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
if (data.success && data.data.passwords) {
this.displayPasswords(card, data.data);
} else {
throw new Error(data.message || 'Unknown error');
}
} catch (error) {
console.error('❌ Password generation failed:', error);
this.showError('Failed to generate passwords: ' + error.message);
} finally {
// Re-enable button
generateBtn.disabled = false;
generateBtn.textContent = originalText;
}
}
async toggleRoulette(type) {
if (this.rouletteRuns.has(type)) {
this.stopRoulette(type);
return;
}
await this.startRoulette(type);
}
async startRoulette(type) {
const card = document.querySelector(`[data-type="${type}"]`);
if (!card) {
return;
}
const run = {
active: true,
iterations: 0,
maxIterations: 12,
};
this.rouletteRuns.set(type, run);
this.updateRouletteButtonState(card, true);
try {
while (run.active && run.iterations < run.maxIterations) {
run.iterations += 1;
await this.generatePasswords(type);
if (!run.active || run.iterations >= run.maxIterations) {
break;
}
const intervalMs = this.getRouletteIntervalMs(card);
const completed = await this.runRouletteCountdown(type, intervalMs);
if (!completed) {
break;
}
}
} finally {
this.stopRoulette(type);
}
}
stopRoulette(type) {
const run = this.rouletteRuns.get(type);
if (run) {
run.active = false;
if (run.countdownFrame) {
cancelAnimationFrame(run.countdownFrame);
}
this.rouletteRuns.delete(type);
}
const card = document.querySelector(`[data-type="${type}"]`);
if (card) {
this.updateRouletteButtonState(card, false);
this.setRouletteProgress(card, 0, false);
}
}
updateRouletteButtonState(card, isRunning) {
const rouletteBtn = card.querySelector('.roulette-btn');
const statusRow = card.querySelector('.roulette-status-row');
if (!rouletteBtn) {
return;
}
rouletteBtn.classList.toggle('is-running', isRunning);
rouletteBtn.textContent = isRunning ? this.t('stop') : this.t('roulette');
if (statusRow) {
statusRow.classList.toggle('is-running', isRunning);
}
}
getRouletteIntervalMs(card) {
const statusRow = card.querySelector('.roulette-status-row');
const seconds = statusRow
? parseInt(statusRow.dataset.rouletteIntervalSeconds || '3', 10)
: 3;
const safeSeconds = Number.isFinite(seconds)
? Math.min(30, Math.max(1, seconds))
: 3;
if (statusRow) {
statusRow.dataset.rouletteIntervalSeconds = String(safeSeconds);
}
return safeSeconds * 1000;
}
adjustRouletteInterval(button, deltaSeconds) {
const card = button.closest('.generator-card');
const statusRow = card?.querySelector('.roulette-status-row');
if (!statusRow) {
return;
}
const current = parseInt(statusRow.dataset.rouletteIntervalSeconds || '3', 10) || 3;
const next = Math.min(30, Math.max(1, current + deltaSeconds));
statusRow.dataset.rouletteIntervalSeconds = String(next);
}
setRouletteProgress(card, progress, active) {
const slot = card.querySelector('.roulette-progress-slot');
const bar = card.querySelector('.roulette-progress-bar');
if (!slot || !bar) {
return;
}
const normalized = Math.max(0, Math.min(1, progress));
bar.style.width = `${normalized * 100}%`;
slot.classList.toggle('is-active', !!active);
}
async runRouletteCountdown(type, durationMs) {
const run = this.rouletteRuns.get(type);
const card = document.querySelector(`[data-type="${type}"]`);
if (!run || !card) {
return false;
}
this.setRouletteProgress(card, 1, true);
return await new Promise((resolve) => {
const startTime = performance.now();
const step = (now) => {
const currentRun = this.rouletteRuns.get(type);
if (!currentRun || !currentRun.active) {
resolve(false);
return;
}
const elapsed = now - startTime;
const remainingRatio = 1 - (elapsed / durationMs);
this.setRouletteProgress(card, remainingRatio, true);
if (elapsed >= durationMs) {
resolve(true);
return;
}
currentRun.countdownFrame = requestAnimationFrame(step);
};
run.countdownFrame = requestAnimationFrame(step);
});
}
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
displayPasswords(card, payload) {
const resultsDiv = card.querySelector('.password-results');
if (!resultsDiv) return;
resultsDiv.innerHTML = '';
const normalizedPayload = Array.isArray(payload)
? { passwords: payload, explanations: [] }
: (payload || { passwords: [], explanations: [] });
const passwords = Array.isArray(normalizedPayload.passwords)
? normalizedPayload.passwords
: [];
const explanations = Array.isArray(normalizedPayload.explanations)
? normalizedPayload.explanations
: [];
const securityAssessment = typeof normalizedPayload.security_assessment === 'string'
? normalizedPayload.security_assessment
: '';
const rawTechnicalDetails = normalizedPayload.raw_technical_details || normalizedPayload.security_info || null;
passwords.forEach((passwordData, index) => {
const passwordValue = typeof passwordData === 'string'
? passwordData
: (passwordData?.password || '');
if (!passwordValue) {
return;
}
const resultItem = document.createElement('div');
resultItem.className = 'password-result-item';
const resultContent = document.createElement('div');
resultContent.className = 'password-result-content';
const passwordText = document.createElement('div');
passwordText.className = 'password-text';
passwordText.textContent = passwordValue;
resultContent.appendChild(passwordText);
const securityRow = document.createElement('div');
securityRow.className = 'password-security-row';
const securityLevel = rawTechnicalDetails?.security_level || '';
const localizedSecurityLevel = this.translateSecurityLevel(securityLevel);
if (securityLevel || securityAssessment.trim().length > 0) {
const assessmentBadge = document.createElement('div');
assessmentBadge.className = 'password-security-badge';
assessmentBadge.textContent = localizedSecurityLevel || securityAssessment.trim();
securityRow.appendChild(assessmentBadge);
}
if (rawTechnicalDetails && typeof rawTechnicalDetails === 'object') {
const entropyValue = rawTechnicalDetails.entropy_bits ?? normalizedPayload.entropy_bits;
const detailRows = [];
if (typeof entropyValue !== 'undefined') {
detailRows.push({
label: this.t('entropy'),
value: `${Number(entropyValue).toFixed(1)} bits`
});
}
if (rawTechnicalDetails.length) {
detailRows.push({
label: this.t('length'),
value: `${rawTechnicalDetails.length} ${this.t('chars') || 'chars'}`
});
}
if (rawTechnicalDetails.security_level) {
detailRows.push({
label: this.t('security_level'),
value: this.translateSecurityLevel(rawTechnicalDetails.security_level)
});
}
if (detailRows.length > 0) {
const technicalToggle = document.createElement('details');
technicalToggle.className = 'password-technical-toggle';
const summary = document.createElement('summary');
summary.className = 'password-technical-summary';
summary.textContent = this.t('details');
const technicalBlock = document.createElement('div');
technicalBlock.className = 'password-technical-details';
detailRows.forEach((detail) => {
const rowEl = document.createElement('div');
rowEl.className = 'password-technical-row';
const labelEl = document.createElement('span');
labelEl.className = 'password-technical-label';
labelEl.textContent = detail.label;
const valueEl = document.createElement('span');
valueEl.className = 'password-technical-value';
valueEl.textContent = detail.value;
rowEl.appendChild(labelEl);
rowEl.appendChild(valueEl);
technicalBlock.appendChild(rowEl);
});
technicalToggle.appendChild(summary);
technicalToggle.appendChild(technicalBlock);
securityRow.appendChild(technicalToggle);
}
}
if (securityRow.children.length > 0) {
resultContent.appendChild(securityRow);
}
const explanation = explanations[index];
if (typeof explanation === 'string' && explanation.trim().length > 0) {
const explanationText = document.createElement('div');
explanationText.className = 'password-explanation';
explanationText.textContent = explanation.trim();
resultContent.appendChild(explanationText);
}
resultItem.appendChild(resultContent);
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.dataset.text = passwordValue;
copyBtn.textContent = this.t('copy');
resultItem.appendChild(copyBtn);
resultsDiv.appendChild(resultItem);
});
}
async copyToClipboard(text, button = null) {
try {
await navigator.clipboard.writeText(text);
if (button) {
const originalText = button.dataset.originalText || button.textContent || this.t('copy');
button.dataset.originalText = originalText;
button.textContent = this.t('copied');
button.classList.add('success');
if (button._copyFeedbackTimeout) {
clearTimeout(button._copyFeedbackTimeout);
}
button._copyFeedbackTimeout = setTimeout(() => {
button.textContent = originalText;
button.classList.remove('success');
button._copyFeedbackTimeout = null;
}, 1400);
}
this.showSuccess(this.t('copied_to_clipboard'));
} catch (error) {
console.error('❌ Failed to copy:', error);
this.showError('Failed to copy to clipboard');
}
}
showSuccess(message) {
console.log('✅', message);
// TODO: Implement toast notification
}
showError(message) {
console.error('❌', message);
// TODO: Implement toast notification
}
}
// Initialize app when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
new PasswordGeneratorApp();
});
} else {
new PasswordGeneratorApp();
}