Titelbilder now have their own page from where they can be created, viewed, updated, and deleted

This commit is contained in:
eclipse 2025-05-27 18:26:07 +02:00
parent 9498c216a1
commit 68d64bed73
4 changed files with 562 additions and 27 deletions

View File

@ -0,0 +1,263 @@
@charset "UTF-8";
/* add nice gradient to the header*/
body>header {
background-image: linear-gradient(to right, rgba(255, 239, 186, .7), var(--pico-background-color));
}
[data-theme=light],
:root:not([data-theme=dark]),
:host(:not([data-theme=dark])) {
/* adapt the header gradient for dark mode */
body>header {
background-image: linear-gradient(to right, #ffefba, var(--pico-background-color));
}
}
@media only screen and (prefers-color-scheme: dark) {
:root:not([data-theme]),
:host(:not([data-theme])) {
}
}
/* on smaller screens, give the main menu dropdown an absolute position so it stretches over the content instead of pushing the content down */
@media (max-width: 767px) {
ul#navbar {
position: absolute;
top: 50px
}
}
nav {
a {
--pico-text-decoration: none;
}
ul {
margin-left: 0;
margin-right: 0;
li#li-subtitle {
padding: 0;
}
li.sun-moon-li {
padding-right: 0;
}
li[aria-current=page]>a,
li[aria-current=page]>details>summary>a {
text-decoration: underline;
}
a {
font-size: 1rem;
font-weight: bold;
text-align: right;
}
details.dropdown {
margin-block-end: calc(var(--pico-spacing) * -1);
>summary:not([role]) a,
li a {
color: var(--pico-primary);
}
>summary:not([role]) {
background-color: inherit;
border: none;
padding-top: var(--pico-form-element-spacing-vertical);
}
}
li a {
text-align: left;
}
}
}
/* change color of sun icon to white*/
#sun-moon:not(:checked)::before {
background-color: var(--pico-color);
}
/* change background & border color of theme toggle */
#sun-moon:not([aria-invalid]) {
background-color: var(--pico-primary-background);
border-color: var(--pico-primary-border);
}
table.dataTable span.dt-column-order::before,
table.dataTable span.dt-column-order::after
{
line-height: var(--pico-line-height) !important;
}
/* make search field expand to ca. half width */
.dt-container div:first-child > .dt-layout-cell,
.dt-container div:first-child .dt-search {
flex-grow: 1;
}
table.dataTable td.action {
padding: calc(var(--pico-spacing) / 2) var(--pico-spacing);
}
.dt-search input[type="search"] {
background-image: none;
}
svg {
height: 1.5em;
}
.required:after {
content: " *";
color: #cf0000;
}
label:has([type="checkbox"]) {
width: 100%;
}
#navbar li[aria-current=page]>a,
#navbar li[aria-current=page]>details>summary>a {
text-decoration: underline;
}
/* correct inconsistent margins when using <details> in a form */
label > :where(div) {
margin-top: calc(var(--pico-spacing) * 0.25);
}
label:has(details.dropdown) {
margin-bottom: calc(var(--pico-spacing) * 1.25);
}
/* filter out elements by search */
.filtered-out {
display: none;
}
/* placeholder for <summary> */
summary:empty::before {
content: attr(data-default);
}
/* badge styles for the selected items of a dropdown select field built with <details> and <summary> */
.dropdown-badge {
background-color: var(--pico-primary);
color: var(--pico-background-color);
font-size: .8em;
padding: calc(var(--pico-form-element-spacing-vertical) * .5) calc(var(--pico-form-element-spacing-horizontal) * .5);
margin: calc(var(--pico-spacing) * .25);
cursor: default;
text-wrap: nowrap;
position: relative;
}
.dropdown-badge-close {
cursor: pointer;
font-size: 1.5em;
position: relative;
vertical-align: sub;
}
form:not([novalidate]) input:user-valid[type="search"] {
border-color: inherit;
background-image: none;
}
/* style for Klappentext tooltips */
[data-tooltip][data-placement="bottom"]::after,
[data-tooltip][data-placement="bottom"]::before {
white-space: pre-line;
max-width: 400px;
}
/* styles for the titelbild elements */
#titelbild-upload {
opacity: 0;
}
.thumbnail-div {
height: 128px;
text-align: center;
}
.thumbnail-img {
object-fit: contain;
height: 128px;
}
.thumbnail-a,
.placeholder {
display: block;
width: 128px;
margin: auto;
border: var(--pico-border-width) solid var(--pico-table-border-color);
svg {
height: 128px;
}
}
#titelbild-controls {
margin-top: var(--pico-form-element-spacing-vertical);
}
.display-none {
display: none;
}
[data-display-werksform]::before {
margin-right: calc(var(--pico-spacing) * .5);
vertical-align: baseline;
color: var(--pico-muted-color);
}
[data-display-werksform="1"]::before {
content: '[Hardcover]';
}
[data-display-werksform="2"]::before {
content: '[Hardcover groß]';
}
[data-display-werksform="3"]::before {
content: '[Taschenbuch]';
/*content: var(--tw-icon-paperback);*/
}
[data-display-werksform="4"]::before {
content: '[Taschenbuch groß';
}
[data-display-werksform="5"]::before {
content: '[Magazin]';
}
[data-display-werksform="6"]::before {
content: '[Download]';
}
[data-display-werksform="7"]::before {
content: '[online]';
/* content: var(--tw-icon-globe);*/
}
[data-display-werksform="8"]::before {
content: '[Ebook]';
}
[data-display-werksform="9"]::before {
content: '[Hörbuch CD]';
}
[data-display-werksform="10"]::before {
content: '[Hörbuch digital]';
}
:root {
/* "book-open" from https://heroicons.com/solid */
--tw-icon-paperback: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="rgb(136, 145, 164)"><path d="M11.25 4.533A9.707 9.707 0 0 0 6 3a9.735 9.735 0 0 0-3.25.555.75.75 0 0 0-.5.707v14.25a.75.75 0 0 0 1 .707A8.237 8.237 0 0 1 6 18.75c1.995 0 3.823.707 5.25 1.886V4.533ZM12.75 20.636A8.214 8.214 0 0 1 18 18.75c.966 0 1.89.166 2.75.47a.75.75 0 0 0 1-.708V4.262a.75.75 0 0 0-.5-.707A9.735 9.735 0 0 0 18 3a9.707 9.707 0 0 0-5.25 1.533v16.103Z" /></svg>');
/* "globe-alt" from https://heroicons.com/solid */
--tw-icon-globe: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="rgb(136, 145, 164)"><path d="M21.721 12.752a9.711 9.711 0 0 0-.945-5.003 12.754 12.754 0 0 1-4.339 2.708 18.991 18.991 0 0 1-.214 4.772 17.165 17.165 0 0 0 5.498-2.477ZM14.634 15.55a17.324 17.324 0 0 0 .332-4.647c-.952.227-1.945.347-2.966.347-1.021 0-2.014-.12-2.966-.347a17.515 17.515 0 0 0 .332 4.647 17.385 17.385 0 0 0 5.268 0ZM9.772 17.119a18.963 18.963 0 0 0 4.456 0A17.182 17.182 0 0 1 12 21.724a17.18 17.18 0 0 1-2.228-4.605ZM7.777 15.23a18.87 18.87 0 0 1-.214-4.774 12.753 12.753 0 0 1-4.34-2.708 9.711 9.711 0 0 0-.944 5.004 17.165 17.165 0 0 0 5.498 2.477ZM21.356 14.752a9.765 9.765 0 0 1-7.478 6.817 18.64 18.64 0 0 0 1.988-4.718 18.627 18.627 0 0 0 5.49-2.098ZM2.644 14.752c1.682.971 3.53 1.688 5.49 2.099a18.64 18.64 0 0 0 1.988 4.718 9.765 9.765 0 0 1-7.478-6.816ZM13.878 2.43a9.755 9.755 0 0 1 6.116 3.986 11.267 11.267 0 0 1-3.746 2.504 18.63 18.63 0 0 0-2.37-6.49ZM12 2.276a17.152 17.152 0 0 1 2.805 7.121c-.897.23-1.837.353-2.805.353-.968 0-1.908-.122-2.805-.353A17.151 17.151 0 0 1 12 2.276ZM10.122 2.43a18.629 18.629 0 0 0-2.37 6.49 11.266 11.266 0 0 1-3.746-2.504 9.754 9.754 0 0 1 6.116-3.985Z" /></svg>');
}

