Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
334d65c4d8 | ||
|
|
987774a320 |
@@ -13,6 +13,8 @@ docker compose up --build -d
|
|||||||
|
|
||||||
Open `http://localhost:8000`, create the first account, and publish a field note. The first request initializes the database automatically.
|
Open `http://localhost:8000`, create the first account, and publish a field note. The first request initializes the database automatically.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Accounts and MFA
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -8,4 +8,5 @@
|
|||||||
- [x] Rotate the initial `admin` password and enroll its authenticator.
|
- [x] Rotate the initial `admin` password and enroll its authenticator.
|
||||||
- [x] Add CSRF protection to all state-changing forms.
|
- [x] Add CSRF protection to all state-changing forms.
|
||||||
- [x] Add automated database backups and test restoration.
|
- [x] Add automated database backups and test restoration.
|
||||||
- [x] Configure TLS reverse proxy and production domain for `eternityproject.fi`.
|
- [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.
|
||||||
Binary file not shown.
@@ -10,20 +10,25 @@ from functools import wraps
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from secrets import token_urlsafe
|
from secrets import token_urlsafe
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for
|
from flask import Flask, abort, flash, g, redirect, render_template, request, send_from_directory, session, url_for
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
import pyotp
|
import pyotp
|
||||||
import qrcode
|
import qrcode
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
DATABASE = Path(os.environ.get("DATABASE_PATH", BASE_DIR / "data" / "eternity.db"))
|
DATABASE = Path(os.environ.get("DATABASE_PATH", BASE_DIR / "data" / "eternity.db"))
|
||||||
|
UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", DATABASE.parent / "uploads"))
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SECRET_KEY=os.environ.get("SECRET_KEY", "change-this-secret-before-production"),
|
SECRET_KEY=os.environ.get("SECRET_KEY", "change-this-secret-before-production"),
|
||||||
DATABASE=DATABASE,
|
DATABASE=DATABASE,
|
||||||
|
UPLOAD_FOLDER=UPLOAD_FOLDER,
|
||||||
|
MAX_CONTENT_LENGTH=8 * 1024 * 1024,
|
||||||
MAIL_HOST=os.environ.get("MAIL_HOST"),
|
MAIL_HOST=os.environ.get("MAIL_HOST"),
|
||||||
MAIL_PORT=int(os.environ.get("MAIL_PORT", "587")),
|
MAIL_PORT=int(os.environ.get("MAIL_PORT", "587")),
|
||||||
MAIL_USERNAME=os.environ.get("MAIL_USERNAME"),
|
MAIL_USERNAME=os.environ.get("MAIL_USERNAME"),
|
||||||
@@ -71,6 +76,7 @@ def init_db():
|
|||||||
excerpt TEXT NOT NULL,
|
excerpt TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
category TEXT NOT NULL,
|
category TEXT NOT NULL,
|
||||||
|
image_filename TEXT,
|
||||||
author_id INTEGER NOT NULL,
|
author_id INTEGER NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
@@ -95,9 +101,12 @@ def init_db():
|
|||||||
"mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0",
|
"mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0",
|
||||||
"email": "ALTER TABLE users ADD COLUMN email TEXT",
|
"email": "ALTER TABLE users ADD COLUMN email TEXT",
|
||||||
}
|
}
|
||||||
|
post_columns = {row["name"] for row in db.execute("PRAGMA table_info(posts)")}
|
||||||
for column, statement in migrations.items():
|
for column, statement in migrations.items():
|
||||||
if column not in user_columns:
|
if column not in user_columns:
|
||||||
db.execute(statement)
|
db.execute(statement)
|
||||||
|
if "image_filename" not in post_columns:
|
||||||
|
db.execute("ALTER TABLE posts ADD COLUMN image_filename TEXT")
|
||||||
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)")
|
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)")
|
||||||
|
|
||||||
# Preserve access for accounts created before approvals were introduced.
|
# Preserve access for accounts created before approvals were introduced.
|
||||||
@@ -159,6 +168,27 @@ def post_from_request(post_id=None):
|
|||||||
return title, unique_slug(title, post_id), excerpt, body, category, now
|
return title, unique_slug(title, post_id), excerpt, body, category, now
|
||||||
|
|
||||||
|
|
||||||
|
def save_post_image():
|
||||||
|
image_file = request.files.get("image")
|
||||||
|
if image_file is None or not image_file.filename:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
image = Image.open(image_file.stream)
|
||||||
|
image.verify()
|
||||||
|
source_format = image.format
|
||||||
|
image_file.stream.seek(0)
|
||||||
|
image = ImageOps.exif_transpose(Image.open(image_file.stream)).convert("RGB")
|
||||||
|
except (UnidentifiedImageError, OSError, SyntaxError) as error:
|
||||||
|
raise ValueError("Upload a valid JPEG, PNG, or WebP image.") from error
|
||||||
|
if source_format not in {"JPEG", "PNG", "WEBP"}:
|
||||||
|
raise ValueError("Upload a JPEG, PNG, or WebP image.")
|
||||||
|
image.thumbnail((2400, 2400))
|
||||||
|
filename = f"{uuid4().hex}.webp"
|
||||||
|
app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True)
|
||||||
|
image.save(app.config["UPLOAD_FOLDER"] / filename, "WEBP", quality=88, method=6)
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
def valid_email(email):
|
def valid_email(email):
|
||||||
return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None
|
return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None
|
||||||
|
|
||||||
@@ -222,6 +252,11 @@ def post(slug):
|
|||||||
return render_template("post.html", post=article)
|
return render_template("post.html", post=article)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/uploads/<path:filename>")
|
||||||
|
def uploaded_image(filename):
|
||||||
|
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/register", methods=("GET", "POST"))
|
@app.route("/register", methods=("GET", "POST"))
|
||||||
def register():
|
def register():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -423,10 +458,11 @@ def write():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
title, slug, excerpt, body, category, now = post_from_request()
|
title, slug, excerpt, body, category, now = post_from_request()
|
||||||
|
image_filename = save_post_image()
|
||||||
get_db().execute(
|
get_db().execute(
|
||||||
"""INSERT INTO posts (title, slug, excerpt, body, category, author_id, created_at, updated_at)
|
"""INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(title, slug, excerpt, body, category, session["user_id"], now, now),
|
(title, slug, excerpt, body, category, image_filename, session["user_id"], now, now),
|
||||||
)
|
)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
return redirect(url_for("post", slug=slug))
|
return redirect(url_for("post", slug=slug))
|
||||||
@@ -446,11 +482,14 @@ def edit_post(slug):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
title, new_slug, excerpt, body, category, now = post_from_request(article["id"])
|
title, new_slug, excerpt, body, category, now = post_from_request(article["id"])
|
||||||
|
image_filename = save_post_image() or article["image_filename"]
|
||||||
get_db().execute(
|
get_db().execute(
|
||||||
"""UPDATE posts SET title=?, slug=?, excerpt=?, body=?, category=?, updated_at=? WHERE id=?""",
|
"""UPDATE posts SET title=?, slug=?, excerpt=?, body=?, category=?, image_filename=?, updated_at=? WHERE id=?""",
|
||||||
(title, new_slug, excerpt, body, category, now, article["id"]),
|
(title, new_slug, excerpt, body, category, image_filename, now, article["id"]),
|
||||||
)
|
)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
|
if image_filename != article["image_filename"] and article["image_filename"]:
|
||||||
|
(app.config["UPLOAD_FOLDER"] / article["image_filename"]).unlink(missing_ok=True)
|
||||||
return redirect(url_for("post", slug=new_slug))
|
return redirect(url_for("post", slug=new_slug))
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
flash(str(error), "error")
|
flash(str(error), "error")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before deployment}
|
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before deployment}
|
||||||
DATABASE_PATH: /data/eternity.db
|
DATABASE_PATH: /data/eternity.db
|
||||||
|
UPLOAD_FOLDER: /data/uploads
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8000}
|
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8000}
|
||||||
MAIL_HOST: ${MAIL_HOST:-}
|
MAIL_HOST: ${MAIL_HOST:-}
|
||||||
MAIL_PORT: ${MAIL_PORT:-587}
|
MAIL_PORT: ${MAIL_PORT:-587}
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -12,7 +12,7 @@ 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) }
|
.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; }
|
.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; }
|
.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-body { max-width:680px; white-space:pre-wrap; font-size:18px; line-height:1.7; margin-bottom:35px; }
|
.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; }
|
||||||
.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); }
|
.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; }
|
.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 { 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,11 +2,12 @@
|
|||||||
{% block title %}{{ 'Edit' if post else 'Write' }} | Eternity Project{% endblock %}
|
{% block title %}{{ 'Edit' if post else 'Write' }} | Eternity Project{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="editor-wrap"><div class="section-head"><span>{{ 'EDIT TRANSMISSION' if post else 'NEW TRANSMISSION' }}</span><span>AUTHOR / {{ current_user.username }}</span></div>
|
<section class="editor-wrap"><div class="section-head"><span>{{ 'EDIT TRANSMISSION' if post else 'NEW TRANSMISSION' }}</span><span>AUTHOR / {{ current_user.username }}</span></div>
|
||||||
<form class="editor-form" method="post">
|
<form class="editor-form" method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<label>Title<input name="title" required value="{{ post.title if post else '' }}" placeholder="A clear, useful heading"></label>
|
<label>Title<input name="title" required value="{{ post.title if post else '' }}" placeholder="A clear, useful heading"></label>
|
||||||
<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>
|
<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<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>
|
||||||
<button class="button" type="submit">Publish transmission <span aria-hidden="true">→</span></button>
|
<button class="button" type="submit">Publish transmission <span aria-hidden="true">→</span></button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}{{ post.title }} | Eternity Project{% endblock %}
|
{% block title %}{{ post.title }} | Eternity Project{% endblock %}
|
||||||
{% block content %}
|
{% 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><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>
|
<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 %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user