Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
87854aee02 v1 2026-02-18 22:51:16 +00:00
20 changed files with 837 additions and 160 deletions

View File

@ -176,6 +176,10 @@ CONTACT_EMAIL_TO = [
# When both TLS and SSL flags are enabled, prefer SSL explicitly # When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL: if EMAIL_USE_SSL:
EMAIL_USE_TLS = False EMAIL_USE_TLS = False
# Increase upload limits for multiple HTML files
DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

Binary file not shown.

View File

@ -1,3 +1,23 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import HtmlBundle, HtmlDocument, HtmlExport
@admin.register(HtmlBundle)
class HtmlBundleAdmin(admin.ModelAdmin):
list_display = ("title", "created_at")
search_fields = ("title",)
ordering = ("-created_at",)
@admin.register(HtmlDocument)
class HtmlDocumentAdmin(admin.ModelAdmin):
list_display = ("original_name", "bundle", "order")
search_fields = ("original_name",)
list_filter = ("bundle",)
@admin.register(HtmlExport)
class HtmlExportAdmin(admin.ModelAdmin):
list_display = ("file_name", "bundle", "created_at")
list_filter = ("bundle",)

14
core/forms.py Normal file
View File

@ -0,0 +1,14 @@
from django import forms
class BundleUploadForm(forms.Form):
title = forms.CharField(
label="Bundle title",
required=False,
widget=forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Q2 policy updates",
}
),
)

View File

@ -0,0 +1,45 @@
# Generated by Django 5.2.7 on 2026-02-18 21:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='HtmlBundle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(blank=True, max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='HtmlDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('original_name', models.CharField(max_length=255)),
('order', models.PositiveIntegerField(default=1)),
('content_text', models.TextField()),
('bundle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='core.htmlbundle')),
],
options={
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='HtmlExport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('file_name', models.CharField(max_length=255)),
('bundle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exports', to='core.htmlbundle')),
],
),
]

View File

@ -1,3 +1,31 @@
from django.db import models from django.db import models
# Create your models here.
class HtmlBundle(models.Model):
title = models.CharField(max_length=200, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return self.title or f"Bundle {self.pk}"
class HtmlDocument(models.Model):
bundle = models.ForeignKey(HtmlBundle, related_name="documents", on_delete=models.CASCADE)
original_name = models.CharField(max_length=255)
order = models.PositiveIntegerField(default=1)
content_text = models.TextField()
class Meta:
ordering = ["order", "id"]
def __str__(self) -> str:
return f"{self.original_name} ({self.bundle_id})"
class HtmlExport(models.Model):
bundle = models.ForeignKey(HtmlBundle, related_name="exports", on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
file_name = models.CharField(max_length=255)
def __str__(self) -> str:
return self.file_name

View File

@ -3,7 +3,9 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Knowledge Base{% endblock %}</title> <title>{% block title %}Knowledge Base{% endblock %}</title>
{% block meta %}
{% if project_description %} {% if project_description %}
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}"> <meta property="og:description" content="{{ project_description }}">
@ -13,13 +15,19 @@
<meta property="og:image" content="{{ project_image_url }}"> <meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}"> <meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} {% endif %}
{% endblock %}
{% load static %} {% load static %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body class="{% block body_class %}{% endblock %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta %}
<meta name="description" content="{{ page_description }}">
<meta property="og:description" content="{{ page_description }}">
<meta property="twitter:description" content="{{ page_description }}">
{% endblock %}
{% block content %}
<div class="container py-4 py-lg-5">
<div class="app-shell">
<nav class="app-nav d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3">
<div class="brand-mark">
<span class="brand-dot"></span>
HTML Bundle to PDF
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<a class="ghost-btn" href="{% url 'home' %}">Home</a>
<a class="ghost-btn" href="{% url 'bundle_list' %}">All bundles</a>
<a class="ghost-btn" href="/admin/">Admin</a>
</div>
</nav>
<div class="p-4">
{% if messages %}
<div class="mb-3">
{% for message in messages %}
<div class="alert alert-custom" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div>
<h1 class="mb-1">{{ bundle.title|default:"Untitled bundle" }}</h1>
<p class="muted-text mb-0">Created {{ bundle.created_at|date:"M d, Y · H:i" }} · {{ documents|length }} HTML files</p>
</div>
<a class="primary-btn" href="{% url 'bundle_download' bundle.id %}">Generate PDF</a>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="workflow-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="section-title mb-0">Order your files</h2>
<span class="pill">Lower number = earlier</span>
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="update_order">
<div class="d-flex flex-column gap-3">
{% for document in documents %}
<div class="list-card">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">{{ document.original_name }}</div>
<div class="small muted-text">{{ document.content_text|truncatechars:120 }}</div>
</div>
<div>
<label class="form-label small mb-1">Order</label>
<input class="order-input" type="number" name="order_{{ document.id }}" value="{{ document.order }}" min="1">
</div>
</div>
</div>
{% empty %}
<div class="file-preview">No documents in this bundle yet.</div>
{% endfor %}
</div>
<div class="d-flex flex-wrap gap-2 mt-4">
<button class="ghost-btn" type="submit">Update order</button>
<span class="muted-text align-self-center">Changes affect the next PDF export.</span>
</div>
</form>
</div>
</div>
<div class="col-lg-5">
<div class="list-card mb-3">
<h3 class="section-title mb-2">Export history</h3>
{% if exports %}
<div class="d-flex flex-column gap-3">
{% for export in exports %}
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ export.file_name }}</div>
<div class="small muted-text">{{ export.created_at|date:"M d, Y · H:i" }}</div>
</div>
<a class="ghost-btn" href="{% url 'export_download' export.id %}">Download</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted-text mb-0">No exports yet. Generate your first PDF to start history.</p>
{% endif %}
</div>
<div class="list-card">
<h3 class="section-title mb-2">Next steps</h3>
<p class="muted-text mb-2">Need quick changes? Update the order and export again. The PDF is always a single, continuous document.</p>
<a class="primary-btn" href="{% url 'bundle_download' bundle.id %}">Download PDF</a>
</div>
</div>
</div>
</div>
</div>
<div class="app-footer text-center mt-4">Bundle created · Adjust order · Export anytime</div>
</div>
{% endblock %}