View File

@ -0,0 +1,92 @@
/*
* FILE INPUT FUNCTIONS
*/
function initFileinput(id_stub, current_stub, modal_id) {
// add input element event handler
document.getElementById(id_stub + "-upload").addEventListener("change", handleFileinputChange);
// add event handler to modal that shows the current image if the modal was raised by an update action, and hides it otherwise
document.getElementById(modal_id).addEventListener("toggle", handleModalToggle);
}
function handleFileinputChange(event) {
let id_stub = event.target.id.split("-")[0];
// mark change
document.getElementById(id_stub + "-haschanged").value += "+";
console.dir(event);
// no file chosen -> show placeholder
if ( ! event.target.files.length ) {
showOrHideThumbnail(id_stub, "hide");
return true;
}
// set img src to the uploaded file
let f = event.target.files[0];
let tn = document.getElementById(id_stub + "-thumbnail");
tn.src = URL.createObjectURL(f);
// display image
showOrHideThumbnail(id_stub, "show");
// event handler after image object is loaded into the page
tn.onload = () => {
// display some data about the image while we're at it
document.getElementById(id_stub + "-dateiname").innerText = f.name;
document.getElementById(id_stub + "-dateigroesse").innerText = f.size;
document.getElementById(id_stub + "-breite").innerText = tn.naturalWidth;
document.getElementById(id_stub + "-hoehe").innerText = tn.naturalHeight;
// remove image object from memory
URL.revokeObjectURL(tn.src);
};
}
function handleModalToggle(event) {
// id_stub equals the first part of the target element's (<dialog id="titelbild-modal">'s) id
// could probably just hardcode "titelbild"
let id_stub = event.target.id.split("-")[0];
// current_stub equals the id of the next <section> sibling right after the section element with id_stub as its id
// could probably just hardcode "current"
let current_stub = document.querySelector(`#${id_stub} + section`).id;
// always reset form and hide thumbnail
document.getElementById(id_stub + "_detail_form").reset();
showOrHideThumbnail(id_stub, "hide");
// set and show current image if modal is being opened for updating an entry (but not for creating a new entry)
if ( event.newState == "open" && document.getElementById("form_submit").formAction.includes("update") ) {
let row_id = document.getElementById("form_submit").formAction.split("/").pop();
let td = document.querySelector(`tr#${id_stub}-${row_id} td.action-update`);
// iterate over the id suffixes of all elements that need to be filled with image data
for ( const suffix of ["bild", "thumbnail", "dateiname", "dateigroesse", "breite", "hoehe"] ) {
let full_id = `${current_stub}-${suffix}`;
let val = td.dataset[full_id.replaceAll(/(-)([a-z])/g, m => m[1].toUpperCase())]; // converting dashed-case to camelCase b/c Javascript returns data attributes in camelCase no matter what :-/
if ( suffix == "bild" ) {
document.getElementById(full_id).setAttribute("href", val);
} else if ( suffix == "thumbnail" ) {
document.getElementById(full_id).setAttribute("src", val);
} else {
document.getElementById(full_id).innerText = val;
}
};
document.getElementById(current_stub).classList.remove("display-none");
} else {
document.getElementById(current_stub).classList.add("display-none");
}
}
function showOrHideThumbnail(id_stub, mode) {
// show modal unless mode is falsy or equals "hide"
if ( mode && mode.toLowerCase() != "hide" ) {
document.getElementById(id_stub + "-bild").classList.remove("display-none"); // show thumbnail
document.getElementById(id_stub + "-placeholder").classList.add("display-none"); // hide placeholder
document.getElementById(id_stub + "-info").classList.remove("display-none"); // show info section
} else {
document.getElementById(id_stub + "-bild").classList.add("display-none"); // hide thumbnail element
document.getElementById(id_stub + "-placeholder").classList.remove("display-none"); // show placeholder
document.getElementById(id_stub + "-info").classList.add("display-none"); // hide info section
}
}

