Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87854aee02 |
Binary file not shown.
@ -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.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
14
core/forms.py
Normal 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",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
45
core/migrations/0001_initial.py
Normal file
45
core/migrations/0001_initial.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
107
core/templates/core/bundle_detail.html
Normal file
107
core/templates/core/bundle_detail.html
Normal 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 %}
|
||||||
59
core/templates/core/bundle_list.html
Normal file
59
core/templates/core/bundle_list.html
Normal 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 %}
|
||||||
@ -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>
|
||||||
|
HTML Bundle to PDF
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
<a class="ghost-btn" href="{% url 'bundle_list' %}">All Bundles</a>
|
||||||
<p class="runtime">
|
<a class="ghost-btn" href="/admin/">Admin</a>
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
</div>
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
</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>
|
</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>
|
||||||
</main>
|
</div>
|
||||||
<footer>
|
<div class="col-lg-5">
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
<div class="stat-card mb-3">
|
||||||
</footer>
|
<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 class="app-footer text-center mt-4">Built for internal staff workflows · Continuous PDF output</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
206
core/views.py
206
core/views.py
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user