View File

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta %}
<meta name="description" content="{{ page_description }}">
<meta property="og:description" content="{{ page_description }}">
<meta property="twitter:description" content="{{ page_description }}">
{% endblock %}
{% block content %}
<div class="container py-4 py-lg-5">
<div class="app-shell">
<nav class="app-nav d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3">
<div class="brand-mark">
<span class="brand-dot"></span>
HTML Bundle to PDF
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<a class="ghost-btn" href="{% url 'home' %}">Home</a>
<a class="ghost-btn" href="/admin/">Admin</a>
</div>
</nav>
<div class="p-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div>
<h1 class="mb-1">All bundles</h1>
<p class="muted-text mb-0">Review and re-download any previously generated bundle.</p>
</div>
<a class="primary-btn" href="{% url 'home' %}#upload">Create new bundle</a>
</div>
{% if bundles %}
<div class="row g-3">
{% for bundle in bundles %}
<div class="col-lg-6">
<div class="list-card h-100">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<h3 class="h5 mb-1">{{ bundle.title|default:"Untitled bundle" }}</h3>
<div class="small muted-text">{{ bundle.created_at|date:"M d, Y · H:i" }}</div>
<div class="small muted-text">{{ bundle.documents.count }} HTML files</div>
</div>
<a class="ghost-btn" href="{% url 'bundle_detail' bundle.id %}">Open</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="list-card">
<p class="muted-text mb-0">No bundles yet. Upload HTML files to create your first bundle.</p>
</div>
{% endif %}
</div>
</div>
<div class="app-footer text-center mt-4">Need a quick export? Create a new bundle from the home page.</div>
</div>
{% endblock %}

View File

