commit
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.env
|
||||
tmp/
|
||||
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
SECRET_KEY=secretKey
|
||||
MAX_FILE_SIZE=16
|
||||
STATIC_CACHE_MAX_AGE=600
|
||||
APP_TITLE=AIO HTML Generator
|
||||
APP_FOOTER=© 2026 Mateusz Gruszczyński
|
||||
UPLOAD_FOLDER=/tmp/uploads
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.env
|
||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.14-alpine
|
||||
RUN apk add --no-cache gcc musl-dev linux-headers
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
RUN mkdir -p /tmp/uploads && chmod 777 /tmp/uploads
|
||||
EXPOSE 5000
|
||||
USER 1000
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
34
app/__init__.py
Normal file
34
app/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from flask import Flask, request, render_template
|
||||
from .config import Config
|
||||
from .api import api_bp
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
app.register_blueprint(api_bp, url_prefix='/api')
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return '', 204
|
||||
|
||||
@app.after_request
|
||||
def add_cache_headers(response):
|
||||
if request.path.startswith('/static/'):
|
||||
max_age = app.config['STATIC_CACHE_MAX_AGE']
|
||||
response.headers['Cache-Control'] = f'public, max-age={max_age}'
|
||||
return response
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
template_vars = {
|
||||
'APP_TITLE': app.config['APP_TITLE'],
|
||||
'APP_FOOTER': app.config['APP_FOOTER']
|
||||
}
|
||||
return render_template('main.html', config=template_vars)
|
||||
|
||||
@app.route('/api/health')
|
||||
def health():
|
||||
return {'status': 'OK'}
|
||||
|
||||
return app
|
||||
263
app/api.py
Normal file
263
app/api.py
Normal file
@@ -0,0 +1,263 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import mimetypes
|
||||
import base64
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import cssutils
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
ALLOWED_EXTENSIONS = {
|
||||
'html', 'htm', 'css', 'js', 'png', 'jpg', 'jpeg', 'gif',
|
||||
'svg', 'ico', 'woff', 'woff2', 'ttf', 'eot', 'json', 'map'
|
||||
}
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def guess_mimetype(filepath):
|
||||
mime_type, _ = mimetypes.guess_type(filepath)
|
||||
return mime_type or 'application/octet-stream'
|
||||
|
||||
def build_file_index(uploaded_paths):
|
||||
"""
|
||||
Index files by basename so resources referenced as /img.png, img.png, assets/img.png
|
||||
still resolve even if uploads were flattened.
|
||||
"""
|
||||
idx = {}
|
||||
for p in uploaded_paths:
|
||||
base = os.path.basename(p)
|
||||
if base not in idx:
|
||||
idx[base] = p
|
||||
return idx
|
||||
|
||||
def resolve_uploaded_path(base_path, resource_path, file_index):
|
||||
"""
|
||||
Try:
|
||||
1) base_path + resource_path (as-is, normalized)
|
||||
2) base_path + lstrip('/') (for /img.png)
|
||||
3) basename lookup in file_index (fallback)
|
||||
"""
|
||||
if not resource_path:
|
||||
return None
|
||||
|
||||
clean = resource_path.split('#', 1)[0].split('?', 1)[0]
|
||||
|
||||
p1 = os.path.normpath(os.path.join(base_path, clean))
|
||||
if os.path.exists(p1):
|
||||
return p1
|
||||
|
||||
p2 = os.path.normpath(os.path.join(base_path, clean.lstrip('/')))
|
||||
if os.path.exists(p2):
|
||||
return p2
|
||||
|
||||
base = os.path.basename(clean)
|
||||
if base in file_index and os.path.exists(file_index[base]):
|
||||
return file_index[base]
|
||||
|
||||
return None
|
||||
|
||||
def embed_resource(base_path, resource_path, file_index):
|
||||
"""Convert resource to base64 data URI."""
|
||||
if not resource_path:
|
||||
return resource_path
|
||||
|
||||
if resource_path.startswith(('http://', 'https://', 'data:', '//')):
|
||||
return resource_path
|
||||
|
||||
full_path = resolve_uploaded_path(base_path, resource_path, file_index)
|
||||
if not full_path:
|
||||
current_app.logger.warning(f"File not found for embed: {resource_path}")
|
||||
return resource_path
|
||||
|
||||
try:
|
||||
with open(full_path, 'rb') as f:
|
||||
data = f.read()
|
||||
mime = guess_mimetype(full_path)
|
||||
b64 = base64.b64encode(data).decode('utf-8')
|
||||
return f"data:{mime};base64,{b64}"
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to embed {full_path}: {e}")
|
||||
return resource_path
|
||||
|
||||
def embed_css_resources(base_path, css_content, file_index):
|
||||
"""Embed CSS url() resources."""
|
||||
def repl(match):
|
||||
url = match.group(1).strip("'\"")
|
||||
data_uri = embed_resource(base_path, url, file_index)
|
||||
return f'url({data_uri})'
|
||||
return re.sub(r'url\s*\(\s*["\']?([^"\')]+)["\']?\s*\)', repl, css_content)
|
||||
|
||||
def resolve_css_imports(css_file_path, file_index):
|
||||
"""Recursively resolve @import statements in CSS."""
|
||||
cssutils.log.setLevel(30)
|
||||
visited = set()
|
||||
|
||||
def parse_css_recursively(css_path):
|
||||
if css_path in visited:
|
||||
return ''
|
||||
visited.add(css_path)
|
||||
|
||||
base_path = os.path.dirname(os.path.abspath(css_path))
|
||||
|
||||
try:
|
||||
sheet = cssutils.parseFile(css_path)
|
||||
css_text = sheet.cssText.decode('utf-8')
|
||||
|
||||
for rule in sheet.cssRules.rulesOfType(cssutils.css.CSSImportRule):
|
||||
import_path = rule.href
|
||||
if not import_path or import_path.startswith(('http', 'data:')):
|
||||
continue
|
||||
|
||||
import_full = resolve_uploaded_path(base_path, import_path, file_index)
|
||||
if import_full and os.path.exists(import_full):
|
||||
imported_css = parse_css_recursively(import_full)
|
||||
css_text = (
|
||||
imported_css
|
||||
+ f'\n/* ===== IMPORTED: {os.path.basename(import_full)} ===== */\n'
|
||||
+ css_text
|
||||
)
|
||||
|
||||
return css_text
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"CSS parse error {css_path}: {e}")
|
||||
try:
|
||||
with open(css_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
return f.read()
|
||||
except:
|
||||
return f"/* ERROR: Could not read {os.path.basename(css_path)} */"
|
||||
|
||||
return parse_css_recursively(css_file_path)
|
||||
|
||||
def make_aio_html(input_file, file_index):
|
||||
"""Generate basic AIO HTML with embedded resources."""
|
||||
base_path = os.path.dirname(os.path.abspath(input_file))
|
||||
|
||||
with open(input_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
soup = BeautifulSoup(f.read(), 'html.parser')
|
||||
|
||||
for script in soup.find_all('script', src=True):
|
||||
script['src'] = embed_resource(base_path, script['src'], file_index)
|
||||
|
||||
for elem in soup.find_all(attrs={'src': True}):
|
||||
elem['src'] = embed_resource(base_path, elem['src'], file_index)
|
||||
|
||||
for style in soup.find_all('style'):
|
||||
if style.string:
|
||||
style.string = embed_css_resources(base_path, style.string, file_index)
|
||||
|
||||
return str(soup)
|
||||
|
||||
def make_aio_html_advanced(input_file, file_index):
|
||||
"""Advanced AIO: resolves @import CSS chains + embeds everything."""
|
||||
base_path = os.path.dirname(os.path.abspath(input_file))
|
||||
|
||||
with open(input_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
soup = BeautifulSoup(f.read(), 'html.parser')
|
||||
|
||||
css_files = []
|
||||
for link in soup.find_all('link', rel='stylesheet'):
|
||||
href = link.get('href')
|
||||
if href and not href.startswith(('http', 'data:')):
|
||||
full_css = resolve_uploaded_path(base_path, href, file_index)
|
||||
if full_css and os.path.exists(full_css):
|
||||
css_files.append(full_css)
|
||||
else:
|
||||
current_app.logger.warning(f"CSS file not found: {href}")
|
||||
|
||||
all_css_content = ''
|
||||
for css_path in css_files:
|
||||
resolved = resolve_css_imports(css_path, file_index)
|
||||
resolved = embed_css_resources(os.path.dirname(os.path.abspath(css_path)), resolved, file_index)
|
||||
all_css_content += f'\n/* ===== {os.path.basename(css_path)} ===== */\n{resolved}\n'
|
||||
|
||||
if all_css_content.strip():
|
||||
style_tag = soup.new_tag('style')
|
||||
style_tag.string = all_css_content
|
||||
if soup.head:
|
||||
soup.head.insert(0, style_tag)
|
||||
else:
|
||||
soup.insert(0, style_tag)
|
||||
|
||||
for link in soup.find_all('link', rel='stylesheet'):
|
||||
href = link.get('href')
|
||||
if href and not href.startswith(('http', 'data:')):
|
||||
link.decompose()
|
||||
|
||||
for script in soup.find_all('script', src=True):
|
||||
script['src'] = embed_resource(base_path, script['src'], file_index)
|
||||
|
||||
for elem in soup.find_all(attrs={'src': True}):
|
||||
elem['src'] = embed_resource(base_path, elem['src'], file_index)
|
||||
|
||||
for style in soup.find_all('style'):
|
||||
if style.string:
|
||||
style.string = embed_css_resources(base_path, style.string, file_index)
|
||||
|
||||
return str(soup)
|
||||
|
||||
@api_bp.route('/upload', methods=['POST'])
|
||||
def upload_files():
|
||||
return _upload_and_generate(make_aio_html)
|
||||
|
||||
@api_bp.route('/upload-advanced', methods=['POST'])
|
||||
def upload_advanced():
|
||||
return _upload_and_generate(make_aio_html_advanced)
|
||||
|
||||
def _upload_and_generate(generator_func):
|
||||
if 'files' not in request.files:
|
||||
return jsonify({'error': 'No files provided'}), 400
|
||||
|
||||
files = request.files.getlist('files')
|
||||
uploaded_paths = []
|
||||
|
||||
upload_folder = current_app.config.get('UPLOAD_FOLDER', '/tmp/uploads')
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
for file in files:
|
||||
if not file or file.filename == '':
|
||||
continue
|
||||
if not allowed_file(file.filename):
|
||||
continue
|
||||
|
||||
filename = secure_filename(os.path.basename(file.filename))
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
file.save(filepath)
|
||||
uploaded_paths.append(filepath)
|
||||
|
||||
if not uploaded_paths:
|
||||
return jsonify({'error': 'No valid files uploaded'}), 400
|
||||
|
||||
html_path = next((p for p in uploaded_paths if p.lower().endswith(('.html', '.htm'))), None)
|
||||
if not html_path:
|
||||
return jsonify({'error': 'No HTML file found. Please upload an HTML file.'}), 400
|
||||
|
||||
file_index = build_file_index(uploaded_paths)
|
||||
|
||||
try:
|
||||
aio_content = generator_func(html_path, file_index)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'AIO HTML generated successfully',
|
||||
'aio_html': aio_content,
|
||||
'size': len(aio_content),
|
||||
'original': os.path.basename(html_path),
|
||||
'features': 'CSS @import resolved, all assets embedded'
|
||||
if 'advanced' in generator_func.__name__
|
||||
else 'Basic embedding'
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"AIO generation failed: {e}")
|
||||
return jsonify({'error': f'Generation failed: {str(e)}'}), 500
|
||||
|
||||
@api_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
return jsonify({
|
||||
'status': 'OK',
|
||||
'version': '2.0.1',
|
||||
'features': ['Basic AIO', 'Advanced @import CSS', 'Asset embedding (basename fallback)'],
|
||||
'upload_folder': current_app.config.get('UPLOAD_FOLDER')
|
||||
})
|
||||
13
app/config.py
Normal file
13
app/config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from flask import g
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-aio-generator-change-me'
|
||||
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_FILE_SIZE', '32')) * 1024 * 1024
|
||||
STATIC_CACHE_MAX_AGE = int(os.environ.get('STATIC_CACHE_MAX_AGE', '31536000'))
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/tmp/uploads')
|
||||
APP_TITLE = os.environ.get('APP_TITLE', 'AIO HTML Generator')
|
||||
APP_FOOTER = os.environ.get('APP_FOOTER', '© 2026 - All-In-One HTML Generator')
|
||||
1
app/static
Symbolic link
1
app/static
Symbolic link
@@ -0,0 +1 @@
|
||||
../static
|
||||
1
app/templates
Symbolic link
1
app/templates
Symbolic link
@@ -0,0 +1 @@
|
||||
../templates
|
||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "4578:5000"
|
||||
volumes:
|
||||
- ./uploads:/tmp/uploads
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
2
entrypoint.sh
Normal file
2
entrypoint.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec gunicorn -w 4 -b 0.0.0.0:5000 'app:create_app()'
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
flask==3.0.3
|
||||
beautifulsoup4==4.12.3
|
||||
lxml
|
||||
python-dotenv==1.0.1
|
||||
werkzeug==3.0.4
|
||||
gunicorn
|
||||
cssutils
|
||||
48
static/css/app.css
Normal file
48
static/css/app.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.mode-btn {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn:hover, .mode-btn.active {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13,110,253,0.1);
|
||||
}
|
||||
|
||||
.drop-zone.dragover {
|
||||
border-color: #0d6efd !important;
|
||||
background-color: rgba(13,110,253,0.05);
|
||||
}
|
||||
|
||||
.result-card {
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
226
static/js/app.js
Normal file
226
static/js/app.js
Normal file
@@ -0,0 +1,226 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dropZone = document.getElementById("drop-zone");
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const modeBtns = document.querySelectorAll(".mode-btn");
|
||||
const progress = document.getElementById("progress");
|
||||
const progressBar = progress.querySelector(".progress-bar");
|
||||
const progressText = document.getElementById("progress-text");
|
||||
const result = document.getElementById("result");
|
||||
|
||||
let currentMode = "basic";
|
||||
|
||||
modeBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
modeBtns.forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
currentMode = btn.dataset.mode || "basic";
|
||||
});
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach((e) =>
|
||||
dropZone.addEventListener(e, (ev) => {
|
||||
ev.preventDefault();
|
||||
dropZone.classList.add("dragover");
|
||||
})
|
||||
);
|
||||
|
||||
["dragleave", "drop"].forEach((e) =>
|
||||
dropZone.addEventListener(e, (ev) => {
|
||||
ev.preventDefault();
|
||||
dropZone.classList.remove("dragover");
|
||||
})
|
||||
);
|
||||
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
processFiles();
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", processFiles);
|
||||
|
||||
dropZone.querySelector("label")?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
function processFiles() {
|
||||
const files = fileInput.files;
|
||||
if (!files || !files.length) return;
|
||||
|
||||
const endpoint =
|
||||
currentMode === "advanced" ? "/api/upload-advanced" : "/api/upload";
|
||||
|
||||
const formData = new FormData();
|
||||
Array.from(files).forEach((file) => formData.append("files", file));
|
||||
|
||||
showProgress(currentMode.toUpperCase(), files.length);
|
||||
|
||||
fetch(endpoint, { method: "POST", body: formData })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
hideProgress();
|
||||
if (data && data.success) showResult(data);
|
||||
else showError(data?.error || "Unknown error");
|
||||
})
|
||||
.catch((e) => {
|
||||
hideProgress();
|
||||
showError(e?.message || "Request failed");
|
||||
});
|
||||
}
|
||||
|
||||
function showProgress(mode, count) {
|
||||
progress.classList.remove("d-none");
|
||||
progressText.classList.remove("d-none");
|
||||
progressText.textContent = `${mode} - ${count} files`;
|
||||
progressBar.style.width = "45%";
|
||||
}
|
||||
|
||||
function hideProgress() {
|
||||
progress.classList.add("d-none");
|
||||
progressText.classList.add("d-none");
|
||||
}
|
||||
|
||||
async function showResult(data) {
|
||||
const sizeKB = ((data.size || 0) / 1024).toFixed(1);
|
||||
|
||||
const rawHtml =
|
||||
typeof data.aio_html === "string" ? data.aio_html : String(data.aio_html ?? "");
|
||||
|
||||
const formattedHtml = await formatHtml(rawHtml);
|
||||
|
||||
result.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="fa-solid fa-circle-check mt-1 flex-shrink-0"></i>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mb-1">${escapeHtml(data.message ?? "Done")}</h5>
|
||||
<div class="mb-2">
|
||||
<small><code>${escapeHtml(data.original ?? "")}</code> → <strong>${sizeKB}KB</strong></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group-vertical btn-group-sm">
|
||||
<button class="btn btn-success btn-copy-sm" type="button">
|
||||
<i class="fa-solid fa-copy me-1"></i>Copy
|
||||
</button>
|
||||
<a
|
||||
href="data:text/html;charset=utf-8,${encodeURIComponent(rawHtml)}"
|
||||
download="aio.html"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
>
|
||||
<i class="fa-solid fa-download me-1"></i>Save
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card result-card">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0"><i class="fa-regular fa-eye me-2"></i>Preview</h6>
|
||||
</div>
|
||||
<iframe id="previewFrame" class="preview-iframe" style="height:70vh; min-height:520px;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card result-card">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="fa-solid fa-code me-2"></i>Code</h6>
|
||||
<button class="btn btn-sm btn-success btn-copy-all" type="button">
|
||||
<i class="fa-solid fa-copy me-1"></i>Copy All
|
||||
</button>
|
||||
</div>
|
||||
<pre class="code-preview mb-0" style="max-height:70vh; min-height:520px; overflow:auto;">
|
||||
<code class="language-markup">${escapeHtml(formattedHtml)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<button class="btn btn-outline-secondary px-4" type="button" id="btn-new">
|
||||
<i class="fa-solid fa-arrow-rotate-right me-2"></i>New
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
result.classList.remove("d-none");
|
||||
|
||||
const frame = result.querySelector("#previewFrame");
|
||||
if (frame) frame.srcdoc = rawHtml;
|
||||
|
||||
if (window.Prism) window.Prism.highlightAllUnder(result);
|
||||
|
||||
result.querySelectorAll(".btn-copy-sm, .btn-copy-all").forEach((btn) => {
|
||||
const originalHtml = btn.innerHTML;
|
||||
btn.addEventListener("click", () => {
|
||||
navigator.clipboard
|
||||
.writeText(rawHtml)
|
||||
.then(() => {
|
||||
btn.innerHTML = '<i class="fa-solid fa-check me-1"></i>Copied';
|
||||
setTimeout(() => (btn.innerHTML = originalHtml), 1200);
|
||||
})
|
||||
.catch(() => fallbackCopy(rawHtml, btn, originalHtml));
|
||||
});
|
||||
});
|
||||
|
||||
result.querySelector("#btn-new")?.addEventListener("click", () => {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
result.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
function fallbackCopy(text, btn, originalHtml) {
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
btn.innerHTML = '<i class="fa-solid fa-check me-1"></i>Copied';
|
||||
setTimeout(() => (btn.innerHTML = originalHtml), 1200);
|
||||
} catch {
|
||||
showError("Clipboard blocked by browser");
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
result.innerHTML = `
|
||||
<div class="alert alert-danger">
|
||||
<i class="fa-solid fa-circle-xmark me-2"></i>${escapeHtml(msg)}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button class="btn btn-outline-primary" type="button" id="btn-try">Try Again</button>
|
||||
</div>
|
||||
`;
|
||||
result.classList.remove("d-none");
|
||||
result.querySelector("#btn-try")?.addEventListener("click", () => location.reload());
|
||||
}
|
||||
});
|
||||
|
||||
async function formatHtml(html) {
|
||||
html = typeof html === "string" ? html : String(html ?? "");
|
||||
try {
|
||||
if (window.prettier && window.prettierPlugins?.html) {
|
||||
return await window.prettier.format(html, {
|
||||
parser: "html",
|
||||
plugins: [window.prettierPlugins.html],
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
text = typeof text === "string" ? text : String(text ?? "");
|
||||
const map = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
92
templates/main.html
Normal file
92
templates/main.html
Normal file
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AIO Generator</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css" rel="stylesheet" />
|
||||
<link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
.mode-wrap { display:flex; justify-content:center; gap:16px; flex-wrap:wrap; }
|
||||
.mode-btn {
|
||||
width: 240px;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.mode-btn i { margin-bottom: 10px; }
|
||||
.mode-btn small { opacity: .85; }
|
||||
|
||||
pre.code-preview { font-size: 12px; line-height: 1.35; }
|
||||
.result-card { border-radius: 16px; overflow: hidden; }
|
||||
.preview-iframe { width: 100%; border: 0; background: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-light">
|
||||
<div class="container-fluid py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xl-10 col-xxl-9">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-4 fw-bold mb-3">AIO Generator</h1>
|
||||
<p class="lead text-muted">One HTML file from many</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode Buttons -->
|
||||
<div class="mode-wrap mb-5">
|
||||
<button class="btn btn-outline-primary btn-lg mode-btn active" data-mode="basic" type="button">
|
||||
<i class="fa-solid fa-bolt fa-2x"></i>
|
||||
<div class="fw-semibold">Basic</div>
|
||||
<small class="text-muted">Fast</small>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-success btn-lg mode-btn" data-mode="advanced" type="button">
|
||||
<i class="fa-solid fa-gears fa-2x"></i>
|
||||
<div class="fw-semibold">Advanced</div>
|
||||
<small class="text-muted">CSS chains</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drop Zone -->
|
||||
<div id="drop-zone" class="drop-zone card border border-2 border-light rounded-4 p-5 mb-4 text-center">
|
||||
<i class="fa-solid fa-cloud-arrow-up fa-4x text-muted mb-4"></i>
|
||||
<h3 class="h4 fw-bold mb-3">Drop files here</h3>
|
||||
<p class="text-muted mb-4">or click to browse</p>
|
||||
<input type="file" id="file-input" multiple class="d-none" accept="*" />
|
||||
<label for="file-input" class="btn btn-primary btn-lg px-5">
|
||||
<i class="fa-solid fa-folder-open me-2"></i>Choose Files
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div id="progress" class="mb-4 d-none">
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"></div>
|
||||
</div>
|
||||
<small id="progress-text" class="text-muted d-block mt-2 text-center">Processing...</small>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div id="result"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-markup.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prettier@3.2.5/standalone.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prettier@3.2.5/plugins/html.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user