diff --git a/the_works/static/css/the_works.css b/the_works/static/css/the_works.css new file mode 100644 index 0000000..24a489e --- /dev/null +++ b/the_works/static/css/the_works.css @@ -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
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:empty::before { + content: attr(data-default); +} + +/* badge styles for the selected items of a dropdown select field built with
and */ +.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,'); + /* "globe-alt" from https://heroicons.com/solid */ + --tw-icon-globe: url('data:image/svg+xml,'); +} diff --git a/the_works/static/js/fileinput.js b/the_works/static/js/fileinput.js new file mode 100644 index 0000000..d43b592 --- /dev/null +++ b/the_works/static/js/fileinput.js @@ -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 ('s) id + // could probably just hardcode "titelbild" + let id_stub = event.target.id.split("-")[0]; + // current_stub equals the id of the next
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 + } +} + diff --git a/the_works/templates/views/titelbild.html b/the_works/templates/views/titelbild.html new file mode 100644 index 0000000..884030d --- /dev/null +++ b/the_works/templates/views/titelbild.html @@ -0,0 +1,111 @@ +{% extends 'base.html' %} + +{% block title %}Titelbilder{% endblock title %} + +{% block head %} + +{% endblock head %} + +{% block heading %}Titelbilder{% endblock heading %} + +{% block content %} + +{% include "_icons.svg" %} + + + + + + + + + + + + + {% for titelbild in titelbilder %} + + + + + + + + + {% endfor %} + +
TitelbildDateinameAbmessungen (BxH)DateigrößeAktionen
{{ titelbild["Dateiname"] }}{{ titelbild["Breite"] }}x{{ titelbild["Hoehe"]}}{{ sizeof_fmt(titelbild["Dateigroesse"]) }}
+ + +
+
+
+ +

#

+
+ +
+ + +
+ +
+ + + +
+
+ +
+ + +
+
+ + + +
+ + +
+
+
+
+{% endblock content %} + +{% block script %} + + + + + +{% endblock script %} diff --git a/the_works/views/titelbild.py b/the_works/views/titelbild.py index b4baf95..0e3532c 100644 --- a/the_works/views/titelbild.py +++ b/the_works/views/titelbild.py @@ -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/") + +@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/") +#def read(id): +# return + + +@bp.route("/titelbild/image/") 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//thumb") + +@bp.route("/titelbild/thumbnail/") 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 \ No newline at end of file + + +@bp.route("/titelbild/update/", methods=["POST"]) +def update(id): + # not written yet + return redirect(url_for("titelbild.all"), code=303) + + +@bp.route("/titelbild/delete/") +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() +