Titelbilder now have their own page from where they can be created, viewed, updated, and deleted
This commit is contained in:
parent
9498c216a1
commit
68d64bed73
263
the_works/static/css/the_works.css
Normal file
263
the_works/static/css/the_works.css
Normal 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>');
|
||||||
|
}
|
||||||
92
the_works/static/js/fileinput.js
Normal file
92
the_works/static/js/fileinput.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
111
the_works/templates/views/titelbild.html
Normal file
111
the_works/templates/views/titelbild.html
Normal 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 %}
|
||||||
@ -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.database import db
|
||||||
from the_works.models import Titelbild
|
from the_works.models import Titelbild
|
||||||
from io import BytesIO
|
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
from io import BytesIO
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import random
|
#import sys
|
||||||
import sys
|
import hashlib
|
||||||
|
|
||||||
bp = Blueprint("titelbild", __name__)
|
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):
|
def image(id):
|
||||||
titelbild = db.session.get(Titelbild, id)
|
titelbild = db.session.get(Titelbild, id)
|
||||||
if titelbild and titelbild.Bild:
|
if titelbild and titelbild.Bild:
|
||||||
return send_file(BytesIO(titelbild.Bild), mimetype=titelbild.Mimetype)
|
return send_file(BytesIO(titelbild.Bild), mimetype=titelbild.Mimetype)
|
||||||
else:
|
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):
|
def thumbnail(id):
|
||||||
titelbild = db.session.get(Titelbild, id)
|
titelbild = db.session.get(Titelbild, id)
|
||||||
if titelbild and titelbild.Thumbnail:
|
if titelbild and titelbild.Thumbnail:
|
||||||
return send_file(BytesIO(titelbild.Thumbnail), mimetype=titelbild.Mimetype)
|
return send_file(BytesIO(titelbild.Thumbnail), mimetype=titelbild.Mimetype)
|
||||||
else:
|
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 == "":
|
if f.filename == "":
|
||||||
raise TypeError("FileStorage object expected")
|
raise TypeError(message="FileStorage object expected")
|
||||||
filesize = sys.getsizeof(f.read())
|
blob = f.read()
|
||||||
f.seek(0)
|
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
|
# open image in memory
|
||||||
|
bytes_like.seek(0)
|
||||||
img = Image.open(bytes_like)
|
img = Image.open(bytes_like)
|
||||||
format = img.format
|
|
||||||
|
|
||||||
# prepare values for database entry
|
# prepare values for database entry
|
||||||
|
bytes_like.seek(0)
|
||||||
titelbild = Titelbild(
|
titelbild = Titelbild(
|
||||||
Mimetype = img.get_format_mimetype(),
|
Mimetype = img.get_format_mimetype(),
|
||||||
Dateiname = secure_filename(f.filename),
|
Dateiname = secure_filename(f.filename),
|
||||||
Dateigroesse = filesize,
|
Dateigroesse = filesize,
|
||||||
Breite = img.width,
|
Breite = img.width,
|
||||||
Hoehe = img.height,
|
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
|
# add thumbnail to table entry
|
||||||
|
tn_format = img.format
|
||||||
img.thumbnail([128, 128])
|
img.thumbnail([128, 128])
|
||||||
t = BytesIO()
|
tn = BytesIO()
|
||||||
img.save(t, format=format)
|
img.save(tn, format=tn_format)
|
||||||
titelbild.Thumbnail = t.getvalue()
|
titelbild.Thumbnail = tn.getvalue()
|
||||||
|
|
||||||
# add record to DB
|
# add record to DB
|
||||||
db.session.add(titelbild)
|
db.session.add(titelbild)
|
||||||
@ -60,12 +98,43 @@ def create(f):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# return assigned ID
|
# return assigned ID
|
||||||
flash("Bilddatei erfolgreich hochgeladen")
|
|
||||||
return id
|
return id
|
||||||
|
|
||||||
# def delete(id):
|
|
||||||
# titelbild = db.session.get(Titelbild, id)
|
@bp.route("/titelbild/update/<int:id>", methods=["POST"])
|
||||||
# db.session.delete(titelbild)
|
def update(id):
|
||||||
# db.session.commit()
|
# not written yet
|
||||||
# flash("Bilddatei erfolgreich gelöscht")
|
return redirect(url_for("titelbild.all"), code=303)
|
||||||
# return
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user