Add ability to use more pictures

This commit is contained in:
2026-08-30 10:48:26 +03:00
parent 334d65c4d8
commit 9540aea3be
8 changed files with 68 additions and 11 deletions
+2
View File
@@ -15,6 +15,8 @@ Open `http://localhost:8000`, create the first account, and publish a field note
Posts can include one JPEG, PNG, or WebP image up to 8 MB. Images are converted to optimized WebP files in the persistent Docker data volume; the article shows a compact preview that opens full-size when selected.
Long posts can also include multiple ordered step images. Upload them under **Step images**, then place `[[image:1]]`, `[[image:2]]`, and so on directly in the article text where each repair step needs an image. Each step image supports an optional caption and opens full-size when selected.
## Accounts and MFA
The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, scan the displayed QR code with any iPhone or Android TOTP authenticator, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish.
+1
View File
@@ -10,3 +10,4 @@
- [x] Add automated database backups and test restoration.
- [x] Configure TLS reverse proxy and production domain for `eternityproject.fi`.
- [x] Add validated post image uploads with an in-article preview and click-to-expand view.
- [x] Add ordered inline step images with captions for long-form repair and engineering posts.
Binary file not shown.
+56 -6
View File
@@ -91,6 +91,15 @@ def init_db():
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS post_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL,
filename TEXT UNIQUE NOT NULL,
caption TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(id)
);
"""
)
user_columns = {row["name"] for row in db.execute("PRAGMA table_info(users)")}
@@ -168,8 +177,7 @@ def post_from_request(post_id=None):
return title, unique_slug(title, post_id), excerpt, body, category, now
def save_post_image():
image_file = request.files.get("image")
def save_post_image(image_file):
if image_file is None or not image_file.filename:
return None
try:
@@ -189,6 +197,43 @@ def save_post_image():
return filename
def save_inline_images(post_id):
image_files = [image_file for image_file in request.files.getlist("images") if image_file.filename]
captions = request.form.getlist("image_captions")
if not image_files:
return
next_position = get_db().execute(
"SELECT COALESCE(MAX(position), 0) + 1 FROM post_images WHERE post_id = ?", (post_id,)
).fetchone()[0]
now = datetime.now(timezone.utc).isoformat()
for offset, image_file in enumerate(image_files):
filename = save_post_image(image_file)
caption = captions[offset].strip() if offset < len(captions) else ""
get_db().execute(
"INSERT INTO post_images (post_id, filename, caption, position, created_at) VALUES (?, ?, ?, ?, ?)",
(post_id, filename, caption, next_position + offset, now),
)
def article_blocks(body, images):
image_map = {str(index): image for index, image in enumerate(images, start=1)}
blocks = []
marker = re.compile(r"\[\[image:(\d+)\]\]")
cursor = 0
for match in marker.finditer(body):
if match.start() > cursor:
blocks.append(("text", body[cursor:match.start()]))
image = image_map.get(match.group(1))
if image is not None:
blocks.append(("image", image))
else:
blocks.append(("text", match.group(0)))
cursor = match.end()
if cursor < len(body):
blocks.append(("text", body[cursor:]))
return blocks
def valid_email(email):
return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None
@@ -249,7 +294,8 @@ def post(slug):
).fetchone()
if article is None:
abort(404)
return render_template("post.html", post=article)
images = get_db().execute("SELECT * FROM post_images WHERE post_id = ? ORDER BY position", (article["id"],)).fetchall()
return render_template("post.html", post=article, blocks=article_blocks(article["body"], images))
@app.get("/uploads/<path:filename>")
@@ -458,12 +504,14 @@ def write():
if request.method == "POST":
try:
title, slug, excerpt, body, category, now = post_from_request()
image_filename = save_post_image()
get_db().execute(
image_file = request.files.get("image")
image_filename = save_post_image(image_file) if image_file and image_file.filename else None
cursor = get_db().execute(
"""INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(title, slug, excerpt, body, category, image_filename, session["user_id"], now, now),
)
save_inline_images(cursor.lastrowid)
get_db().commit()
return redirect(url_for("post", slug=slug))
except ValueError as error:
@@ -482,11 +530,13 @@ def edit_post(slug):
if request.method == "POST":
try:
title, new_slug, excerpt, body, category, now = post_from_request(article["id"])
image_filename = save_post_image() or article["image_filename"]
image_file = request.files.get("image")
image_filename = save_post_image(image_file) if image_file and image_file.filename else article["image_filename"]
get_db().execute(
"""UPDATE posts SET title=?, slug=?, excerpt=?, body=?, category=?, image_filename=?, updated_at=? WHERE id=?""",
(title, new_slug, excerpt, body, category, image_filename, now, article["id"]),
)
save_inline_images(article["id"])
get_db().commit()
if image_filename != article["image_filename"] and article["image_filename"]:
(app.config["UPLOAD_FOLDER"] / article["image_filename"]).unlink(missing_ok=True)
BIN
View File
Binary file not shown.
+3 -2
View File
@@ -12,7 +12,8 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa
.hero { min-height:620px; padding:clamp(70px, 12vw, 150px) max(5vw, 24px) 75px; border-bottom:1px solid var(--ink); position:relative; overflow:hidden; }.signal-label, .section-head, .post-meta, .byline { font:11px "DM Mono", monospace; letter-spacing:.7px; }.signal-label { color:var(--orange); }.hero h1 { max-width:820px; margin:20px 0; font-size:clamp(52px, 9vw, 132px); line-height:.83; letter-spacing:0; font-weight:600; }.hero h1 em { font-family:Georgia, serif; font-weight:400; }.hero p { max-width:510px; color:var(--muted); font-size:18px; line-height:1.45; margin:28px 0; }.scope { position:absolute; right:8%; bottom:-95px; width:310px; height:310px; border:2px solid var(--orange); border-radius:50%; display:grid; place-items:center; font:100px Georgia, serif; color:var(--orange); }.scope span { position:absolute; width:100%; height:1px; background:var(--orange); transform:rotate(45deg); }.scope span:nth-child(2){ transform:rotate(90deg) }.scope span:nth-child(3){ transform:rotate(135deg) }
.journal, .editor-wrap { padding:40px max(5vw, 24px) 90px; }.section-head { display:flex; justify-content:space-between; padding-bottom:14px; border-bottom:1px solid var(--ink); margin-bottom:18px; }.post-grid { display:grid; grid-template-columns:repeat(3, 1fr); border-top:1px solid var(--ink); border-left:1px solid var(--ink); }.post-card { min-height:320px; padding:21px; border-right:1px solid var(--ink); border-bottom:1px solid var(--ink); display:flex; flex-direction:column; transition:background .2s; }.post-card:hover { background:var(--acid); }.post-meta { display:flex; justify-content:space-between; color:var(--muted); }.post-card h2 { font-size:29px; line-height:1; margin:28px 0 14px; }.post-card h2 a:hover { text-decoration:underline; text-decoration-thickness:2px; }.post-card p { line-height:1.45; margin:0; color:var(--muted); }.post-foot { margin-top:auto; display:flex; justify-content:space-between; align-items:center; padding-top:20px; font:10px "DM Mono", monospace; }.post-foot a { font:30px "DM Mono", monospace; }.empty-state { min-height:300px; padding:64px 0; text-align:center; border-bottom:1px solid var(--ink); }.empty-icon { font:42px "DM Mono", monospace; color:var(--orange); }.empty-state h2 { margin:10px 0 0; font-size:31px; }.empty-state p { color:var(--muted); margin:8px 0 24px; }
.auth-layout { min-height:calc(100vh - 160px); padding:70px max(10vw, 24px); display:grid; grid-template-columns:1fr minmax(280px, 390px); gap:10vw; align-items:center; }.auth-copy h1 { font-size:clamp(45px, 6vw, 82px); line-height:.9; margin:15px 0; }.auth-copy p { max-width:380px; font-size:18px; line-height:1.5; color:var(--muted); }.auth-form, .editor-form { display:grid; gap:19px; }.auth-form { padding:27px; border:1px solid var(--ink); background:rgba(241,241,233,.8); }.auth-form label, .editor-form label { display:grid; gap:7px; font:11px "DM Mono", monospace; }input, textarea { width:100%; resize:vertical; background:#fbfbf6; border:1px solid var(--ink); border-radius:0; padding:12px; color:var(--ink); font:15px "Space Grotesk", sans-serif; }input:focus, textarea:focus { outline:3px solid var(--acid); outline-offset:1px; }.form-switch { font-size:13px; color:var(--muted); }.form-switch a { color:var(--ink); text-decoration:underline; }.editor-form { max-width:900px; }.two-col { display:grid; grid-template-columns:1fr 2fr; gap:19px; }
.article { max-width:850px; margin:0 auto; padding:80px 24px 100px; }.article h1 { font-size:clamp(48px, 7vw, 90px); line-height:.93; margin:23px 0; }.article-lead { border-left:4px solid var(--orange); padding-left:18px; max-width:720px; font-size:23px; line-height:1.35; }.byline { padding:20px 0; border-top:1px solid var(--ink); border-bottom:1px solid var(--ink); margin:35px 0; }.article-image-button { display:block; max-width:680px; width:100%; margin:0 0 30px; padding:0; border:0; background:none; cursor:zoom-in; }.article-image { display:block; width:100%; max-height:430px; object-fit:cover; border:1px solid var(--ink); }.image-dialog { width:min(94vw, 1200px); max-width:none; padding:0; border:1px solid var(--ink); background:var(--ink); }.image-dialog::backdrop { background:rgba(17,33,43,.85); }.image-dialog img { display:block; width:100%; max-height:88vh; object-fit:contain; }.dialog-close { position:absolute; top:10px; right:10px; border:1px solid var(--paper); background:var(--ink); color:var(--paper); padding:8px 10px; font:12px "DM Mono", monospace; cursor:pointer; }.article-body { max-width:680px; white-space:pre-wrap; font-size:18px; line-height:1.7; margin-bottom:35px; }
.step-images { margin:0; padding:16px; border:1px solid var(--ink); }.step-images legend { padding:0 5px; font:11px "DM Mono", monospace; }.step-images p { margin:0 0 15px; color:var(--muted); font-size:13px; line-height:1.45; }.step-images code { color:var(--orange); font:12px "DM Mono", monospace; }.step-image-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px; }.add-step-image { justify-self:start; border:1px solid var(--ink); background:transparent; padding:8px 10px; font:11px "DM Mono", monospace; cursor:pointer; }.add-step-image:hover { background:var(--acid); }
.article { max-width:850px; margin:0 auto; padding:80px 24px 100px; }.article h1 { font-size:clamp(48px, 7vw, 90px); line-height:.93; margin:23px 0; }.article-lead { border-left:4px solid var(--orange); padding-left:18px; max-width:720px; font-size:23px; line-height:1.35; }.byline { padding:20px 0; border-top:1px solid var(--ink); border-bottom:1px solid var(--ink); margin:35px 0; }.article-image-button { display:block; max-width:680px; width:100%; margin:0 0 30px; padding:0; border:0; background:none; cursor:zoom-in; }.article-image { display:block; width:100%; max-height:430px; object-fit:cover; border:1px solid var(--ink); }.inline-image { max-width:680px; margin:28px 0; }.inline-image .article-image-button { margin:0; }.inline-image figcaption { padding-top:8px; color:var(--muted); font:12px "DM Mono", monospace; }.image-dialog { width:min(94vw, 1200px); max-width:none; padding:0; border:1px solid var(--ink); background:var(--ink); }.image-dialog::backdrop { background:rgba(17,33,43,.85); }.image-dialog img { display:block; width:100%; max-height:88vh; object-fit:contain; }.dialog-close { position:absolute; top:10px; right:10px; border:1px solid var(--paper); background:var(--ink); color:var(--paper); padding:8px 10px; font:12px "DM Mono", monospace; cursor:pointer; }.article-body { max-width:680px; font-size:18px; line-height:1.7; margin-bottom:35px; }.article-text { white-space:pre-wrap; }
.flash { margin:16px max(5vw, 24px) 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }.flash.error { border-color:var(--orange); background:#ffe3d9; }.flash.success { background:var(--acid); }footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); }
.mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; }.account-row .email { display:block; margin-top:5px; color:var(--muted); overflow-wrap:anywhere; }
@media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } }
@media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col, .step-image-row { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } }
+2
View File
@@ -8,7 +8,9 @@
<div class="two-col"><label>Category<input name="category" value="{{ post.category if post else 'Engineering' }}" placeholder="Engineering"></label><label>Summary<input name="excerpt" required value="{{ post.excerpt if post else '' }}" placeholder="The one-paragraph signal"></label></div>
<label>Article<textarea name="body" required rows="16" placeholder="Write in plain text. Paragraph breaks are preserved.">{{ post.body if post else '' }}</textarea></label>
<label>Article image<input name="image" type="file" accept="image/jpeg,image/png,image/webp">{% if post and post.image_filename %}<small>Leave empty to retain the current image.</small>{% endif %}</label>
<fieldset class="step-images"><legend>Step images</legend><p>Upload images for individual repair steps, then place <code>[[image:1]]</code>, <code>[[image:2]]</code>, and so on in the article text.</p><div id="step-image-list"><div class="step-image-row"><label>Image<input name="images" type="file" accept="image/jpeg,image/png,image/webp"></label><label>Caption<input name="image_captions" type="text" placeholder="Optional caption"></label></div></div><button class="add-step-image" type="button">Add another step image</button></fieldset>
<button class="button" type="submit">Publish transmission <span aria-hidden="true">→</span></button>
</form>
</section>
<script>document.querySelector('.add-step-image').addEventListener('click',()=>{const row=document.querySelector('.step-image-row').cloneNode(true);row.querySelectorAll('input').forEach(input=>input.value='');document.querySelector('#step-image-list').append(row)});</script>
{% endblock %}
+3 -2
View File
@@ -1,6 +1,7 @@
{% extends 'base.html' %}
{% block title %}{{ post.title }} | Eternity Project{% endblock %}
{% block content %}
<article class="article"><div class="post-meta"><span>{{ post.category }}</span><time datetime="{{ post.created_at }}">{{ post.created_at[:10] }}</time></div><h1>{{ post.title }}</h1><p class="article-lead">{{ post.excerpt }}</p><div class="byline">WRITTEN BY {{ post.username }}{% if post.updated_at != post.created_at %} / UPDATED {{ post.updated_at[:10] }}{% endif %}</div>{% if post.image_filename %}<button class="article-image-button" type="button" aria-label="Open full-size article image"><img class="article-image" src="{{ url_for('uploaded_image', filename=post.image_filename) }}" alt="Image for {{ post.title }}"></button><dialog class="image-dialog"><button class="dialog-close" type="button" aria-label="Close full-size image">Close</button><img src="{{ url_for('uploaded_image', filename=post.image_filename) }}" alt="Full-size image for {{ post.title }}"></dialog>{% endif %}<div class="article-body">{{ post.body }}</div>{% if current_user and current_user.id == post.author_id %}<a class="button compact" href="{{ url_for('edit_post', slug=post.slug) }}">Edit post</a>{% endif %}</article>
{% if post.image_filename %}<script>const imageButton=document.querySelector('.article-image-button'),imageDialog=document.querySelector('.image-dialog');imageButton.addEventListener('click',()=>imageDialog.showModal());imageDialog.querySelector('.dialog-close').addEventListener('click',()=>imageDialog.close());imageDialog.addEventListener('click',event=>{if(event.target===imageDialog)imageDialog.close()});</script>{% endif %}
<article class="article"><div class="post-meta"><span>{{ post.category }}</span><time datetime="{{ post.created_at }}">{{ post.created_at[:10] }}</time></div><h1>{{ post.title }}</h1><p class="article-lead">{{ post.excerpt }}</p><div class="byline">WRITTEN BY {{ post.username }}{% if post.updated_at != post.created_at %} / UPDATED {{ post.updated_at[:10] }}{% endif %}</div>{% if post.image_filename %}<button class="article-image-button" type="button" data-image="{{ url_for('uploaded_image', filename=post.image_filename) }}" data-alt="Image for {{ post.title }}"><img class="article-image" src="{{ url_for('uploaded_image', filename=post.image_filename) }}" alt="Image for {{ post.title }}"></button>{% endif %}<div class="article-body">{% for kind, content in blocks %}{% if kind == 'text' %}<div class="article-text">{{ content }}</div>{% else %}<figure class="inline-image"><button class="article-image-button" type="button" data-image="{{ url_for('uploaded_image', filename=content.filename) }}" data-alt="{{ content.caption or 'Article step image' }}"><img class="article-image" src="{{ url_for('uploaded_image', filename=content.filename) }}" alt="{{ content.caption or 'Article step image' }}"></button>{% if content.caption %}<figcaption>{{ content.caption }}</figcaption>{% endif %}</figure>{% endif %}{% endfor %}</div>{% if current_user and current_user.id == post.author_id %}<a class="button compact" href="{{ url_for('edit_post', slug=post.slug) }}">Edit post</a>{% endif %}</article>
<dialog class="image-dialog"><button class="dialog-close" type="button" aria-label="Close full-size image">Close</button><img alt=""></dialog>
<script>const imageDialog=document.querySelector('.image-dialog'),dialogImage=imageDialog.querySelector('img');document.querySelectorAll('.article-image-button').forEach(button=>button.addEventListener('click',()=>{dialogImage.src=button.dataset.image;dialogImage.alt=button.dataset.alt;imageDialog.showModal()}));imageDialog.querySelector('.dialog-close').addEventListener('click',()=>imageDialog.close());imageDialog.addEventListener('click',event=>{if(event.target===imageDialog)imageDialog.close()});</script>
{% endblock %}