@ -1,145 +1,152 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %} {% block title %}{{ page_title }}{% endblock %}
{% block head %} {% block meta %}
<link rel="preconnect" href="https://fonts.googleapis.com"> <meta name="description" content="{{ page_description }}">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <meta property="og:description" content="{{ page_description }}">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> <meta property="twitter:description" content="{{ page_description }}">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main> <div class="container py-4 py-lg-5">
<div class="card"> <div class="app-shell">
<h1>Analyzing your requirements and generating your app…</h1> <nav class="app-nav d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="brand-mark">
<span class="sr-only">Loading…</span> <span class="brand-dot"></span>
</div> HTML Bundle to PDF
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p> </div>
<p class="hint">This page will refresh automatically as the plan is implemented.</p> <div class="d-flex flex-wrap align-items-center gap-2">
<p class="runtime"> <a class="ghost-btn" href="{% url 'bundle_list' %}">All Bundles</a>
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code> <a class="ghost-btn" href="/admin/">Admin</a>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code> </div>
</p> </nav>
<section class="hero m-3 m-lg-4">
<div class="row align-items-center g-4">
<div class="col-lg-7 hero-content">
<span class="pill">Internal doc bundler</span>
<h1 class="mt-3">Combine multiple HTML files into a single PDF in minutes.</h1>
<p class="mt-3">
Upload multiple HTML files, set the order, and download one clean PDF. Built for staff who
need to bundle text-heavy documents without manual copy/paste.
</p>
<div class="d-flex flex-wrap gap-2 mt-4">
<a class="primary-btn" href="#upload">Start a bundle</a>
<a class="ghost-btn" href="#recent">View recent exports</a>
</div>
</div>
<div class="col-lg-5">
<div class="stat-card mb-3">
<div class="d-flex justify-content-between">
<span class="muted-text">Average prep time</span>
<span class="badge-soft">Under 3 mins</span>
</div>
<h3 class="mt-2">Upload → reorder → export</h3>
<p class="muted-text mb-0">One continuous PDF with clean spacing.</p>
</div>
<div class="file-preview">
<strong>What gets bundled</strong>
<div class="mt-2">Policies · Updates · Release notes · Staff memos</div>
</div>
</div>
</div>
</section>
<section id="upload" class="m-3 m-lg-4">
{% if messages %}
<div class="mb-3">
{% for message in messages %}
<div class="alert alert-custom" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="workflow-card">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div>
<h2 class="section-title mb-2">Create a new bundle</h2>
<p class="muted-text mb-0">Drop in your HTML files. We will keep their order and let you refine it on the next screen.</p>
</div>
<span class="pill">Continuous PDF output</span>
</div>
<form class="mt-4" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row g-3">
<div class="col-lg-5">
<label class="form-label fw-semibold">{{ form.title.label }}</label>
{{ form.title }}
{% if form.title.errors %}
<div class="text-danger small mt-1">{{ form.title.errors|join:", " }}</div>
{% endif %}
</div>
<div class="col-lg-7">
<label class="form-label fw-semibold">HTML files</label>
<div class="upload-drop">
<input type="file" name="files" class="form-control" multiple accept=".html,.htm">
<div class="small muted-text mt-2">HTML only · We extract the text and combine it in order.</div>
</div>
{% if file_errors %}
<div class="text-danger small mt-1">{{ file_errors|join:", " }}</div>
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-4">
<button type="submit" class="primary-btn">Create bundle</button>
<span class="muted-text align-self-center">You can reorder before exporting.</span>
</div>
</form>
</div>
</section>
<section id="recent" class="m-3 m-lg-4">
<div class="row g-3">
<div class="col-lg-6">
<div class="list-card h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="section-title mb-0">Recent bundles</h3>
<a class="ghost-btn" href="{% url 'bundle_list' %}">See all</a>
</div>
{% if bundles %}
<div class="d-flex flex-column gap-3">
{% for bundle in bundles %}
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ bundle.title|default:"Untitled bundle" }}</div>
<div class="small muted-text">{{ bundle.created_at|date:"M d, Y · H:i" }} · {{ bundle.documents.count }} files</div>
</div>
<a class="ghost-btn" href="{% url 'bundle_detail' bundle.id %}">Open</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted-text mb-0">No bundles yet. Upload HTML files to create your first bundle.</p>
{% endif %}
</div>
</div>
<div class="col-lg-6">
<div class="list-card h-100">
<h3 class="section-title mb-3">Latest exports</h3>
{% if exports %}
<div class="d-flex flex-column gap-3">
{% for export in exports %}
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ export.bundle.title|default:"Untitled bundle" }}</div>
<div class="small muted-text">{{ export.created_at|date:"M d, Y · H:i" }}</div>
</div>
<a class="ghost-btn" href="{% url 'export_download' export.id %}">Download</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted-text mb-0">No exports yet. Generate your first PDF to see history here.</p>
{% endif %}
</div>
</div>
</div>
</section>
</div> </div>
</main> <div class="app-footer text-center mt-4">Built for internal staff workflows · Continuous PDF output</div>
<footer> </div>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,11 @@
from django.urls import path from django.urls import path
from .views import home from .views import bundle_detail, bundle_download, bundle_list, export_download, home
urlpatterns = [ urlpatterns = [
path("", home, name="home"), path("", home, name="home"),
path("bundles/", bundle_list, name="bundle_list"),
path("bundles/<int:bundle_id>/", bundle_detail, name="bundle_detail"),
path("bundles/<int:bundle_id>/download/", bundle_download, name="bundle_download"),
path("exports/<int:export_id>/download/", export_download, name="export_download"),
] ]

