From cbfb9ac87c76e184e65ad9d32c3853ec4f13bed5 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 10 Apr 2025 22:25:40 +0800 Subject: [PATCH] Enhance CheckpointModal: Implement detailed checkpoint display, editable fields, and showcase functionality --- static/js/components/CheckpointModal.js | 1016 +++++++++++++++++++++-- static/js/managers/ModalManager.js | 13 + templates/components/lora_modals.html | 5 +- 3 files changed, 960 insertions(+), 74 deletions(-) diff --git a/static/js/components/CheckpointModal.js b/static/js/components/CheckpointModal.js index 54dd2d33..160b274d 100644 --- a/static/js/components/CheckpointModal.js +++ b/static/js/components/CheckpointModal.js @@ -1,9 +1,9 @@ import { showToast } from '../utils/uiHelpers.js'; -import { modalManager } from '../managers/ModalManager.js'; +import { BASE_MODELS } from '../utils/constants.js'; /** * CheckpointModal - Component for displaying checkpoint details - * This is a basic implementation that can be expanded in the future + * Similar to LoraModal but customized for checkpoint models */ export class CheckpointModal { constructor() { @@ -33,76 +33,6 @@ export class CheckpointModal { }); } - /** - * Show checkpoint details in the modal - * @param {Object} checkpoint - Checkpoint data - */ - showCheckpointDetails(checkpoint) { - if (!this.modal || !this.modalContent) { - console.error('Checkpoint modal elements not found'); - return; - } - - this.currentCheckpoint = checkpoint; - - // Set modal title - if (this.modalTitle) { - this.modalTitle.textContent = checkpoint.model_name || 'Checkpoint Details'; - } - - // This is a basic implementation that can be expanded with more details - // For now, just display some basic information - this.modalContent.innerHTML = ` -
-
- ${checkpoint.model_name} -
-
-

${checkpoint.model_name}

-
-
- File Name: - ${checkpoint.file_name} -
-
- Location: - ${checkpoint.folder} -
-
- Base Model: - ${checkpoint.base_model || 'Unknown'} -
-
- File Size: - ${this._formatFileSize(checkpoint.file_size)} -
-
- SHA256: - ${checkpoint.sha256 || 'Unknown'} -
-
-
-
-
-

Detailed checkpoint information will be implemented in a future update.

-
- `; - - // Show the modal - this.modal.style.display = 'block'; - } - - /** - * Close the modal - */ - close() { - if (this.modal) { - this.modal.style.display = 'none'; - this.currentCheckpoint = null; - } - } - /** * Format file size for display * @param {number} bytes - File size in bytes @@ -117,4 +47,944 @@ export class CheckpointModal { if (i === 0) return `${bytes} ${sizes[i]}`; return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`; } -} \ No newline at end of file + + /** + * Render compact tags for the checkpoint + * @param {Array} tags - Array of tags + * @returns {string} - HTML for tags + */ + _renderCompactTags(tags) { + if (!tags || tags.length === 0) return ''; + + // Display up to 5 tags, with a count if there are more + const visibleTags = tags.slice(0, 5); + const remainingCount = Math.max(0, tags.length - 5); + + return ` +
+
+ ${visibleTags.map(tag => `${tag}`).join('')} + ${remainingCount > 0 ? + `+${remainingCount}` : + ''} +
+ ${tags.length > 0 ? + `
+
+ ${tags.map(tag => `${tag}`).join('')} +
+
` : + ''} +
+ `; + } + + /** + * Set up tag tooltip functionality + */ + _setupTagTooltip() { + const tagsContainer = document.querySelector('.model-tags-container'); + const tooltip = document.querySelector('.model-tags-tooltip'); + + if (tagsContainer && tooltip) { + tagsContainer.addEventListener('mouseenter', () => { + tooltip.classList.add('visible'); + }); + + tagsContainer.addEventListener('mouseleave', () => { + tooltip.classList.remove('visible'); + }); + } + } + + /** + * Render showcase content (example images) + * @param {Array} images - Array of image data + * @returns {string} - HTML content + */ + _renderShowcaseContent(images) { + if (!images?.length) return '
No example images available
'; + + return ` +
+ + Scroll or click to show ${images.length} examples +
+ + `; + } + + /** + * Show checkpoint details in the modal + * @param {Object} checkpoint - Checkpoint data + */ + showCheckpointDetails(checkpoint) { + if (!this.modal) { + console.error('Checkpoint modal element not found'); + return; + } + + this.currentCheckpoint = checkpoint; + + const content = ` + + `; + + this.modal.innerHTML = content; + this.modal.style.display = 'block'; + + this._setupEditableFields(); + this._setupShowcaseScroll(); + this._setupTabSwitching(); + this._setupTagTooltip(); + this._setupModelNameEditing(); + this._setupBaseModelEditing(); + this._setupFileNameEditing(); + + // If we have a model ID but no description, fetch it + if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) { + this._loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path); + } + } + + /** + * Close the checkpoint modal + */ + close() { + if (this.modal) { + this.modal.style.display = 'none'; + this.currentCheckpoint = null; + } + } + + /** + * Set up editable fields in the modal + */ + _setupEditableFields() { + const editableFields = this.modal.querySelectorAll('.editable-field [contenteditable]'); + + editableFields.forEach(field => { + field.addEventListener('focus', function() { + if (this.textContent === 'Add your notes here...') { + this.textContent = ''; + } + }); + + field.addEventListener('blur', function() { + if (this.textContent.trim() === '') { + if (this.classList.contains('notes-content')) { + this.textContent = 'Add your notes here...'; + } + } + }); + }); + + // Add keydown event listeners for notes + const notesContent = this.modal.querySelector('.notes-content'); + if (notesContent) { + notesContent.addEventListener('keydown', async (e) => { + if (e.key === 'Enter') { + if (e.shiftKey) { + // Allow shift+enter for new line + return; + } + e.preventDefault(); + const filePath = this.modal.querySelector('.file-path').textContent + + this.modal.querySelector('#file-name').textContent; + await this._saveNotes(filePath); + } + }); + } + } + + /** + * Save notes for the checkpoint + * @param {string} filePath - Path to the checkpoint file + */ + async _saveNotes(filePath) { + const content = this.modal.querySelector('.notes-content').textContent; + try { + // This would typically call an API endpoint to save the notes + // For now we'll just show a success message + console.log('Would save notes:', content, 'for file:', filePath); + + showToast('Notes saved successfully', 'success'); + } catch (error) { + showToast('Failed to save notes', 'error'); + } + } + + /** + * Set up model name editing functionality + */ + _setupModelNameEditing() { + const modelNameContent = this.modal.querySelector('.model-name-content'); + const editBtn = this.modal.querySelector('.edit-model-name-btn'); + + if (!modelNameContent || !editBtn) return; + + // Show edit button on hover + const modelNameHeader = this.modal.querySelector('.model-name-header'); + modelNameHeader.addEventListener('mouseenter', () => { + editBtn.classList.add('visible'); + }); + + modelNameHeader.addEventListener('mouseleave', () => { + if (!modelNameContent.getAttribute('data-editing')) { + editBtn.classList.remove('visible'); + } + }); + + // Handle edit button click + editBtn.addEventListener('click', () => { + modelNameContent.setAttribute('data-editing', 'true'); + modelNameContent.focus(); + + // Place cursor at the end + const range = document.createRange(); + const sel = window.getSelection(); + if (modelNameContent.childNodes.length > 0) { + range.setStart(modelNameContent.childNodes[0], modelNameContent.textContent.length); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + + editBtn.classList.add('visible'); + }); + + // Handle focus out + modelNameContent.addEventListener('blur', function() { + this.removeAttribute('data-editing'); + editBtn.classList.remove('visible'); + + if (this.textContent.trim() === '') { + // Restore original model name if empty + this.textContent = 'Checkpoint Details'; + } + }); + + // Handle enter key + modelNameContent.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + modelNameContent.blur(); + // Save model name here (would call an API endpoint) + showToast('Model name updated', 'success'); + } + }); + + // Limit model name length + modelNameContent.addEventListener('input', function() { + if (this.textContent.length > 100) { + this.textContent = this.textContent.substring(0, 100); + // Place cursor at the end + const range = document.createRange(); + const sel = window.getSelection(); + range.setStart(this.childNodes[0], 100); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + + showToast('Model name is limited to 100 characters', 'warning'); + } + }); + } + + /** + * Set up base model editing functionality + */ + _setupBaseModelEditing() { + const baseModelContent = this.modal.querySelector('.base-model-content'); + const editBtn = this.modal.querySelector('.edit-base-model-btn'); + + if (!baseModelContent || !editBtn) return; + + // Show edit button on hover + const baseModelDisplay = this.modal.querySelector('.base-model-display'); + baseModelDisplay.addEventListener('mouseenter', () => { + editBtn.classList.add('visible'); + }); + + baseModelDisplay.addEventListener('mouseleave', () => { + if (!baseModelDisplay.classList.contains('editing')) { + editBtn.classList.remove('visible'); + } + }); + + // Handle edit button click + editBtn.addEventListener('click', () => { + baseModelDisplay.classList.add('editing'); + + // Store the original value to check for changes later + const originalValue = baseModelContent.textContent.trim(); + + // Create dropdown selector to replace the base model content + const currentValue = originalValue; + const dropdown = document.createElement('select'); + dropdown.className = 'base-model-selector'; + + // Flag to track if a change was made + let valueChanged = false; + + // Add options from BASE_MODELS constants + const baseModelCategories = { + 'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER], + 'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1], + 'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO], + 'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER], + 'Video Models': [BASE_MODELS.SVD, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO], + 'Other Models': [ + BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW, + BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1, + BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, + BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.UNKNOWN + ] + }; + + // Create option groups for better organization + Object.entries(baseModelCategories).forEach(([category, models]) => { + const group = document.createElement('optgroup'); + group.label = category; + + models.forEach(model => { + const option = document.createElement('option'); + option.value = model; + option.textContent = model; + option.selected = model === currentValue; + group.appendChild(option); + }); + + dropdown.appendChild(group); + }); + + // Replace content with dropdown + baseModelContent.style.display = 'none'; + baseModelDisplay.insertBefore(dropdown, editBtn); + + // Hide edit button during editing + editBtn.style.display = 'none'; + + // Focus the dropdown + dropdown.focus(); + + // Handle dropdown change + dropdown.addEventListener('change', function() { + const selectedModel = this.value; + baseModelContent.textContent = selectedModel; + + // Mark that a change was made if the value differs from original + if (selectedModel !== originalValue) { + valueChanged = true; + } else { + valueChanged = false; + } + }); + + // Function to save changes and exit edit mode + const saveAndExit = function() { + // Check if dropdown still exists and remove it + if (dropdown && dropdown.parentNode === baseModelDisplay) { + baseModelDisplay.removeChild(dropdown); + } + + // Show the content and edit button + baseModelContent.style.display = ''; + editBtn.style.display = ''; + + // Remove editing class + baseModelDisplay.classList.remove('editing'); + + // Only save if the value has actually changed + if (valueChanged || baseModelContent.textContent.trim() !== originalValue) { + // Get file path for saving + const filePath = document.querySelector('#checkpointModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#checkpointModal .modal-content') + .querySelector('#file-name').textContent + '.safetensors'; + + // Save the changes (would call API to save model base change) + showToast('Base model updated successfully', 'success'); + } + + // Remove this event listener + document.removeEventListener('click', outsideClickHandler); + }; + + // Handle outside clicks to save and exit + const outsideClickHandler = function(e) { + // If click is outside the dropdown and base model display + if (!baseModelDisplay.contains(e.target)) { + saveAndExit(); + } + }; + + // Add delayed event listener for outside clicks + setTimeout(() => { + document.addEventListener('click', outsideClickHandler); + }, 0); + + // Also handle dropdown blur event + dropdown.addEventListener('blur', function(e) { + // Only save if the related target is not the edit button or inside the baseModelDisplay + if (!baseModelDisplay.contains(e.relatedTarget)) { + saveAndExit(); + } + }); + }); + } + + /** + * Set up file name editing functionality + */ + _setupFileNameEditing() { + const fileNameContent = this.modal.querySelector('.file-name-content'); + const editBtn = this.modal.querySelector('.edit-file-name-btn'); + + if (!fileNameContent || !editBtn) return; + + // Show edit button on hover + const fileNameWrapper = this.modal.querySelector('.file-name-wrapper'); + fileNameWrapper.addEventListener('mouseenter', () => { + editBtn.classList.add('visible'); + }); + + fileNameWrapper.addEventListener('mouseleave', () => { + if (!fileNameWrapper.classList.contains('editing')) { + editBtn.classList.remove('visible'); + } + }); + + // Handle edit button click + editBtn.addEventListener('click', () => { + fileNameWrapper.classList.add('editing'); + fileNameContent.setAttribute('contenteditable', 'true'); + fileNameContent.focus(); + + // Store original value + fileNameContent.dataset.originalValue = fileNameContent.textContent.trim(); + + // Place cursor at the end + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(fileNameContent); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + + editBtn.classList.add('visible'); + }); + + // Handle keyboard events + fileNameContent.addEventListener('keydown', function(e) { + if (!this.getAttribute('contenteditable')) return; + + if (e.key === 'Enter') { + e.preventDefault(); + this.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + // Restore original value + this.textContent = this.dataset.originalValue; + exitEditMode(); + } + }); + + // Handle input validation + fileNameContent.addEventListener('input', function() { + if (!this.getAttribute('contenteditable')) return; + + // Replace invalid characters for filenames + const invalidChars = /[\\/:*?"<>|]/g; + if (invalidChars.test(this.textContent)) { + const cursorPos = window.getSelection().getRangeAt(0).startOffset; + this.textContent = this.textContent.replace(invalidChars, ''); + + // Restore cursor position + const range = document.createRange(); + const sel = window.getSelection(); + const newPos = Math.min(cursorPos, this.textContent.length); + + if (this.firstChild) { + range.setStart(this.firstChild, newPos); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + + showToast('Invalid characters removed from filename', 'warning'); + } + }); + + // Handle focus out - save changes + fileNameContent.addEventListener('blur', function() { + if (!this.getAttribute('contenteditable')) return; + + const newFileName = this.textContent.trim(); + const originalValue = this.dataset.originalValue; + + // Validation + if (!newFileName) { + this.textContent = originalValue; + showToast('File name cannot be empty', 'error'); + exitEditMode(); + return; + } + + if (newFileName !== originalValue) { + // Would call API to rename file + showToast(`File would be renamed to: ${newFileName}`, 'success'); + } + + exitEditMode(); + }); + + function exitEditMode() { + fileNameContent.removeAttribute('contenteditable'); + fileNameWrapper.classList.remove('editing'); + editBtn.classList.remove('visible'); + } + } + + /** + * Set up showcase scroll functionality + */ + _setupShowcaseScroll() { + // Initialize scroll listeners for showcase section + const showcaseSection = this.modal.querySelector('.showcase-section'); + if (!showcaseSection) return; + + // Set up back-to-top button + const backToTopBtn = showcaseSection.querySelector('.back-to-top'); + const modalContent = this.modal.querySelector('.modal-content'); + + if (backToTopBtn && modalContent) { + modalContent.addEventListener('scroll', () => { + if (modalContent.scrollTop > 300) { + backToTopBtn.classList.add('visible'); + } else { + backToTopBtn.classList.remove('visible'); + } + }); + } + + // Set up scroll to toggle showcase + document.addEventListener('wheel', (event) => { + if (this.modal.style.display !== 'block') return; + + const showcase = this.modal.querySelector('.showcase-section'); + if (!showcase) return; + + const carousel = showcase.querySelector('.carousel'); + const scrollIndicator = showcase.querySelector('.scroll-indicator'); + + if (carousel?.classList.contains('collapsed') && event.deltaY > 0) { + const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100; + + if (isNearBottom) { + this._toggleShowcase(scrollIndicator); + event.preventDefault(); + } + } + }, { passive: false }); + } + + /** + * Toggle showcase expansion + * @param {HTMLElement} element - The scroll indicator element + */ + _toggleShowcase(element) { + const carousel = element.nextElementSibling; + const isCollapsed = carousel.classList.contains('collapsed'); + const indicator = element.querySelector('span'); + const icon = element.querySelector('i'); + + carousel.classList.toggle('collapsed'); + + if (isCollapsed) { + const count = carousel.querySelectorAll('.media-wrapper').length; + indicator.textContent = `Scroll or click to hide examples`; + icon.classList.replace('fa-chevron-down', 'fa-chevron-up'); + this._initLazyLoading(carousel); + this._initMetadataPanelHandlers(carousel); + } else { + const count = carousel.querySelectorAll('.media-wrapper').length; + indicator.textContent = `Scroll or click to show ${count} examples`; + icon.classList.replace('fa-chevron-up', 'fa-chevron-down'); + } + } + + /** + * Initialize lazy loading for images + * @param {HTMLElement} container - Container with lazy-load images + */ + _initLazyLoading(container) { + const lazyImages = container.querySelectorAll('img.lazy'); + + const lazyLoad = (image) => { + image.src = image.dataset.src; + image.classList.remove('lazy'); + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + lazyLoad(entry.target); + observer.unobserve(entry.target); + } + }); + }); + + lazyImages.forEach(image => observer.observe(image)); + } + + /** + * Initialize metadata panel handlers + * @param {HTMLElement} container - Container with metadata panels + */ + _initMetadataPanelHandlers(container) { + const mediaWrappers = container.querySelectorAll('.media-wrapper'); + + mediaWrappers.forEach(wrapper => { + const metadataPanel = wrapper.querySelector('.image-metadata-panel'); + if (!metadataPanel) return; + + // Prevent events from bubbling + metadataPanel.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + // Handle copy prompt buttons + const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn'); + copyBtns.forEach(copyBtn => { + const promptIndex = copyBtn.dataset.promptIndex; + const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`); + + copyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + + if (!promptElement) return; + + try { + await navigator.clipboard.writeText(promptElement.textContent); + showToast('Prompt copied to clipboard', 'success'); + } catch (err) { + console.error('Copy failed:', err); + showToast('Copy failed', 'error'); + } + }); + }); + + // Prevent panel scroll from causing modal scroll + metadataPanel.addEventListener('wheel', (e) => { + e.stopPropagation(); + }); + }); + } + + /** + * Set up tab switching functionality + */ + _setupTabSwitching() { + const tabButtons = this.modal.querySelectorAll('.showcase-tabs .tab-btn'); + + tabButtons.forEach(button => { + button.addEventListener('click', () => { + // Remove active class from all tabs + this.modal.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn => + btn.classList.remove('active') + ); + this.modal.querySelectorAll('.tab-content .tab-pane').forEach(tab => + tab.classList.remove('active') + ); + + // Add active class to clicked tab + button.classList.add('active'); + const tabId = `${button.dataset.tab}-tab`; + this.modal.querySelector(`#${tabId}`).classList.add('active'); + + // If switching to description tab, handle content + if (button.dataset.tab === 'description') { + const descriptionContent = this.modal.querySelector('.model-description-content'); + if (descriptionContent) { + const hasContent = descriptionContent.innerHTML.trim() !== ''; + this.modal.querySelector('.model-description-loading')?.classList.add('hidden'); + + if (!hasContent) { + descriptionContent.innerHTML = '
No model description available
'; + descriptionContent.classList.remove('hidden'); + } + } + } + }); + }); + } + + /** + * Load model description from API + * @param {string} modelId - Model ID + * @param {string} filePath - File path + */ + async _loadModelDescription(modelId, filePath) { + try { + const descriptionContainer = this.modal.querySelector('.model-description-content'); + const loadingElement = this.modal.querySelector('.model-description-loading'); + + if (!descriptionContainer || !loadingElement) return; + + // Show loading indicator + loadingElement.classList.remove('hidden'); + descriptionContainer.classList.add('hidden'); + + // In production, this would fetch from the API + // For now, just simulate loading + setTimeout(() => { + descriptionContainer.innerHTML = '