View File

@ -0,0 +1,111 @@
{% extends 'base.html' %}
{% block title %}Titelbilder{% endblock title %}
{% block head %}
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='css/datatables.css') }}">
{% endblock head %}
{% block heading %}Titelbilder{% endblock heading %}
{% block content %}
{% include "_icons.svg" %}
<table id="titelbild-table">
<thead>
<tr>
<th>Titelbild</th>
<th>Dateiname</th>
<th>Abmessungen (BxH)</th>
<th>Dateigröße</th>
<th colspan="2">Aktionen</th>
</tr>
</thead>
<tbody>
{% for titelbild in titelbilder %}
<tr id="titelbild-{{ titelbild['ID'] }}">
<td title="Titelbild"><a href="{{ titelbild['Bild'] }}"><img src="{{ titelbild['Thumbnail'] }}" /></a></td>
<td>{{ titelbild["Dateiname"] }}</td>
<td>{{ titelbild["Breite"] }}x{{ titelbild["Hoehe"]}}</td>
<td>{{ sizeof_fmt(titelbild["Dateigroesse"]) }}</td>
<td class="action action-update" data-id="{{ titelbild['ID'] }}" data-current-bild="{{ titelbild['Bild'] }}" data-current-thumbnail="{{ titelbild['Thumbnail'] }}" data-current-dateiname="{{ titelbild['Dateiname'] }}" data-current-dateigroesse="{{ titelbild['Dateigroesse'] }}" data-current-breite="{{ titelbild['Breite'] }}" data-current-hoehe="{{ titelbild['Hoehe'] }}"><a href="#" title="Titelbild bearbeiten"><svg viewbox="0 0 24 24"><use href="#update" /></svg></a></td>
<td id="delete-{{ titelbild['ID'] }}" class="action"><a onclick="return confirm('Eintrag wirklich löschen?');" href="{{ url_for('titelbild.delete', id=titelbild['ID']) }}" title="Titelbild löschen"><svg viewbox="0 0 24 24"><use href="#delete" /></svg></a></td>
</tr>
{% endfor %}
</tbody>
</table>
<dialog aria-labelledby="dialog-heading" id="titelbild-modal">
<article>
<form id="titelbild_detail_form" method="post" enctype="multipart/form-data" action="#">
<header>
<button aria-label="close" rel="prev"></button>
<h1 id="dialog-heading">#</h1>
</header>
<section id="titelbild">
<label>
<span class="required">Neues Bild</span>
</label>
<label for="titelbild-upload" role="button">Bild aussuchen</label>
<div class="thumbnail-div">
<a class="thumbnail-a display-none" id="titelbild-bild" href="" title="zum Originalbild">
<img class="thumbnail-img" id="titelbild-thumbnail" alt="Thumbnail" src="" />
</a>
<div class="placeholder" id="titelbild-placeholder">
<svg viewbox="0 0 90 128">
<use href="#placeholder" />
</svg>
</div>
</div>
<div id="titelbild-info" class="display-none">
<small>
<span id="titelbild-dateiname"></span> Bytes<br />
<span id="titelbild-dateigroesse"></span> Bytes, <span id="titelbild-breite"></span>x<span id="titelbild-hoehe"></span> px
</small>
</div>
<div id="titelbild-controls">
<input type="file" id="titelbild-upload" name="form_Titelbild" aria-label="Titelbild" placeholder="kein Titelbild" accept="image/*"/>
<input type="hidden" id="titelbild-haschanged" name="form_Titelbild_haschanged" value="" />
</div>
</section>
<section id="current" class="display-none">
<label>Bisheriges Bild</label>
<div class="thumbnail-div">
<a class="thumbnail-a" id="current-bild" href="" title="zum Originalbild">
<img class="thumbnail-img" id="current-thumbnail" alt="Thumbnail" src="" />
</a>
</div>
<div id="current-info">
<small>
<span id="current-dateiname"></span> Bytes<br />
<span id="current-dateigroesse"></span> Bytes, <span id="current-breite"></span>x<span id="current-hoehe"></span> px
</small>
</div>
</section>
<footer class="grid">
<button id="form_submit" type="submit" formmethod="post" formaction="{{ url_for('titelbild.create') }}">OK</button>
<button class="secondary" aria-label="close" formmethod="dialog" formnovalidate>Abbrechen</button>
</footer>
</form>
</article>
</dialog>
{% endblock content %}
{% block script %}
<script src="{{ url_for('static', filename='js/datatables.js') }}"></script>
<script src="{{ url_for('static', filename='js/init_dt.js') }}"></script>
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/fileinput.js') }}"></script>
<script>
window.onload = function () {
initDataTable("titelbild-table");
initCreateButton("titelbild-table", "titelbild hinzufügen …");
initModal("titelbild-modal", [], ["Neues Titelbild", "Titelbild bearbeiten"], ["{{ url_for('titelbild.create') }}", "{{ url_for('titelbild.update', id=-1) }}"]);
initFileinput("titelbild", "current", "titelbild-modal");
}
</script>
{% endblock script %}