View File

@ -1,25 +1,199 @@
import os from html.parser import HTMLParser
import platform from io import BytesIO
import textwrap
from django import get_version as django_version from django.contrib import messages
from django.shortcuts import render from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from .forms import BundleUploadForm
from .models import HtmlBundle, HtmlDocument, HtmlExport
class _HtmlTextExtractor(HTMLParser):
block_tags = {
"p",
"div",
"br",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"section",
"article",
"header",
"footer",
}
def __init__(self):
super().__init__()
self.parts = []
def handle_starttag(self, tag, attrs):
if tag in self.block_tags:
self.parts.append("\n")
def handle_endtag(self, tag):
if tag in self.block_tags:
self.parts.append("\n")
def handle_data(self, data):
if data.strip():
self.parts.append(data)
def _extract_text_from_html(html_bytes: bytes) -> str:
html_text = html_bytes.decode("utf-8", errors="replace")
parser = _HtmlTextExtractor()
parser.feed(html_text)
raw_text = "".join(parser.parts)
lines = [line.strip() for line in raw_text.splitlines()]
cleaned = "\n".join([line for line in lines if line])
return cleaned
def _build_pdf(bundle: HtmlBundle) -> BytesIO:
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=letter)
width, height = letter
margin = 72
line_height = 14
y = height - margin
documents = bundle.documents.all()
for document in documents:
text = document.content_text or ""
paragraphs = text.splitlines() or [""]
for paragraph in paragraphs:
wrapped = textwrap.wrap(paragraph, width=95) or [""]
for line in wrapped:
if y < margin:
pdf.showPage()
y = height - margin
pdf.setFont("Helvetica", 11)
pdf.drawString(margin, y, line)
y -= line_height
y -= 6
y -= 12
pdf.save()
buffer.seek(0)
return buffer
def home(request): def home(request):
"""Render the landing screen with loader and environment details.""" file_errors = []
host_name = request.get_host().lower() form = BundleUploadForm()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" if request.method == "POST":
now = timezone.now() form = BundleUploadForm(request.POST)
files = request.FILES.getlist("files")
if form.is_valid():
if not files:
file_errors.append("No files were received. Please ensure you have selected files and try again.")
else:
invalid_files = [f.name for f in files if not f.name.lower().endswith((".html", ".htm"))]
if invalid_files:
file_errors.append(
f"Only .html or .htm files are supported. Invalid: {', '.join(invalid_files[:3])}"
)
else:
title = form.cleaned_data.get("title", "").strip()
bundle = HtmlBundle.objects.create(title=title)
for index, uploaded in enumerate(files, start=1):
content_text = _extract_text_from_html(uploaded.read())
HtmlDocument.objects.create(
bundle=bundle,
original_name=uploaded.name,
order=index,
content_text=content_text,
)
messages.success(request, f"Bundle '{bundle.title or 'Untitled'}' created with {len(files)} files.")
return redirect("bundle_detail", bundle_id=bundle.id)
bundles = HtmlBundle.objects.order_by("-created_at")[:5]
exports = HtmlExport.objects.select_related("bundle").order_by("-created_at")[:5]
context = { context = {
"project_name": "New Style", "page_title": "HTML Bundle to PDF",
"agent_brand": agent_brand, "page_description": "Upload multiple HTML files, arrange the order, and export a single PDF instantly.",
"django_version": django_version(), "form": form,
"python_version": platform.python_version(), "file_errors": file_errors,
"current_time": now, "bundles": bundles,
"host_name": host_name, "exports": exports,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, "core/index.html", context) return render(request, "core/index.html", context)
def bundle_list(request):
bundles = HtmlBundle.objects.order_by("-created_at")
context = {
"page_title": "All Bundles",
"page_description": "Browse recent HTML bundles and download combined PDFs.",
"bundles": bundles,
}
return render(request, "core/bundle_list.html", context)
def bundle_detail(request, bundle_id: int):
bundle = get_object_or_404(HtmlBundle, pk=bundle_id)
documents = bundle.documents.all()
exports = bundle.exports.order_by("-created_at")
if request.method == "POST" and request.POST.get("action") == "update_order":
updates = []
for document in documents:
field_name = f"order_{document.id}"
raw_value = request.POST.get(field_name)
if raw_value is None:
continue
try:
order_value = int(raw_value)
if order_value < 1:
raise ValueError
except ValueError:
messages.error(request, "Order values must be positive numbers.")
return redirect("bundle_detail", bundle_id=bundle.id)
document.order = order_value
updates.append(document)
if updates:
HtmlDocument.objects.bulk_update(updates, ["order"])
messages.success(request, "Order updated.")
return redirect("bundle_detail", bundle_id=bundle.id)
context = {
"page_title": bundle.title or "Untitled bundle",
"page_description": "Review your bundle and generate a combined PDF.",
"bundle": bundle,
"documents": documents,
"exports": exports,
}
return render(request, "core/bundle_detail.html", context)
def bundle_download(request, bundle_id: int):
bundle = get_object_or_404(HtmlBundle, pk=bundle_id)
slug = slugify(bundle.title) or "bundle"
timestamp = timezone.now().strftime("%Y%m%d-%H%M")
file_name = f"{slug}-{timestamp}.pdf"
HtmlExport.objects.create(bundle=bundle, file_name=file_name)
pdf_buffer = _build_pdf(bundle)
response = HttpResponse(pdf_buffer.getvalue(), content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
return response
def export_download(request, export_id: int):
export = get_object_or_404(HtmlExport, pk=export_id)
pdf_buffer = _build_pdf(export.bundle)
response = HttpResponse(pdf_buffer.getvalue(), content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{export.file_name}"'
return response

View File

@ -1,3 +1,4 @@
Django==5.2.7 Django==5.2.7
mysqlclient==2.2.7 mysqlclient==2.2.7
python-dotenv==1.1.1 python-dotenv==1.1.1
reportlab==4.2.0

View File

@ -1,4 +1,210 @@
/* Custom styles for the application */ /* Custom styles for the application */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --ink-900: #0b1f24;
--ink-700: #1e3a40;
--ink-500: #456870;
--primary-600: #1f6f5b;
--primary-500: #2f8f76;
--accent-500: #f2c94c;
--accent-600: #e7b93a;
--coral-500: #e07a5f;
--sand-50: #f6f4ef;
--sand-100: #efeae2;
--surface-0: #ffffff;
--shadow-soft: 0 16px 40px rgba(11, 31, 36, 0.12);
--shadow-card: 0 12px 28px rgba(11, 31, 36, 0.1);
--radius-xl: 28px;
--radius-lg: 20px;
--radius-md: 14px;
--radius-sm: 10px;
--gradient-hero: linear-gradient(120deg, rgba(47, 143, 118, 0.15), rgba(242, 201, 76, 0.2), rgba(224, 122, 95, 0.12));
}
body {
font-family: "DM Sans", system-ui, -apple-system, sans-serif;
color: var(--ink-900);
background: radial-gradient(circle at top left, #f7f5f2 0%, #f4f0e8 45%, #f1e8dc 100%);
min-height: 100vh;
}
h1, h2, h3, h4, h5 {
font-family: "Space Grotesk", "DM Sans", sans-serif;
color: var(--ink-900);
letter-spacing: -0.02em;
}
a {
color: inherit;
text-decoration: none;
}
.app-shell {
background: var(--surface-0);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-soft);
border: 1px solid rgba(30, 58, 64, 0.08);
}
.app-nav {
padding: 1.2rem 1.8rem;
border-bottom: 1px solid rgba(30, 58, 64, 0.08);
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
}
.brand-mark {
display: inline-flex;
align-items: center;
gap: 0.65rem;
font-weight: 700;
font-size: 1.1rem;
}
.brand-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-500), var(--accent-500));
box-shadow: 0 0 0 6px rgba(47, 143, 118, 0.15);
}
.hero {
padding: 4.5rem 2.5rem 3.5rem;
border-radius: var(--radius-xl);
position: relative;
overflow: hidden;
background: var(--gradient-hero);
}
.hero::after {
content: "";
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Ccircle cx='24' cy='24' r='2.2' fill='rgba(30,58,64,0.15)'/%3E%3Ccircle cx='96' cy='58' r='1.8' fill='rgba(30,58,64,0.12)'/%3E%3Ccircle cx='54' cy='94' r='2' fill='rgba(30,58,64,0.1)'/%3E%3C/svg%3E");
opacity: 0.35;
pointer-events: none;
}
.hero-content {
position: relative;
z-index: 1;
}
.hero h1 {
font-size: clamp(2.6rem, 3vw + 1.2rem, 3.6rem);
line-height: 1.1;
}
.hero p {
font-size: 1.1rem;
color: var(--ink-700);
}
.primary-btn {
background: var(--primary-600);
border: none;
color: #ffffff;
font-weight: 600;
padding: 0.75rem 1.4rem;
border-radius: 999px;
box-shadow: 0 12px 18px rgba(31, 111, 91, 0.24);
}
.primary-btn:hover {
background: var(--primary-500);
color: #ffffff;
}
.ghost-btn {
border-radius: 999px;
border: 1px solid rgba(31, 111, 91, 0.25);
color: var(--primary-600);
padding: 0.7rem 1.2rem;
font-weight: 600;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
background: rgba(31, 111, 91, 0.12);
color: var(--ink-700);
font-size: 0.85rem;
font-weight: 600;
}
.workflow-card {
border-radius: var(--radius-lg);
border: 1px solid rgba(30, 58, 64, 0.1);
background: var(--surface-0);
box-shadow: var(--shadow-card);
padding: 1.8rem;
}
.section-title {
font-size: 1.5rem;
}
.file-preview {
background: var(--sand-50);
border: 1px dashed rgba(30, 58, 64, 0.15);
border-radius: var(--radius-md);
padding: 1rem;
font-size: 0.95rem;
color: var(--ink-500);
}
.stat-card {
padding: 1rem 1.2rem;
border-radius: var(--radius-md);
border: 1px solid rgba(30, 58, 64, 0.1);
background: rgba(255, 255, 255, 0.8);
}
.list-card {
border-radius: var(--radius-md);
border: 1px solid rgba(30, 58, 64, 0.08);
padding: 1.2rem;
background: var(--surface-0);
}
.muted-text {
color: var(--ink-500);
}
.order-input {
width: 72px;
border-radius: var(--radius-sm);
border: 1px solid rgba(30, 58, 64, 0.2);
padding: 0.4rem 0.5rem;
}
.app-footer {
color: var(--ink-500);
font-size: 0.9rem;
}
.badge-soft {
background: rgba(242, 201, 76, 0.2);
color: var(--ink-700);
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-weight: 600;
font-size: 0.8rem;
}
.upload-drop {
border-radius: var(--radius-md);
border: 2px dashed rgba(47, 143, 118, 0.4);
padding: 1.2rem;
background: rgba(47, 143, 118, 0.06);
}
.alert-custom {
border-radius: var(--radius-md);
border: 1px solid rgba(31, 111, 91, 0.2);
background: rgba(31, 111, 91, 0.08);
color: var(--ink-700);
} }