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
if EMAIL_USE_SSL:
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
# 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
# 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
# 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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Knowledge Base{% endblock %}</title>
{% block meta %}
{% if project_description %}
<meta name="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="twitter:image" content="{{ project_image_url }}">
{% endif %}
{% endblock %}
{% 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 }}">
{% block head %}{% endblock %}
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
{% block content %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</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" %}
{% block title %}{{ project_name }}{% endblock %}
{% block title %}{{ page_title }}{% endblock %}
{% block head %}
<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=Inter:wght@400;700&display=swap" rel="stylesheet">
<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>
{% 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 %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
<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 'bundle_list' %}">All Bundles</a>
<a class="ghost-btn" href="/admin/">Admin</a>
</div>
</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>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}
<div class="app-footer text-center mt-4">Built for internal staff workflows · Continuous PDF output</div>
</div>
{% endblock %}

View File

@ -1,7 +1,11 @@
from django.urls import path
from .views import home
from .views import bundle_detail, bundle_download, bundle_list, export_download, home
urlpatterns = [
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
import platform
from html.parser import HTMLParser
from io import BytesIO
import textwrap
from django import get_version as django_version
from django.shortcuts import render
from django.contrib import messages
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
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):
"""Render the landing screen with loader and environment details."""
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
file_errors = []
form = BundleUploadForm()
if request.method == "POST":
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 = {
"project_name": "New Style",
"agent_brand": agent_brand,
"django_version": django_version(),
"python_version": platform.python_version(),
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
"page_title": "HTML Bundle to PDF",
"page_description": "Upload multiple HTML files, arrange the order, and export a single PDF instantly.",
"form": form,
"file_errors": file_errors,
"bundles": bundles,
"exports": exports,
}
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
mysqlclient==2.2.7
python-dotenv==1.1.1
reportlab==4.2.0

View File

@ -1,4 +1,210 @@
/* Custom styles for the application */
body {
font-family: system-ui, -apple-system, sans-serif;
:root {
--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);
}