This is a placeholder for the checkpoint model description.

'; + + // Show the description and hide loading indicator + descriptionContainer.classList.remove('hidden'); + loadingElement.classList.add('hidden'); + }, 500); + } catch (error) { + console.error('Error loading model description:', error); + const loadingElement = this.modal.querySelector('.model-description-loading'); + if (loadingElement) { + loadingElement.innerHTML = `
Failed to load model description. ${error.message}
`; + } + + // Show empty state message + const descriptionContainer = this.modal.querySelector('.model-description-content'); + if (descriptionContainer) { + descriptionContainer.innerHTML = '
No model description available
'; + descriptionContainer.classList.remove('hidden'); + } + } + } + + /** + * Scroll to top of modal content + * @param {HTMLElement} button - The back to top button + */ + scrollToTop(button) { + const modalContent = button.closest('.modal-content'); + if (modalContent) { + modalContent.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } + } +} + +// Create and export global instance +export const checkpointModal = new CheckpointModal(); + +// Add global functions for use in HTML +window.toggleShowcase = function(element) { + checkpointModal._toggleShowcase(element); +}; + +window.scrollToTopCheckpoint = function(button) { + checkpointModal.scrollToTop(button); +}; + +window.saveCheckpointNotes = function(filePath) { + checkpointModal._saveNotes(filePath); +}; \ No newline at end of file diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 9b6ecf91..5c2a2700 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -23,6 +23,19 @@ export class ModalManager { }); } + // Add checkpointModal registration + const checkpointModal = document.getElementById('checkpointModal'); + if (checkpointModal) { + this.registerModal('checkpointModal', { + element: checkpointModal, + onClose: () => { + this.getModal('checkpointModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + }, + closeOnOutsideClick: true + }); + } + const deleteModal = document.getElementById('deleteModal'); if (deleteModal) { this.registerModal('deleteModal', { diff --git a/templates/components/lora_modals.html b/templates/components/lora_modals.html index a6ab03d1..3bc0ab30 100644 --- a/templates/components/lora_modals.html +++ b/templates/components/lora_modals.html @@ -1,6 +1,9 @@ + + + - \ No newline at end of file + \ No newline at end of file