SOY NV AI
Add Gemini API integration with REST API support, improve error handling, and add markdown bold formatting for messages
665bcdc
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>κ΄λ¦¬μ νμ΄μ§ - SOY NV AI</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: #f8f9fa; | |
| color: #202124; | |
| } | |
| .header { | |
| background: white; | |
| border-bottom: 1px solid #dadce0; | |
| padding: 16px 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); | |
| } | |
| .header-title { | |
| font-size: 20px; | |
| font-weight: 500; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .header-actions { | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| } | |
| .btn { | |
| padding: 8px 16px; | |
| border: none; | |
| border-radius: 6px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| text-decoration: none; | |
| display: inline-block; | |
| } | |
| .btn-primary { | |
| background: #1a73e8; | |
| color: white; | |
| } | |
| .btn-primary:hover { | |
| background: #1557b0; | |
| } | |
| .btn-secondary { | |
| background: #f1f3f4; | |
| color: #202124; | |
| } | |
| .btn-secondary:hover { | |
| background: #e8eaed; | |
| } | |
| .btn-danger { | |
| background: #ea4335; | |
| color: white; | |
| } | |
| .btn-danger:hover { | |
| background: #c5221f; | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 24px; | |
| } | |
| .page-header { | |
| margin-bottom: 24px; | |
| } | |
| .page-header h1 { | |
| font-size: 28px; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| } | |
| .page-header p { | |
| color: #5f6368; | |
| } | |
| .card { | |
| background: white; | |
| border-radius: 8px; | |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); | |
| padding: 24px; | |
| margin-bottom: 24px; | |
| } | |
| .card-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| } | |
| .card-title { | |
| font-size: 18px; | |
| font-weight: 500; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| thead { | |
| background: #f8f9fa; | |
| } | |
| th, td { | |
| padding: 12px; | |
| text-align: left; | |
| border-bottom: 1px solid #e8eaed; | |
| } | |
| th { | |
| font-weight: 500; | |
| font-size: 14px; | |
| color: #5f6368; | |
| } | |
| td { | |
| font-size: 14px; | |
| } | |
| .badge { | |
| display: inline-block; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| } | |
| .badge-admin { | |
| background: #e8f0fe; | |
| color: #1967d2; | |
| } | |
| .badge-user { | |
| background: #e8f5e9; | |
| color: #137333; | |
| } | |
| .badge-inactive { | |
| background: #fce8e6; | |
| color: #c5221f; | |
| } | |
| .modal { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.5); | |
| z-index: 1000; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal.active { | |
| display: flex; | |
| } | |
| .modal-content { | |
| background: white; | |
| border-radius: 8px; | |
| padding: 24px; | |
| width: 90%; | |
| max-width: 500px; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| } | |
| .modal-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| } | |
| .modal-title { | |
| font-size: 20px; | |
| font-weight: 500; | |
| } | |
| .modal-close { | |
| background: none; | |
| border: none; | |
| font-size: 24px; | |
| cursor: pointer; | |
| color: #5f6368; | |
| } | |
| .form-group { | |
| margin-bottom: 16px; | |
| } | |
| .form-group label { | |
| display: block; | |
| font-size: 14px; | |
| font-weight: 500; | |
| margin-bottom: 8px; | |
| } | |
| .form-group input, | |
| .form-group select { | |
| width: 100%; | |
| padding: 10px 12px; | |
| border: 1px solid #dadce0; | |
| border-radius: 6px; | |
| font-size: 14px; | |
| font-family: inherit; | |
| } | |
| .form-group input:focus, | |
| .form-group select:focus { | |
| outline: none; | |
| border-color: #1a73e8; | |
| box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1); | |
| } | |
| .form-group-checkbox { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .form-group-checkbox input { | |
| width: auto; | |
| } | |
| .modal-actions { | |
| display: flex; | |
| gap: 8px; | |
| justify-content: flex-end; | |
| margin-top: 24px; | |
| } | |
| .alert { | |
| padding: 12px 16px; | |
| border-radius: 6px; | |
| margin-bottom: 16px; | |
| font-size: 14px; | |
| } | |
| .alert.error { | |
| background: #fce8e6; | |
| color: #c5221f; | |
| } | |
| .alert.success { | |
| background: #e8f5e9; | |
| color: #137333; | |
| } | |
| /* νμΌ μ λ‘λ μμ */ | |
| .file-upload-section { | |
| margin-top: 24px; | |
| } | |
| .file-upload-input-wrapper { | |
| position: relative; | |
| margin-bottom: 12px; | |
| border: 2px dashed #dadce0; | |
| border-radius: 8px; | |
| padding: 20px; | |
| text-align: center; | |
| background: #f8f9fa; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .file-upload-input-wrapper:hover { | |
| border-color: #1a73e8; | |
| background: #e8f0fe; | |
| } | |
| .file-upload-input-wrapper.dragover { | |
| border-color: #1a73e8; | |
| background: #e8f0fe; | |
| } | |
| .file-upload-input-wrapper input[type="file"] { | |
| position: absolute; | |
| opacity: 0; | |
| width: 100%; | |
| height: 100%; | |
| cursor: pointer; | |
| } | |
| .file-upload-label { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 8px; | |
| color: #5f6368; | |
| font-size: 14px; | |
| } | |
| .file-upload-label svg { | |
| width: 32px; | |
| height: 32px; | |
| } | |
| .file-upload-status { | |
| font-size: 12px; | |
| margin-top: 8px; | |
| min-height: 16px; | |
| } | |
| .file-upload-status.success { | |
| color: #137333; | |
| } | |
| .file-upload-status.error { | |
| color: #c5221f; | |
| } | |
| .file-upload-status.progress { | |
| color: #1a73e8; | |
| font-weight: 500; | |
| } | |
| .file-upload-progress { | |
| margin-top: 12px; | |
| padding: 12px; | |
| background: #f8f9fa; | |
| border-radius: 6px; | |
| display: none; | |
| } | |
| .file-upload-progress.active { | |
| display: block; | |
| } | |
| .progress-item { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 8px 0; | |
| border-bottom: 1px solid #e8eaed; | |
| } | |
| .progress-item:last-child { | |
| border-bottom: none; | |
| } | |
| .progress-item-name { | |
| flex: 1; | |
| font-size: 13px; | |
| color: #202124; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| margin-right: 12px; | |
| } | |
| .progress-item-status { | |
| font-size: 12px; | |
| font-weight: 500; | |
| min-width: 80px; | |
| text-align: right; | |
| } | |
| .progress-item-status.uploading { | |
| color: #1a73e8; | |
| } | |
| .progress-item-status.success { | |
| color: #137333; | |
| } | |
| .progress-item-status.error { | |
| color: #c5221f; | |
| } | |
| .file-upload-input-wrapper.disabled { | |
| opacity: 0.6; | |
| pointer-events: none; | |
| cursor: not-allowed; | |
| } | |
| .spinner { | |
| display: inline-block; | |
| width: 12px; | |
| height: 12px; | |
| border: 2px solid #e8eaed; | |
| border-top-color: #1a73e8; | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| margin-right: 6px; | |
| vertical-align: middle; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| .files-table { | |
| margin-top: 16px; | |
| } | |
| .file-size { | |
| color: #5f6368; | |
| font-size: 12px; | |
| } | |
| .file-actions { | |
| display: flex; | |
| gap: 4px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div class="header-title"> | |
| <span>π€</span> | |
| <span>SOY NV AI κ΄λ¦¬μ νμ΄μ§</span> | |
| </div> | |
| <div class="header-actions"> | |
| <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span> | |
| <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">μΉμμ€ κ΄λ¦¬</a> | |
| <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">λ©μμ§ νμΈ</a> | |
| <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©μΈμΌλ‘</a> | |
| <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘κ·Έμμ</a> | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <div class="page-header"> | |
| <h1>μ¬μ©μ κ΄λ¦¬</h1> | |
| <p>μμ€ν μ¬μ©μλ₯Ό κ΄λ¦¬ν μ μμ΅λλ€.</p> | |
| </div> | |
| <div id="alertContainer"></div> | |
| <!-- Gemini API ν€ μ€μ μΉμ --> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <div class="card-title">Gemini API ν€ μ€μ </div> | |
| </div> | |
| <div style="padding: 16px 0;"> | |
| <div class="form-group"> | |
| <label for="geminiApiKey">Gemini API ν€</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <input type="password" id="geminiApiKey" placeholder="Gemini API ν€λ₯Ό μ λ ₯νμΈμ" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;"> | |
| <button class="btn btn-primary" onclick="saveGeminiApiKey()">μ μ₯</button> | |
| <button class="btn btn-secondary" onclick="loadGeminiApiKey()">νμ¬ μν νμΈ</button> | |
| </div> | |
| <small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;"> | |
| Google AI Studio(<a href="https://aistudio.google.com/app/apikey" target="_blank">https://aistudio.google.com/app/apikey</a>)μμ API ν€λ₯Ό λ°κΈλ°μ μ μμ΅λλ€. | |
| </small> | |
| <div id="geminiApiKeyStatus" style="margin-top: 8px; font-size: 13px;"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <div class="card-title">μ¬μ©μ λͺ©λ‘</div> | |
| <button class="btn btn-primary" onclick="openCreateModal()">μ μ¬μ©μ μΆκ°</button> | |
| </div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>ID</th> | |
| <th>μ¬μ©μλͺ </th> | |
| <th>λλ€μ</th> | |
| <th>κΆν</th> | |
| <th>μν</th> | |
| <th>μμ±μΌ</th> | |
| <th>λ§μ§λ§ λ‘κ·ΈμΈ</th> | |
| <th>μμ </th> | |
| </tr> | |
| </thead> | |
| <tbody id="usersTableBody"> | |
| {% for user in users %} | |
| <tr data-user-id="{{ user.id }}"> | |
| <td>{{ user.id }}</td> | |
| <td>{{ user.username }}</td> | |
| <td>{{ user.nickname or '-' }}</td> | |
| <td> | |
| {% if user.is_admin %} | |
| <span class="badge badge-admin">κ΄λ¦¬μ</span> | |
| {% else %} | |
| <span class="badge badge-user">μΌλ° μ¬μ©μ</span> | |
| {% endif %} | |
| </td> | |
| <td> | |
| {% if user.is_active %} | |
| <span class="badge badge-user">νμ±</span> | |
| {% else %} | |
| <span class="badge badge-inactive">λΉνμ±</span> | |
| {% endif %} | |
| </td> | |
| <td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else '-' }}</td> | |
| <td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '-' }}</td> | |
| <td> | |
| <button class="btn btn-secondary" onclick="openEditModal({{ user.id }}, '{{ user.username }}', '{{ user.nickname or '' }}', {{ user.is_admin|lower }}, {{ user.is_active|lower }})" style="padding: 4px 8px; font-size: 12px;">μμ </button> | |
| {% if user.id != current_user.id %} | |
| <button class="btn btn-danger" onclick="deleteUser({{ user.id }})" style="padding: 4px 8px; font-size: 12px;">μμ </button> | |
| {% endif %} | |
| </td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- μ¬μ©μ μμ±/μμ λͺ¨λ¬ --> | |
| <div id="userModal" class="modal"> | |
| <div class="modal-content"> | |
| <div class="modal-header"> | |
| <div class="modal-title" id="modalTitle">μ μ¬μ©μ μΆκ°</div> | |
| <button class="modal-close" onclick="closeModal()">×</button> | |
| </div> | |
| <form id="userForm" onsubmit="saveUser(event)"> | |
| <input type="hidden" id="userId" name="user_id"> | |
| <div class="form-group"> | |
| <label for="username">μ¬μ©μλͺ </label> | |
| <input type="text" id="username" name="username" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="nickname">λλ€μ</label> | |
| <input type="text" id="nickname" name="nickname" placeholder="μ νμ¬ν"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="password">λΉλ°λ²νΈ</label> | |
| <input type="password" id="password" name="password" id="passwordInput"> | |
| <small style="color: #5f6368; font-size: 12px;">μμ μ λΉλ°λ²νΈλ₯Ό λ³κ²½νμ§ μμΌλ €λ©΄ λΉμλμΈμ.</small> | |
| </div> | |
| <div class="form-group-checkbox"> | |
| <input type="checkbox" id="isAdmin" name="is_admin"> | |
| <label for="isAdmin">κ΄λ¦¬μ κΆν</label> | |
| </div> | |
| <div class="form-group-checkbox"> | |
| <input type="checkbox" id="isActive" name="is_active" checked> | |
| <label for="isActive">νμ± μν</label> | |
| </div> | |
| <div class="modal-actions"> | |
| <button type="button" class="btn btn-secondary" onclick="closeModal()">μ·¨μ</button> | |
| <button type="submit" class="btn btn-primary">μ μ₯</button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <script> | |
| let currentEditUserId = null; | |
| function showAlert(message, type = 'success') { | |
| const container = document.getElementById('alertContainer'); | |
| container.innerHTML = `<div class="alert ${type}">${message}</div>`; | |
| setTimeout(() => { | |
| container.innerHTML = ''; | |
| }, 5000); | |
| } | |
| function openCreateModal() { | |
| currentEditUserId = null; | |
| document.getElementById('modalTitle').textContent = 'μ μ¬μ©μ μΆκ°'; | |
| document.getElementById('userForm').reset(); | |
| document.getElementById('userId').value = ''; | |
| document.getElementById('password').required = true; | |
| document.getElementById('isActive').checked = true; | |
| document.getElementById('userModal').classList.add('active'); | |
| } | |
| function openEditModal(userId, username, nickname, isAdmin, isActive) { | |
| currentEditUserId = userId; | |
| document.getElementById('modalTitle').textContent = 'μ¬μ©μ μμ '; | |
| document.getElementById('userId').value = userId; | |
| document.getElementById('username').value = username; | |
| document.getElementById('nickname').value = nickname || ''; | |
| document.getElementById('password').value = ''; | |
| document.getElementById('password').required = false; | |
| document.getElementById('isAdmin').checked = isAdmin; | |
| document.getElementById('isActive').checked = isActive; | |
| document.getElementById('userModal').classList.add('active'); | |
| } | |
| function closeModal() { | |
| document.getElementById('userModal').classList.remove('active'); | |
| currentEditUserId = null; | |
| } | |
| async function saveUser(event) { | |
| event.preventDefault(); | |
| const formData = { | |
| username: document.getElementById('username').value.trim(), | |
| nickname: document.getElementById('nickname').value.trim(), | |
| password: document.getElementById('password').value, | |
| is_admin: document.getElementById('isAdmin').checked, | |
| is_active: document.getElementById('isActive').checked | |
| }; | |
| const userId = document.getElementById('userId').value; | |
| const url = userId ? `/api/admin/users/${userId}` : '/api/admin/users'; | |
| const method = userId ? 'PUT' : 'POST'; | |
| if (!userId && !formData.password) { | |
| showAlert('λΉλ°λ²νΈλ₯Ό μ λ ₯ν΄μ£ΌμΈμ.', 'error'); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(url, { | |
| method: method, | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(formData) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| showAlert(data.message, 'success'); | |
| closeModal(); | |
| setTimeout(() => { | |
| location.reload(); | |
| }, 1000); | |
| } else { | |
| showAlert(data.error || 'μ€λ₯κ° λ°μνμ΅λλ€.', 'error'); | |
| } | |
| } catch (error) { | |
| showAlert(`μ€λ₯: ${error.message}`, 'error'); | |
| } | |
| } | |
| async function deleteUser(userId) { | |
| if (!confirm('μ λ§ μ΄ μ¬μ©μλ₯Ό μμ νμκ² μ΅λκΉ?')) { | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`/api/admin/users/${userId}`, { | |
| method: 'DELETE' | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| showAlert(data.message, 'success'); | |
| setTimeout(() => { | |
| location.reload(); | |
| }, 1000); | |
| } else { | |
| showAlert(data.error || 'μμ μ€ μ€λ₯κ° λ°μνμ΅λλ€.', 'error'); | |
| } | |
| } catch (error) { | |
| showAlert(`μ€λ₯: ${error.message}`, 'error'); | |
| } | |
| } | |
| // λͺ¨λ¬ μΈλΆ ν΄λ¦ μ λ«κΈ° | |
| document.getElementById('userModal').addEventListener('click', function(e) { | |
| if (e.target === this) { | |
| closeModal(); | |
| } | |
| }); | |
| // Gemini API ν€ κ΄λ ¨ ν¨μ | |
| async function loadGeminiApiKey() { | |
| try { | |
| const response = await fetch('/api/admin/gemini-api-key'); | |
| // μλ΅μ΄ JSONμΈμ§ νμΈ | |
| const contentType = response.headers.get('content-type'); | |
| if (!contentType || !contentType.includes('application/json')) { | |
| const text = await response.text(); | |
| console.error('Non-JSON response:', text.substring(0, 200)); | |
| const statusDiv = document.getElementById('geminiApiKeyStatus'); | |
| statusDiv.innerHTML = `<span style="color: #ea4335;">μλ² μ€λ₯: μλ΅ νμμ΄ μ¬λ°λ₯΄μ§ μμ΅λλ€.</span>`; | |
| return; | |
| } | |
| const data = await response.json(); | |
| const statusDiv = document.getElementById('geminiApiKeyStatus'); | |
| if (!response.ok) { | |
| statusDiv.innerHTML = `<span style="color: #ea4335;">μ€λ₯: ${data.error || 'μ μ μλ μ€λ₯'}</span>`; | |
| return; | |
| } | |
| if (data.has_api_key) { | |
| statusDiv.innerHTML = `<span style="color: #137333;">β API ν€κ° μ€μ λμ΄ μμ΅λλ€ (${data.masked_key})</span>`; | |
| } else { | |
| statusDiv.innerHTML = `<span style="color: #ea4335;">β API ν€κ° μ€μ λμ§ μμμ΅λλ€</span>`; | |
| } | |
| } catch (error) { | |
| console.error('Gemini API ν€ λ‘λ μ€λ₯:', error); | |
| const statusDiv = document.getElementById('geminiApiKeyStatus'); | |
| statusDiv.innerHTML = `<span style="color: #ea4335;">μ€λ₯: ${error.message}</span>`; | |
| } | |
| } | |
| async function saveGeminiApiKey() { | |
| const apiKeyInput = document.getElementById('geminiApiKey'); | |
| const apiKey = apiKeyInput.value.trim(); | |
| if (!apiKey) { | |
| showAlert('API ν€λ₯Ό μ λ ₯ν΄μ£ΌμΈμ.', 'error'); | |
| return; | |
| } | |
| try { | |
| const response = await fetch('/api/admin/gemini-api-key', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ api_key: apiKey }) | |
| }); | |
| // μλ΅μ΄ JSONμΈμ§ νμΈ | |
| const contentType = response.headers.get('content-type'); | |
| if (!contentType || !contentType.includes('application/json')) { | |
| const text = await response.text(); | |
| console.error('Non-JSON response:', text.substring(0, 200)); | |
| showAlert('μλ² μ€λ₯: μλ΅ νμμ΄ μ¬λ°λ₯΄μ§ μμ΅λλ€.', 'error'); | |
| return; | |
| } | |
| const data = await response.json(); | |
| if (response.ok) { | |
| showAlert(data.message, 'success'); | |
| apiKeyInput.value = ''; // 보μμ μν΄ μ λ ₯ νλ μ΄κΈ°ν | |
| loadGeminiApiKey(); // μν μ λ°μ΄νΈ | |
| } else { | |
| showAlert(data.error || 'API ν€ μ μ₯ μ€ μ€λ₯κ° λ°μνμ΅λλ€.', 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Gemini API ν€ μ μ₯ μ€λ₯:', error); | |
| showAlert(`μ€λ₯: ${error.message}`, 'error'); | |
| } | |
| } | |
| // νμ΄μ§ λ‘λ μ API ν€ μν νμΈ | |
| window.addEventListener('load', () => { | |
| loadGeminiApiKey(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |