Spaces:
Running
Running
<script> | |
import { onDestroy, tick } from 'svelte'; | |
// Core state | |
let mainPrompt = ''; | |
let loraSearchQuery = ''; | |
let foundLoras = []; // Array to store {id: string, author: string} | |
let selectedLoraId = ''; | |
let generatedImageUrl = ''; | |
// Status flags | |
let isSearchingLoras = false; | |
let isGeneratingImage = false; | |
let searchError = ''; | |
let generationError = ''; | |
// Interaction state | |
let showLoraSearch = false; // Controls visibility of LoRA search section | |
let debounceTimeout; | |
let animatedLoadingText = ''; // For animated text during generation | |
let loadingIntervalId = null; | |
// --- Phases (Derived Reactively) --- | |
$: isInPromptPhase = !showLoraSearch && !isGeneratingImage && !generatedImageUrl; | |
$: isInLoraSearchPhase = showLoraSearch && !selectedLoraId && !isGeneratingImage && !generatedImageUrl; | |
$: isInLoraSelectedPhase = showLoraSearch && selectedLoraId && !isGeneratingImage && !generatedImageUrl; | |
$: isInGeneratingPhase = isGeneratingImage; | |
$: isInResultPhase = generatedImageUrl && !isGeneratingImage; | |
// --- Debounced LoRA Search --- | |
function debounce(func, delay) { | |
return function(...args) { | |
clearTimeout(debounceTimeout); | |
debounceTimeout = setTimeout(() => { | |
func.apply(this, args); | |
}, delay); | |
}; | |
} | |
async function searchLoras(query) { | |
if (!query || query.trim().length < 3) { | |
foundLoras = []; | |
searchError = query.trim().length > 0 ? 'Enter at least 3 characters.' : ''; | |
isSearchingLoras = false; | |
// Don't reset selection if query becomes too short temporarily | |
// selectedLoraId = ''; | |
return; | |
} | |
isSearchingLoras = true; | |
searchError = ''; | |
// Don't clear foundLoras immediately, prevents flicker | |
// foundLoras = []; | |
// selectedLoraId = ''; // Reset selection on new search? Maybe not, allow refinement. | |
generationError = ''; // Clear generation error | |
console.log(`Searching for: ${query}`); | |
try { | |
const response = await fetch('/api/search-loras', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ query: query }) | |
}); | |
if (!response.ok) { | |
const errorData = await response.json(); | |
throw new Error(errorData.message || `HTTP error ${response.status}`); | |
} | |
const results = await response.json(); | |
console.log('Found LoRAs:', results.length, results); // Log results received | |
// Check if the previously selected LoRA is still valid in the new results | |
let previousSelectionStillValid = selectedLoraId && results.some(lora => lora.id === selectedLoraId); | |
foundLoras = results; | |
// If the previous selection is no longer valid, or if nothing was selected, | |
// automatically select the first result if available. | |
if (!previousSelectionStillValid && foundLoras.length > 0) { | |
selectedLoraId = foundLoras[0].id; | |
// Clear any generation error since we made a selection | |
generationError = ''; | |
} else if (!previousSelectionStillValid) { | |
// If previous selection invalid AND no new results, clear selection | |
selectedLoraId = ''; | |
} | |
// If previousSelectionStillValid is true, selectedLoraId remains unchanged. | |
if (foundLoras.length === 0) { | |
searchError = 'No matching LoRAs found.'; | |
} | |
} catch (err) { | |
console.error('Search error:', err); | |
searchError = err.message || 'Failed to search.'; | |
selectedLoraId = ''; // Reset selection on error | |
foundLoras = []; // Clear results on error | |
} finally { | |
isSearchingLoras = false; | |
} | |
} | |
const debouncedSearchLoras = debounce(searchLoras, 400); // 400ms delay | |
function handleLoraSearchInput(event) { | |
const query = event.target.value; | |
loraSearchQuery = query; | |
selectedLoraId = ''; // Clear selection when search query changes | |
searchError = ''; | |
isSearchingLoras = query.trim().length >= 3; | |
debouncedSearchLoras(query); | |
} | |
function handleLoraSelection(loraId) { | |
selectedLoraId = loraId; | |
generationError = ''; // Clear error on selection | |
// No automatic generation, wait for button click | |
} | |
// --- Image Generation --- | |
async function generateImage() { | |
if (!selectedLoraId || !mainPrompt || isGeneratingImage) { | |
generationError = 'Prompt and LoRA style must be selected.'; | |
return; | |
} | |
isGeneratingImage = true; | |
generationError = ''; | |
showLoraSearch = false; // Hide search during generation | |
// Clean up previous object URL if it exists | |
if (generatedImageUrl.startsWith('blob:')) { | |
URL.revokeObjectURL(generatedImageUrl); | |
generatedImageUrl = ''; | |
} | |
try { | |
const response = await fetch('/api/generate-image', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ loraModelId: selectedLoraId, prompt: mainPrompt }) | |
}); | |
if (!response.ok) { | |
let errorMsg = `Generation failed (${response.status})`; | |
try { | |
const errorData = await response.json(); | |
errorMsg = errorData.message || errorMsg; | |
} catch (e) { /* Ignore */ } | |
throw new Error(errorMsg); | |
} | |
const imageBlob = await response.blob(); | |
generatedImageUrl = URL.createObjectURL(imageBlob); | |
} catch (err) { | |
console.error('Generation error:', err); | |
generationError = err.message || 'Failed to generate image.'; | |
generatedImageUrl = ''; // Ensure no broken image shows | |
} finally { | |
isGeneratingImage = false; | |
// Stay in result phase (or error) | |
} | |
} | |
// --- Keyboard Handling --- | |
function handlePromptKeydown(event) { | |
if (event.key === 'Enter' && !event.shiftKey && mainPrompt.trim()) { | |
event.preventDefault(); // Prevent newline | |
proceedFromPrompt(); | |
} | |
} | |
// --- Actions --- | |
function proceedFromPrompt() { | |
if (mainPrompt.trim()) { | |
showLoraSearch = true; | |
// Reset LoRA search state if needed | |
loraSearchQuery = ''; | |
foundLoras = []; | |
selectedLoraId = ''; | |
searchError = ''; | |
generationError = ''; | |
// Focus the LoRA input shortly after it appears | |
tick().then(() => { | |
const loraInput = document.querySelector('.lora-search-input'); | |
if (loraInput) loraInput.focus(); | |
}); | |
} | |
} | |
function goBackToPrompt() { | |
showLoraSearch = false; | |
generationError = ''; | |
searchError = ''; | |
// Keep prompt value | |
// Focus prompt input | |
tick().then(() => { | |
const promptInput = document.querySelector('.prompt-input'); | |
if (promptInput) promptInput.focus(); | |
}); | |
} | |
function startOver() { | |
mainPrompt = ''; | |
loraSearchQuery = ''; | |
foundLoras = []; | |
selectedLoraId = ''; | |
if (generatedImageUrl.startsWith('blob:')) { | |
URL.revokeObjectURL(generatedImageUrl); | |
} | |
generatedImageUrl = ''; | |
isSearchingLoras = false; | |
isGeneratingImage = false; | |
searchError = ''; | |
generationError = ''; | |
showLoraSearch = false; | |
// Focus prompt input | |
tick().then(() => { | |
const promptInput = document.querySelector('.prompt-input'); | |
if (promptInput) promptInput.focus(); | |
}); | |
} | |
// --- Single Button Logic --- | |
$: buttonText = (() => { | |
if (isInGeneratingPhase) return 'Generating...'; | |
if (isInResultPhase) return 'Start Over'; | |
if (isInLoraSelectedPhase) return 'Generate Image'; | |
if (isInLoraSearchPhase) return 'Select a Style'; // Or maybe disabled? | |
if (isInPromptPhase) return 'Choose Style →'; | |
return '...'; // Default/fallback | |
})(); | |
$: isButtonDisabled = (() => { | |
if (isInGeneratingPhase) return true; | |
if (isInResultPhase) return false; // Always allow start over | |
if (isInLoraSelectedPhase) return false; // Ready to generate | |
if (isInLoraSearchPhase) return !selectedLoraId; // Disabled until selection (or enable to go back?) No, separate back button is better | |
if (isInPromptPhase) return !mainPrompt.trim(); | |
return true; | |
})(); | |
function handleButtonClick() { | |
if (isInGeneratingPhase) return; // Should be disabled anyway | |
if (isInResultPhase) { | |
startOver(); | |
} else if (isInLoraSelectedPhase) { | |
generateImage(); | |
} else if (isInLoraSearchPhase) { | |
// Button should ideally be disabled here until selection. If clicked, do nothing or maybe focus input. | |
} else if (isInPromptPhase) { | |
proceedFromPrompt(); | |
} | |
} | |
// --- Image Error Handling --- | |
function handleImageError(event) { | |
// Hide the broken image | |
event.target.style.display = 'none'; | |
// Find and show the error message paragraph that should be right after the image | |
const errorMsgElement = event.target.nextElementSibling; | |
if (errorMsgElement && errorMsgElement.classList.contains('image-load-error')) { | |
errorMsgElement.style.display = 'block'; | |
} | |
} | |
// --- Reactive Animation Logic --- | |
$: { | |
if (isGeneratingImage && !loadingIntervalId) { | |
const steps = [ | |
`Prompt: ${mainPrompt}`, | |
`Style: ${selectedLoraId}`, | |
'Generating...' | |
]; | |
let currentStepIndex = 0; | |
// Set initial text immediately | |
animatedLoadingText = steps[currentStepIndex]; | |
loadingIntervalId = setInterval(() => { | |
currentStepIndex = (currentStepIndex + 1) % steps.length; | |
animatedLoadingText = steps[currentStepIndex]; | |
}, 1000); // Change text every 1 second | |
} else if (!isGeneratingImage && loadingIntervalId) { | |
clearInterval(loadingIntervalId); | |
loadingIntervalId = null; | |
animatedLoadingText = ''; // Clear text when done | |
} | |
} | |
// --- Lifecycle --- | |
onDestroy(() => { | |
clearTimeout(debounceTimeout); | |
if (loadingIntervalId) { | |
clearInterval(loadingIntervalId); // Ensure cleanup on destroy | |
} | |
if (generatedImageUrl.startsWith('blob:')) { | |
URL.revokeObjectURL(generatedImageUrl); | |
} | |
}); | |
</script> | |
<svelte:head> | |
<title>Image Generator</title> | |
</svelte:head> | |
<main class="app-container"> | |
<!-- Prompt Input --> | |
{#if isInPromptPhase || isInLoraSearchPhase || isInLoraSelectedPhase} | |
<section class="prompt-section" class:hidden={!isInPromptPhase}> | |
<textarea | |
class="prompt-input" | |
bind:value={mainPrompt} | |
placeholder="Describe your image..." | |
rows="3" | |
on:keydown={handlePromptKeydown} | |
disabled={!isInPromptPhase} | |
></textarea> | |
</section> | |
{/if} | |
<!-- LoRA Search & Selection --> | |
{#if showLoraSearch && !isInGeneratingPhase && !isInResultPhase} | |
<section class="lora-section"> | |
<button class="back-button" on:click={goBackToPrompt} title="Back to Prompt">← Edit Prompt</button> | |
<div class="search-container"> | |
<input | |
type="search" | |
class="lora-search-input" | |
bind:value={loraSearchQuery} | |
on:input={handleLoraSearchInput} | |
placeholder="Search for a style (e.g., watercolor)..." | |
disabled={isGeneratingImage} | |
/> | |
{#if isSearchingLoras}<div class="spinner"></div>{/if} | |
</div> | |
{#if searchError}<p class="error-message">{searchError}</p>{/if} | |
{#if !isSearchingLoras && foundLoras.length > 0} | |
<ul class="lora-list"> | |
{#each foundLoras as lora (lora.id)} | |
<li> | |
<button | |
class="lora-item" | |
class:selected={selectedLoraId === lora.id} | |
on:click={() => handleLoraSelection(lora.id)} | |
title={lora.id} | |
> | |
{#if lora.thumbnail} | |
<img | |
src={lora.thumbnail} | |
alt="{lora.id} thumbnail" | |
class="lora-thumbnail" | |
loading="lazy" | |
/> | |
{:else} | |
<div class="lora-thumbnail-placeholder"></div> | |
{/if} | |
<div class="lora-info"> | |
<span class="lora-name">{lora.id.split('/').pop()}</span> | |
<span class="lora-author">by {lora.author}</span> | |
</div> | |
</button> | |
</li> | |
{/each} | |
</ul> | |
{:else if !isSearchingLoras && loraSearchQuery.trim().length >= 3 && !searchError} | |
<p class="info-message">No styles found matching "{loraSearchQuery}".</p> | |
{/if} | |
</section> | |
{/if} | |
<!-- Generating State --> | |
{#if isInGeneratingPhase} | |
<section class="generating-section"> | |
<div class="spinner large"></div> | |
{#key animatedLoadingText} <!-- Use key block for animation trigger --> | |
<p class="loading-text" in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}> | |
{animatedLoadingText} | |
</p> | |
{/key} | |
</section> | |
{/if} | |
<!-- Result Display --> | |
{#if isInResultPhase} | |
<section class="result-section"> | |
{#if generationError} | |
<p class="error-message fatal">{generationError}</p> | |
{/if} | |
{#if generatedImageUrl} | |
<img | |
class="result-image" | |
src={generatedImageUrl} | |
alt="Generated: {mainPrompt}" | |
on:error={handleImageError} | |
/> | |
<p class="error-message image-load-error" style="display: none;">Failed to load generated image.</p> | |
{/if} | |
</section> | |
{/if} | |
<!-- Action Button --> | |
<div class="action-button-container"> | |
{#if !isInGeneratingPhase} | |
<button | |
class="action-button" | |
on:click={handleButtonClick} | |
disabled={isButtonDisabled} | |
> | |
{buttonText} | |
</button> | |
{/if} | |
</div> | |
</main> | |
<style> | |
:root { | |
--bg-color: #121212; /* Dark background */ | |
--primary-color: #bb86fc; /* Purple accent */ | |
--primary-variant: #3700b3; | |
--secondary-color: #03dac6; /* Teal accent */ | |
--text-color: #e0e0e0; /* Light text */ | |
--text-secondary: #a0a0a0; /* Dimmer text */ | |
--surface-color: #1e1e1e; /* Slightly lighter surface */ | |
--error-color: #cf6679; | |
--border-color: #333; | |
--input-bg: #2a2a2a; | |
--selected-bg: rgba(187, 134, 252, 0.2); /* Semi-transparent primary */ | |
/* Animated Background */ | |
--gradient-start: #1a1a2e; /* Dark Blue/Purple */ | |
--gradient-mid: #16213e; /* Darker Blue */ | |
--gradient-end: #0f3460; /* Deep Blue */ | |
background: linear-gradient(-45deg, var(--gradient-start), var(--gradient-mid), var(--gradient-end), var(--gradient-mid)); | |
background-size: 400% 400%; | |
animation: gradientBG 15s ease infinite; | |
} | |
/* Basic Reset & Body */ | |
* { | |
box-sizing: border-box; | |
margin: 0; | |
padding: 0; | |
} | |
html, body { | |
height: 100%; | |
background-color: var(--bg-color); | |
color: var(--text-color); | |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
font-size: 16px; | |
line-height: 1.6; | |
overflow: hidden; /* Prevent body scroll */ | |
} | |
/* Main App Container */ | |
.app-container { | |
display: flex; | |
flex-direction: column; | |
height: 100vh; /* Full viewport height */ | |
width: 100vw; /* Full viewport width */ | |
padding: 2rem; | |
padding-bottom: 8rem; /* Space for action button */ | |
overflow-y: auto; /* Allow content scrolling if needed */ | |
position: relative; | |
} | |
/* Sections */ | |
section { | |
width: 100%; | |
max-width: 800px; /* Limit content width */ | |
margin: 0 auto; /* Center content */ | |
padding: 1rem 0; | |
} | |
section.hidden { | |
display: none; | |
} | |
/* Prompt Input */ | |
.prompt-section { | |
flex-grow: 1; /* Takes up space initially */ | |
display: flex; | |
align-items: center; /* Vertically center */ | |
justify-content: center; | |
} | |
.prompt-input { | |
width: 100%; | |
padding: 1.5rem; | |
border: 1px solid var(--border-color); | |
border-radius: 8px; | |
font-size: 1.5rem; /* Larger font */ | |
line-height: 1.4; | |
background-color: var(--input-bg); | |
color: var(--text-color); | |
resize: none; /* Disable manual resize */ | |
transition: all 0.2s ease; | |
min-height: 100px; /* Ensure decent initial height */ | |
} | |
.prompt-input:focus { | |
outline: none; | |
border-color: var(--primary-color); | |
box-shadow: 0 0 0 3px rgba(187, 134, 252, 0.3); | |
} | |
.prompt-input:disabled { | |
opacity: 0.5; | |
background-color: #333; | |
} | |
/* LoRA Section */ | |
.lora-section { | |
position: relative; /* For back button positioning */ | |
padding-top: 0; /* Reduce top padding */ | |
} | |
.back-button { | |
position: absolute; | |
top: -10px; /* Adjust as needed */ | |
left: 0; | |
background: none; | |
border: none; | |
color: var(--text-secondary); | |
padding: 0.5rem; | |
cursor: pointer; | |
font-size: 0.9rem; | |
} | |
.back-button:hover { | |
color: var(--primary-color); | |
} | |
.search-container { | |
position: relative; | |
margin-bottom: 1rem; | |
} | |
.lora-search-input { | |
width: 100%; | |
padding: 1rem 1.5rem; | |
padding-right: 3rem; /* Space for spinner */ | |
border: 1px solid var(--border-color); | |
border-radius: 8px; | |
font-size: 1.2rem; | |
background-color: var(--input-bg); | |
color: var(--text-color); | |
transition: all 0.2s ease; | |
} | |
.lora-search-input:focus { | |
outline: none; | |
border-color: var(--primary-color); | |
box-shadow: 0 0 0 3px rgba(187, 134, 252, 0.3); | |
} | |
.lora-list { | |
list-style: none; | |
max-height: calc(100vh - 260px); /* Increased height */ | |
overflow-y: auto; | |
border: 1px solid var(--border-color); | |
border-radius: 8px; | |
background-color: var(--surface-color); | |
} | |
.lora-list li { | |
border-bottom: 1px solid var(--border-color); | |
} | |
.lora-list li:last-child { | |
border-bottom: none; | |
} | |
.lora-item { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
width: 100%; | |
padding: 0.8rem 1.2rem; | |
background: none; | |
border: none; | |
color: var(--text-color); | |
cursor: pointer; | |
text-align: left; | |
transition: background-color 0.15s ease; | |
font-size: 1rem; | |
} | |
.lora-item:hover { | |
background-color: rgba(255, 255, 255, 0.05); | |
} | |
.lora-item.selected { | |
background-color: var(--selected-bg); | |
/* font-weight: 600; */ /* Removed to avoid layout shift */ | |
/* color: var(--primary-color); */ /* Color applied below */ | |
} | |
.lora-thumbnail, | |
.lora-thumbnail-placeholder { | |
width: 40px; | |
height: 40px; | |
border-radius: 4px; | |
margin-right: 12px; | |
object-fit: cover; | |
flex-shrink: 0; /* Prevent shrinking */ | |
background-color: var(--input-bg); /* Placeholder bg */ | |
} | |
.lora-thumbnail-placeholder { | |
border: 1px dashed var(--border-color); | |
} | |
.lora-info { | |
display: flex; | |
flex-direction: column; | |
overflow: hidden; /* Prevent text overflow */ | |
flex-grow: 1; | |
} | |
.lora-item.selected .lora-info .lora-name { | |
color: var(--primary-color); | |
font-weight: 600; | |
} | |
.lora-name { | |
white-space: nowrap; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
/* margin-right: 1rem; */ /* Removed as info is now a column */ | |
font-size: 1rem; | |
margin-bottom: 2px; /* Small space between name and author */ | |
} | |
.lora-author { | |
font-size: 0.85rem; | |
color: var(--text-secondary); | |
white-space: nowrap; | |
flex-shrink: 0; /* Prevent author from shrinking */ | |
} | |
/* Generating & Result */ | |
.generating-section, .result-section { | |
flex-grow: 1; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
text-align: center; | |
} | |
.result-image { | |
max-width: 100%; | |
max-height: calc(100vh - 12rem); /* Adjust based on padding/button */ | |
height: auto; | |
border-radius: 8px; | |
object-fit: contain; | |
background-color: var(--input-bg); /* Placeholder BG */ | |
margin-bottom: 1rem; /* Space before button if image fails */ | |
} | |
.generating-prompt, .generating-lora { | |
color: var(--text-secondary); | |
font-size: 0.9rem; | |
max-width: 90%; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
} | |
/* Action Button Container */ | |
.action-button-container { | |
position: fixed; /* Stick to bottom */ | |
bottom: 0; | |
left: 0; | |
width: 100%; | |
padding: 1.5rem 2rem; | |
display: flex; | |
justify-content: center; | |
/* background: linear-gradient(to top, var(--bg-color) 60%, transparent); */ /* Removed fade background */ | |
z-index: 10; | |
} | |
.action-button { | |
padding: 1rem 2.5rem; | |
background-color: var(--primary-color); | |
color: #000; /* Black text on primary */ | |
border: none; | |
border-radius: 30px; /* Pill shape */ | |
font-size: 1.2rem; | |
font-weight: 600; | |
cursor: pointer; | |
transition: all 0.2s ease; | |
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); | |
} | |
.action-button:hover:not(:disabled) { | |
background-color: var(--secondary-color); | |
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); | |
transform: translateY(-2px); | |
} | |
.action-button:disabled { | |
background-color: #555; | |
color: #999; | |
cursor: not-allowed; | |
opacity: 0.6; | |
box-shadow: none; | |
} | |
/* Spinner */ | |
.spinner { | |
border: 3px solid rgba(255, 255, 255, 0.3); | |
border-radius: 50%; | |
border-top-color: var(--secondary-color); /* Teal spinner */ | |
width: 20px; | |
height: 20px; | |
animation: spin 1s linear infinite; | |
position: absolute; | |
right: 1rem; | |
top: 50%; | |
transform: translateY(-50%); | |
} | |
.spinner.large { | |
width: 50px; | |
height: 50px; | |
border-width: 5px; | |
position: static; /* Reset position */ | |
transform: none; | |
margin-bottom: 1rem; | |
} | |
@keyframes spin { | |
to { transform: translateY(-50%) rotate(360deg); } | |
} | |
@keyframes spin-large { | |
to { transform: rotate(360deg); } | |
} | |
.spinner.large { | |
animation-name: spin-large; | |
transform: none; /* Override */ | |
} | |
@keyframes gradientBG { | |
0% { | |
background-position: 0% 50%; | |
} | |
50% { | |
background-position: 100% 50%; | |
} | |
100% { | |
background-position: 0% 50%; | |
} | |
} | |
/* Error/Info Messages */ | |
.error-message, .info-message { | |
padding: 0.8rem 1.2rem; | |
border-radius: 6px; | |
margin: 1rem auto; | |
text-align: center; | |
max-width: 600px; | |
font-size: 0.95rem; | |
} | |
.error-message { | |
background-color: rgba(207, 102, 121, 0.2); /* Error bg */ | |
color: var(--error-color); | |
border: 1px solid var(--error-color); | |
} | |
.error-message.fatal { /* For generation errors */ | |
font-size: 1.1rem; | |
margin-bottom: 1rem; | |
} | |
.info-message { | |
background-color: rgba(3, 218, 198, 0.1); /* Secondary color bg */ | |
color: var(--secondary-color); | |
border: 1px solid var(--secondary-color); | |
} | |
/* Animated Loading Text */ | |
.loading-text { | |
margin-top: 1rem; | |
font-size: 1.1rem; | |
color: var(--text-secondary); | |
min-height: 1.5em; /* Prevent layout jump */ | |
white-space: nowrap; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
max-width: 90%; /* Ensure it doesn't overflow container */ | |
} | |
</style> |