View File

@ -1,57 +1,95 @@
from flask import Blueprint, send_file, flash
from flask import Blueprint, render_template, request, redirect, send_file, flash, url_for
from sqlalchemy import select
from the_works.database import db
from the_works.models import Titelbild
from io import BytesIO
from werkzeug.utils import secure_filename
from io import BytesIO
from PIL import Image
import random
import sys
#import sys
import hashlib
bp = Blueprint("titelbild", __name__)
@bp.route("/cover/<int:id>")
@bp.route("/titelbild")
@bp.route("/titelbild/all")
def all():
return render_template("views/titelbild.html", titelbilder=map(lambda t: t._asdict_with_urls(), db.session.scalars(select(Titelbild))))
#@bp.route("/titelbild/read/<int:id>")
#def read(id):
# return
@bp.route("/titelbild/image/<int:id>")
def image(id):
titelbild = db.session.get(Titelbild, id)
if titelbild and titelbild.Bild:
return send_file(BytesIO(titelbild.Bild), mimetype=titelbild.Mimetype)
else:
return "requested image data not found", 404
return False
#raise ValueError(message="requested image not found") #-> will fail b/c ValueError() does not take any arguments
@bp.route("/cover/<int:id>/thumb")
@bp.route("/titelbild/thumbnail/<int:id>")
def thumbnail(id):
titelbild = db.session.get(Titelbild, id)
if titelbild and titelbild.Thumbnail:
return send_file(BytesIO(titelbild.Thumbnail), mimetype=titelbild.Mimetype)
else:
return "requested image data not found", 404
return False
#raise ValueError(message="requested thumbnail not found")
def create(f):
# wrapper function; the actual magic happens in create_from_filestorage
@bp.route("/titelbild/create", methods=["POST"])
def create():
id = create_from_filestorage(request.files["form_Titelbild"])
if id:
flash("Eintrag erfolgreich hinzugefügt")
else:
flash("Eintrag ist bereits in Datenbank vorhanden")
return redirect(url_for("titelbild.all"), code=303)
# take a FileStorage object with an image and, if it does not yet exist in the DB, create a new Titelbild record around it; return record id
def create_from_filestorage(f):
if f.filename == "":
raise TypeError("FileStorage object expected")
filesize = sys.getsizeof(f.read())
f.seek(0)
raise TypeError(message="FileStorage object expected")
blob = f.read()
filesize = len(blob)
print(f"filesize of {f.filename} is {filesize}")
# use BytesIO as a wrapper around the byte stream
with BytesIO(blob) as bytes_like:
if _check_duplicate(bytes_like):
return False
# Image.open() has a filename input requirement; BytesIO as a wrapper emulates the filename
with BytesIO(f.read()) as bytes_like:
# open image in memory
bytes_like.seek(0)
img = Image.open(bytes_like)
format = img.format
# prepare values for database entry
bytes_like.seek(0)
titelbild = Titelbild(
Mimetype = img.get_format_mimetype(),
Dateiname = secure_filename(f.filename),
Dateigroesse = filesize,
Breite = img.width,
Hoehe = img.height,
Bild = bytes_like.getvalue()
Bild = bytes_like.getvalue(),
# I'd rather use hashlib.file_digest() instead of a makeshift function, but alas, it's not available in Python 3.10
#sha256 = hashlib.file_digest(bytes_like, "sha256")
sha256 = makeshift_digest(bytes_like)
)
# add thumbnail to table entry
tn_format = img.format
img.thumbnail([128, 128])
t = BytesIO()
img.save(t, format=format)
titelbild.Thumbnail = t.getvalue()
tn = BytesIO()
img.save(tn, format=tn_format)
titelbild.Thumbnail = tn.getvalue()
# add record to DB
db.session.add(titelbild)
@ -60,12 +98,43 @@ def create(f):
db.session.commit()
# return assigned ID
flash("Bilddatei erfolgreich hochgeladen")
return id
# def delete(id):
# titelbild = db.session.get(Titelbild, id)
# db.session.delete(titelbild)
# db.session.commit()
# flash("Bilddatei erfolgreich gelöscht")
# return
@bp.route("/titelbild/update/<int:id>", methods=["POST"])
def update(id):
# not written yet
return redirect(url_for("titelbild.all"), code=303)
@bp.route("/titelbild/delete/<int:id>")
def delete(id):
titelbild = db.session.get(Titelbild, id)
db.session.delete(titelbild)
db.session.commit()
flash("Eintrag erfolgreich gelöscht")
return redirect(url_for("titelbild.all"))
# returns id of record with same checksum or False if there is none
def _check_duplicate(bytes_like):
bytes_like.seek(0)
# would use hashlib.file_digest() from the standard library instead of a makeshift function, but it's not available in Python 3.10
#duplicate = db.session.scalar(select(Titelbild).where(Titelbild.sha256 == hashlib.file_digest(bytes_like, "sha256")))
duplicate = db.session.scalar(select(Titelbild).where(Titelbild.sha256 == makeshift_digest(bytes_like)))
if duplicate:
return duplicate.ID
else:
return False
# code adapted from https://stackoverflow.com/a/76937680
def makeshift_digest(bytes_io):
h = hashlib.sha256()
b = bytearray(1024*1024)
mv = memoryview(b)
while n := bytes_io.readinto(mv):
h.update(mv[:n])
return h.hexdigest()