Compare commits

...

2 Commits

Author SHA1 Message Date
Priec
f467f2b417 fixing blog:
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-05-19 22:56:55 +02:00
Priec
57798b5ea0 quill for a blog page 2026-05-19 22:16:37 +02:00
17 changed files with 642 additions and 143 deletions

View File

@@ -2,3 +2,8 @@ hello-world = Hallo Welt!
greeting = Hallochen { $name }! greeting = Hallochen { $name }!
.placeholder = Hallo Freund! .placeholder = Hallo Freund!
about = Uber about = Uber
image-size = Image size
image-size-small = Small
image-size-medium = Medium
image-size-full = Full width
image-width-px = Width px

View File

@@ -1,10 +1,175 @@
hello-world = Hello World! brand = Universal Web
greeting = Hello { $name }! hello-world = Hello world!
.placeholder = Hello Friend! meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
about = About nav-home = Home
simple = simple text nav-about = About
reference = simple text with a reference: { -something } nav-blog = Blog
parameter = text with a { $param } nav-audio = Albums
parameter2 = text one { $param } second { $multi-word-param } nav-songs = Songs
email = text with an EMAIL("example@example.org") nav-admin = Admin
fallback = this should fall back admin-title = Admin
admin-dashboard = Dashboard
admin-blog = Blog
admin-audio = Audio
admin-about = About
admin-exit = Exit
view-site = View site
admin-blog-desc = create and update blog articles.
admin-about-desc = edit the public about page content.
admin-audio-desc = upload songs, then group them into albums.
logout = Log out
settings = Settings
settings-language = Language
settings-theme = Theme
language-en = English
language-sk = Slovak
menu = Menu
theme-system = System
theme-light = Light
theme-dark = Dark
home-title = Home
home-sub = news and updates.
home-all-posts = All posts
home-recent = Recent posts
home-tagline = guitar player - original songs, albums, and notes
home-sections = about/ blog/ audio/ songs/
home-no-posts = no published posts yet
blog-title = Blog
blog-sub = published article(s)
blog-manage = Manage
blog-read = Read
blog-no-posts = no published posts yet
blog-views = views logged
cd-up = cd ..
about-sub = about this site.
about-readonly = readonly
audio-title = Audio
audio-sub = published album(s)
audio-all-songs = All songs
audio-open = Open
audio-play = Play
audio-no-albums = no published albums yet
songs-title = Songs
songs-sub = track(s) across every album.
songs-play-all = Play all
songs-albums = Albums
songs-no-tracks = no tracks yet
album-by = by
album-play-full = Play full album
album-queue-all = queue all tracks in order
album-no-tracks = no tracks yet
login-title = Admin login
login-error = Access denied - invalid email or password.
login-root = root
login-auth = Authenticate
login-email = Email
login-password = Password
auth = Auth
admin-session = Session
readonly = readonly
post = post
album = album
published = published
draft = draft
single = single
manage = Manage
open = Open
play = Play
new-article = New article
edit = Edit
delete = Delete
save = Save
cancel = Cancel
create = Create
upload = Upload
view = View
back-to-dashboard = Back to dashboard
back-to-articles = Back to articles
title = Title
status = Status
actions = Actions
content = Content
excerpt = Excerpt
featured-image-id = Featured image id
image-file = Image file
uploaded-image-id = Uploaded image id
url = URL
upload-featured-image = Upload image
image-upload-help = Upload an image here to use it as the article image.
image-uploading = Uploading...
image-uploaded = Image uploaded and selected.
image-upload-error = Image upload failed.
image-size = Image size
image-size-small = Small
image-size-medium = Medium
image-size-full = Full width
image-width-px = Width px
admin-blog-articles = Blog articles
admin-blog-index-desc = Create, edit, and remove blog posts.
admin-blog-create-desc = Create a blog post for the public site.
admin-no-articles = No articles yet.
admin-create-first-post = Create the first blog post.
edit-article = Edit article
create-article = Create article
edit-about = Edit About
update-about-page = Update the public about page.
view-page = View page
albums-title = Albums
new-album = New album
admin-albums-desc = Step 2 - group songs into a release with a cover.
admin-albums-before = Before you make an album
admin-albums-step-upload = Upload your songs first - an album is built from songs that already exist.
admin-albums-step-create = Create the album here, then tick the songs that belong to it.
admin-no-albums = No albums yet
admin-create-album-empty = Create an album to group your songs into a release.
open-edit = Open and edit
songs-title-admin = Songs
admin-songs-desc = Step 1 - every audio file you upload becomes a song.
upload-song = Upload song
admin-audio-how = How audio works
admin-audio-step-upload = Upload a song - pick an audio file here; it becomes a song you can publish.
admin-audio-step-album = Make an album (optional) - group songs together with a cover and track order.
admin-audio-note = A song can be published on its own or as part of an album.
song = Song
where = Where
in-album = In an album
publish = Publish
unpublish = Unpublish
featured = Featured
remove-from-album = Remove from album
admin-no-songs = No songs yet
admin-upload-first-song = Upload your first audio file.
admin-tracklist = Tracklist
admin-add-existing-song = Add an existing song
admin-existing-song-help = These are songs you have uploaded that are not in an album yet.
admin-add-to-album = Add to album
admin-album-empty = This album has no songs yet
admin-album-empty-help = Upload a file into the album, or add an existing song above.
admin-two-ways-title = Two ways to add a song to this album
admin-two-ways-upload = Upload a new file straight into the album using the button above.
admin-two-ways-pick = Pick an existing song that is not in any album yet.
album-title-label = Album title *
artist = Artist
release-date = Release date
cover-image = Cover image
description = Description
songs-in-album = Songs in this album
admin-new-album-desc = Fill in the details, then tick the songs to include.
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
free-songs-help = Only songs that are not in an album yet are shown.
no-free-songs = No free songs to add.
upload-song-first = Upload a song first
create-empty-add-later = or create the album empty and add songs later.
publish-album-now = Publish now - visitors can see this album.
create-album = Create album
upload-song-into-album = Upload song into album
upload-song-title = Upload song
upload-into-album-help = Goes straight into the album
upload-single-help = Uploads as a standalone song. You can add it to an album later.
audio-file = Audio file *
audio-file-help = Required - mp3, wav, ogg, flac, aac, m4a or webm.
title-help = Optional - leave blank to use the audio file's name.
track-number = Track number
track-number-help = Optional - this song's position in the album track list.
featured-help = Highlight this song on the site
publish-song-now = Publish now - visitors can see it.

View File

@@ -99,6 +99,11 @@ image-upload-help = Upload an image here to use it as the article image.
image-uploading = Uploading... image-uploading = Uploading...
image-uploaded = Image uploaded and selected. image-uploaded = Image uploaded and selected.
image-upload-error = Image upload failed. image-upload-error = Image upload failed.
image-size = Image size
image-size-small = Small
image-size-medium = Medium
image-size-full = Full width
image-width-px = Width px
admin-blog-articles = Blog articles admin-blog-articles = Blog articles
admin-blog-index-desc = Create, edit, and remove blog posts. admin-blog-index-desc = Create, edit, and remove blog posts.
admin-blog-create-desc = Create a blog post for the public site. admin-blog-create-desc = Create a blog post for the public site.

View File

@@ -99,6 +99,11 @@ image-upload-help = Tu nahraj obrázok, ktorý sa použije ako obrázok článku
image-uploading = Nahrávam... image-uploading = Nahrávam...
image-uploaded = Obrázok je nahratý a vybraný. image-uploaded = Obrázok je nahratý a vybraný.
image-upload-error = Nahratie obrázka zlyhalo. image-upload-error = Nahratie obrázka zlyhalo.
image-size = Veľkosť obrázka
image-size-small = Malý
image-size-medium = Stredný
image-size-full = Na celú šírku
image-width-px = Šírka px
admin-blog-articles = Blogové články admin-blog-articles = Blogové články
admin-blog-index-desc = Vytvárať, upravovať a odstraňovať blogové články. admin-blog-index-desc = Vytvárať, upravovať a odstraňovať blogové články.
admin-blog-create-desc = Vytvoriť blogový článok pre verejný web. admin-blog-create-desc = Vytvoriť blogový článok pre verejný web.

View File

@@ -575,6 +575,179 @@ body {
/* --- square the icon buttons ------------------------------- */ /* --- square the icon buttons ------------------------------- */
.btn-circle { border-radius: 0; } .btn-circle { border-radius: 0; }
/* --- blog editor ------------------------------------------- */
.blog-editor {
min-height: 24rem;
background: oklch(var(--b1));
}
.blog-editor .ql-editor {
min-height: 24rem;
font-size: 1rem;
line-height: 1.7;
}
.ql-toolbar.ql-snow,
.ql-container.ql-snow {
border-color: oklch(var(--b3));
}
.ql-toolbar.ql-snow {
background: oklch(var(--b2));
}
.ql-snow .ql-stroke,
.ql-snow .ql-stroke-miter {
stroke: oklch(var(--bc));
}
.ql-snow .ql-fill,
.ql-snow .ql-stroke.ql-fill {
fill: oklch(var(--bc));
}
.ql-snow .ql-picker,
.ql-snow .ql-picker-options {
color: oklch(var(--bc));
}
.ql-snow .ql-picker-options {
background: oklch(var(--b1));
border-color: oklch(var(--b3));
}
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label,
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options {
border-color: oklch(var(--b3));
}
/* active / hover toolbar state -> gruvbox accent */
.ql-snow.ql-toolbar button:hover,
.ql-snow.ql-toolbar button:focus,
.ql-snow.ql-toolbar button.ql-active,
.ql-snow.ql-toolbar .ql-picker-label:hover,
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
.ql-snow.ql-toolbar .ql-picker-item:hover,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected,
.ql-snow .ql-picker.ql-expanded .ql-picker-label {
color: oklch(var(--p));
}
.ql-snow.ql-toolbar button:hover .ql-stroke,
.ql-snow.ql-toolbar button:focus .ql-stroke,
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
.ql-snow.ql-toolbar button:hover .ql-stroke-miter,
.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
stroke: oklch(var(--p));
}
.ql-snow.ql-toolbar button:hover .ql-fill,
.ql-snow.ql-toolbar button:focus .ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-fill,
.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill {
fill: oklch(var(--p));
}
/* link tooltip popup */
.ql-snow .ql-tooltip {
background-color: oklch(var(--b1));
border-color: oklch(var(--b3));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
color: oklch(var(--bc));
}
.ql-snow .ql-tooltip input[type=text] {
background: oklch(var(--b2));
border-color: oklch(var(--b3));
color: oklch(var(--bc));
}
.ql-snow .ql-tooltip a {
color: oklch(var(--p));
}
.ql-snow .ql-tooltip a.ql-action::after {
border-color: oklch(var(--b3));
}
.ql-snow .ql-editor a {
color: oklch(var(--p));
}
.blog-image-size-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.875rem;
}
.blog-image-size-controls.hidden {
display: none;
}
.blog-image-size-controls button {
border: 1px solid oklch(var(--b3));
background: oklch(var(--b2));
padding: 0.3rem 0.65rem;
line-height: 1;
}
.blog-image-size-controls button:hover {
border-color: oklch(var(--p));
color: oklch(var(--p));
}
.blog-image-size-controls label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.blog-image-size-controls input {
width: 5rem;
}
.blog-editor img,
.blog-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1rem auto;
border-radius: 0.25rem;
}
.blog-editor img {
cursor: pointer;
}
.blog-image-small {
width: min(100%, 18rem);
}
.blog-image-medium {
width: min(100%, 34rem);
}
.blog-image-full {
width: 100%;
}
.blog-content {
line-height: 1.75;
}
.blog-content h2 {
margin: 1.5rem 0 0.75rem;
font-size: 1.35rem;
font-weight: 700;
}
.blog-content h3 {
margin: 1.25rem 0 0.5rem;
font-size: 1.15rem;
font-weight: 700;
}
.blog-content p,
.blog-content ul,
.blog-content ol {
margin: 0.75rem 0;
}
.blog-content ul {
list-style: disc;
padding-left: 1.4rem;
}
.blog-content ol {
list-style: decimal;
padding-left: 1.4rem;
}
.blog-content a {
color: oklch(var(--p));
text-decoration: underline;
}
/* --- small screens ----------------------------------------- */ /* --- small screens ----------------------------------------- */
@media (max-width: 767px) { @media (max-width: 767px) {
.term-nav { gap: 0.5rem; } .term-nav { gap: 0.5rem; }

View File

@@ -0,0 +1,149 @@
(function () {
function setImageSize(image, size) {
image.classList.remove('blog-image-small', 'blog-image-medium', 'blog-image-full');
image.style.removeProperty('width');
image.style.removeProperty('height');
image.classList.add('blog-image-' + size);
}
function setImageWidth(image, width) {
var px = parseInt(width, 10);
if (!Number.isFinite(px) || px < 40) return;
image.classList.remove('blog-image-small', 'blog-image-medium', 'blog-image-full');
image.style.width = Math.min(px, 1200) + 'px';
image.style.height = 'auto';
}
function normalizeEditorImages(root) {
root.querySelectorAll('img').forEach(function (image) {
if (
!image.classList.contains('blog-image-small')
&& !image.classList.contains('blog-image-medium')
&& !image.classList.contains('blog-image-full')
) {
image.classList.add('blog-image-full');
}
});
}
function initEditor(form) {
var editorEl = form.querySelector('[data-rich-editor]');
var contentInput = form.querySelector('[data-rich-content]');
var status = form.querySelector('[data-rich-status]');
var imageControls = form.querySelector('[data-image-size-controls]');
var imageWidthInput = form.querySelector('[data-image-width]');
if (!editorEl || !contentInput || !window.Quill) return;
var selectedImage = null;
var toolbar = [
[{ header: [2, 3, false] }],
['bold', 'italic'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['clean']
];
var editor = new Quill(editorEl, {
modules: { toolbar: toolbar },
placeholder: '',
theme: 'snow'
});
var initialContent = contentInput.value.trim();
if (initialContent) {
if (initialContent.indexOf('<') >= 0) editor.clipboard.dangerouslyPasteHTML(initialContent);
else editor.setText(initialContent);
normalizeEditorImages(editor.root);
}
function syncContent() {
normalizeEditorImages(editor.root);
contentInput.value = editor.root.innerHTML;
}
function setStatus(message) {
if (status) status.textContent = message || '';
}
function chooseImageFile() {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/webp,image/gif';
input.addEventListener('change', function () {
var file = input.files && input.files[0];
if (!file) return;
uploadImage(file);
});
input.click();
}
async function uploadImage(file) {
var formData = new FormData();
formData.append('file', file);
setStatus(status ? status.dataset.uploading : '');
try {
var response = await fetch('/images/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin'
});
if (!response.ok) throw new Error('upload failed');
var result = await response.json();
var range = editor.getSelection(true);
editor.insertEmbed(range.index, 'image', result.url, 'user');
editor.setSelection(range.index + 1, 0, 'silent');
window.setTimeout(function () {
var images = editor.root.querySelectorAll('img');
var image = images[images.length - 1];
if (image) {
setImageSize(image, 'full');
selectedImage = image;
if (imageControls) imageControls.classList.remove('hidden');
}
syncContent();
}, 0);
setStatus(status ? status.dataset.uploaded : '');
} catch (_error) {
setStatus(status ? status.dataset.error : '');
}
}
editor.getModule('toolbar').addHandler('image', chooseImageFile);
editor.root.addEventListener('click', function (event) {
if (event.target && event.target.tagName === 'IMG') {
selectedImage = event.target;
if (imageWidthInput) imageWidthInput.value = parseInt(selectedImage.style.width, 10) || '';
if (imageControls) imageControls.classList.remove('hidden');
}
});
if (imageControls) {
imageControls.addEventListener('click', function (event) {
var button = event.target.closest('[data-image-size]');
if (button && selectedImage) {
setImageSize(selectedImage, button.dataset.imageSize);
if (imageWidthInput) imageWidthInput.value = '';
syncContent();
}
});
}
if (imageWidthInput) {
imageWidthInput.addEventListener('change', function () {
if (!selectedImage) return;
setImageWidth(selectedImage, imageWidthInput.value);
syncContent();
});
}
editor.on('text-change', syncContent);
form.addEventListener('submit', syncContent);
syncContent();
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-rich-editor]').forEach(function (editorEl) {
var form = editorEl.closest('form');
if (form) initEditor(form);
});
});
})();

31
assets/static/vendor/quill/LICENSE vendored Normal file
View File

@@ -0,0 +1,31 @@
Copyright (c) 2017-2024, Slab
Copyright (c) 2014, Jason Chen
Copyright (c) 2013, salesforce.com
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

3
assets/static/vendor/quill/quill.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
/*!
* Quill Editor v2.0.3
* https://quilljs.com
* Copyright (c) 2017-2024, Slab
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/

File diff suppressed because one or more lines are too long

View File

@@ -37,6 +37,7 @@
}); });
</script> </script>
<link href="/static/css/app.css" rel="stylesheet" type="text/css"> <link href="/static/css/app.css" rel="stylesheet" type="text/css">
{% block head %}{% endblock head %}
<link href="/static/css/theme.css" rel="stylesheet" type="text/css"> <link href="/static/css/theme.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script> <script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style> <style>

View File

@@ -1,75 +1,63 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block title %}{{ t(key="edit-article", lang=lang | default(value='sk')) }}{% endblock title %} {% block title %}{{ t(key="edit-article", lang=lang | default(value='sk')) }}{% endblock title %}
{% block head %}
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
{% endblock head %}
{% block content %} {% block content %}
<h1>{{ t(key="edit-article", lang=lang | default(value='sk')) }}</h1> <div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<form method="post" action="/admin/blog/articles/{{ article.id }}"> <div>
<label> <h1 class="text-2xl font-bold">{{ t(key="edit-article", lang=lang | default(value='sk')) }}</h1>
{{ t(key="title", lang=lang | default(value='sk')) }} </div>
<input type="text" name="title" value="{{ article.title }}" required> <a href="/admin/blog/articles" class="btn btn-ghost btn-sm">{{ t(key="back-to-articles", lang=lang | default(value='sk')) }}</a>
</label>
<label>
{{ t(key="excerpt", lang=lang | default(value='sk')) }}
<textarea name="excerpt" rows="4">{% if article.excerpt %}{{ article.excerpt }}{% endif %}</textarea>
</label>
<label>
{{ t(key="content", lang=lang | default(value='sk')) }}
<textarea name="content" rows="18" required>{{ article.content }}</textarea>
</label>
<label>
{{ t(key="featured-image-id", lang=lang | default(value='sk')) }}
<input type="text" name="featured_image_id" data-blog-image-id value="{% if article.featured_image_id %}{{ article.featured_image_id }}{% endif %}">
</label>
<div>
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" data-blog-image-file class="file-input file-input-bordered">
<button type="button" class="btn btn-outline btn-sm" data-blog-image-upload data-uploading="{{ t(key="image-uploading", lang=lang | default(value='sk')) }}" data-ready="{{ t(key="upload-featured-image", lang=lang | default(value='sk')) }}">{{ t(key="upload-featured-image", lang=lang | default(value='sk')) }}</button>
<p class="text-sm opacity-70" data-blog-image-status>{{ t(key="image-upload-help", lang=lang | default(value='sk')) }}</p>
<img data-blog-image-preview alt="" class="mt-2 max-h-48 rounded border border-base-300 object-cover" {% if article.featured_image_id %}src="/images/{{ article.featured_image_id }}"{% endif %} style="{% if not article.featured_image_id %}display: none;{% endif %}">
</div> </div>
<label>
<input type="checkbox" name="published" {% if article.published %}checked{% endif %}>
{{ t(key="published", lang=lang | default(value='sk')) }}
</label>
<button type="submit">{{ t(key="save", lang=lang | default(value='sk')) }}</button>
</form>
<script>
(function () {
const fileInput = document.querySelector('[data-blog-image-file]');
const idInput = document.querySelector('[data-blog-image-id]');
const uploadButton = document.querySelector('[data-blog-image-upload]');
const status = document.querySelector('[data-blog-image-status]');
const preview = document.querySelector('[data-blog-image-preview]');
if (!fileInput || !idInput || !uploadButton || !status || !preview) return;
function setPreview(filename) { <div class="card border border-base-300 bg-base-100 shadow-sm">
if (!filename) return; <div class="card-body">
preview.src = '/images/' + filename; <form method="post" action="/admin/blog/articles/{{ article.id }}" class="space-y-2">
preview.style.display = ''; <div class="form-control">
} <label class="label"><span class="label-text">{{ t(key="title", lang=lang | default(value='sk')) }}</span></label>
<input type="text" name="title" value="{{ article.title }}" required class="input input-bordered w-full">
</div>
uploadButton.addEventListener('click', async function () { <div class="form-control">
const file = fileInput.files && fileInput.files[0]; <label class="label"><span class="label-text">{{ t(key="excerpt", lang=lang | default(value='sk')) }}</span></label>
if (!file) return; <textarea name="excerpt" rows="4" class="textarea textarea-bordered w-full">{% if article.excerpt %}{{ article.excerpt }}{% endif %}</textarea>
uploadButton.disabled = true; </div>
uploadButton.textContent = uploadButton.dataset.uploading;
const formData = new FormData(); <div class="form-control">
formData.append('file', file); <label class="label"><span class="label-text">{{ t(key="content", lang=lang | default(value='sk')) }}</span></label>
try { <textarea name="content" data-rich-content class="hidden">{{ article.content }}</textarea>
const response = await fetch('/images/upload', { method: 'POST', body: formData }); <input type="hidden" name="featured_image_id" data-featured-image-id value="{% if article.featured_image_id %}{{ article.featured_image_id }}{% endif %}">
if (!response.ok) throw new Error('upload failed'); <div data-rich-editor class="blog-editor"></div>
const result = await response.json(); <div data-image-size-controls class="blog-image-size-controls hidden">
idInput.value = result.filename; <span>{{ t(key="image-size", lang=lang | default(value='sk')) }}</span>
setPreview(result.filename); <button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang | default(value='sk')) }}</button>
status.textContent = '{{ t(key="image-uploaded", lang=lang | default(value='sk')) }}'; <button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang | default(value='sk')) }}</button>
} catch (_error) { <button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang | default(value='sk')) }}</button>
status.textContent = '{{ t(key="image-upload-error", lang=lang | default(value='sk')) }}'; <label>
} finally { <span>{{ t(key="image-width-px", lang=lang | default(value='sk')) }}</span>
uploadButton.disabled = false; <input type="number" min="40" max="1200" step="10" data-image-width class="input input-bordered input-sm">
uploadButton.textContent = uploadButton.dataset.ready; </label>
} </div>
}); <p class="text-sm opacity-70" data-rich-status data-uploading='{{ t(key="image-uploading", lang=lang | default(value='sk')) }}' data-uploaded='{{ t(key="image-uploaded", lang=lang | default(value='sk')) }}' data-error='{{ t(key="image-upload-error", lang=lang | default(value='sk')) }}'></p>
})(); </div>
</script>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="published" class="checkbox checkbox-sm" {% if article.published %}checked{% endif %}>
<span class="label-text">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
</label>
<div class="flex flex-wrap gap-2 pt-2">
<button type="submit" class="btn btn-neutral btn-sm">{{ t(key="save", lang=lang | default(value='sk')) }}</button>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
</form>
</div>
</div>
</div>
<script src="/static/vendor/quill/quill.js"></script>
<script src="/static/js/blog-editor.js"></script>
{% endblock content %} {% endblock content %}

View File

@@ -1,6 +1,9 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block title %}{{ t(key="new-article", lang=lang | default(value='sk')) }}{% endblock title %} {% block title %}{{ t(key="new-article", lang=lang | default(value='sk')) }}{% endblock title %}
{% block head %}
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
{% endblock head %}
{% block content %} {% block content %}
<div class="space-y-2"> <div class="space-y-2">
@@ -27,18 +30,20 @@
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text">{{ t(key="content", lang=lang | default(value='sk')) }}</span></label> <label class="label"><span class="label-text">{{ t(key="content", lang=lang | default(value='sk')) }}</span></label>
<textarea name="content" rows="18" required class="textarea textarea-bordered w-full"></textarea> <textarea name="content" data-rich-content class="hidden"></textarea>
</div> <input type="hidden" name="featured_image_id" data-featured-image-id>
<div data-rich-editor class="blog-editor"></div>
<div class="form-control"> <div data-image-size-controls class="blog-image-size-controls hidden">
<label class="label"><span class="label-text">{{ t(key="featured-image-id", lang=lang | default(value='sk')) }}</span></label> <span>{{ t(key="image-size", lang=lang | default(value='sk')) }}</span>
<div class="flex flex-wrap gap-2"> <button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang | default(value='sk')) }}</button>
<input type="text" name="featured_image_id" data-blog-image-id class="input input-bordered min-w-0 flex-1"> <button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang | default(value='sk')) }}</button>
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" data-blog-image-file class="file-input file-input-bordered min-w-0 flex-1"> <button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang | default(value='sk')) }}</button>
<button type="button" class="btn btn-outline btn-sm" data-blog-image-upload data-uploading="{{ t(key="image-uploading", lang=lang | default(value='sk')) }}" data-ready="{{ t(key="upload-featured-image", lang=lang | default(value='sk')) }}">{{ t(key="upload-featured-image", lang=lang | default(value='sk')) }}</button> <label>
<span>{{ t(key="image-width-px", lang=lang | default(value='sk')) }}</span>
<input type="number" min="40" max="1200" step="10" data-image-width class="input input-bordered input-sm">
</label>
</div> </div>
<p class="text-sm opacity-70" data-blog-image-status>{{ t(key="image-upload-help", lang=lang | default(value='sk')) }}</p> <p class="text-sm opacity-70" data-rich-status data-uploading='{{ t(key="image-uploading", lang=lang | default(value='sk')) }}' data-uploaded='{{ t(key="image-uploaded", lang=lang | default(value='sk')) }}' data-error='{{ t(key="image-upload-error", lang=lang | default(value='sk')) }}'></p>
<img data-blog-image-preview alt="" class="mt-2 hidden max-h-48 rounded border border-base-300 object-cover">
</div> </div>
<label class="label cursor-pointer justify-start gap-2"> <label class="label cursor-pointer justify-start gap-2">
@@ -54,42 +59,6 @@
</div> </div>
</div> </div>
</div> </div>
<script> <script src="/static/vendor/quill/quill.js"></script>
(function () { <script src="/static/js/blog-editor.js"></script>
const fileInput = document.querySelector('[data-blog-image-file]');
const idInput = document.querySelector('[data-blog-image-id]');
const uploadButton = document.querySelector('[data-blog-image-upload]');
const status = document.querySelector('[data-blog-image-status]');
const preview = document.querySelector('[data-blog-image-preview]');
if (!fileInput || !idInput || !uploadButton || !status || !preview) return;
function setPreview(filename) {
if (!filename) return;
preview.src = '/images/' + filename;
preview.classList.remove('hidden');
}
uploadButton.addEventListener('click', async function () {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
uploadButton.disabled = true;
uploadButton.textContent = uploadButton.dataset.uploading;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/images/upload', { method: 'POST', body: formData });
if (!response.ok) throw new Error('upload failed');
const result = await response.json();
idInput.value = result.filename;
setPreview(result.filename);
status.textContent = '{{ t(key="image-uploaded", lang=lang | default(value='sk')) }}';
} catch (_error) {
status.textContent = '{{ t(key="image-upload-error", lang=lang | default(value='sk')) }}';
} finally {
uploadButton.disabled = false;
uploadButton.textContent = uploadButton.dataset.ready;
}
});
})();
</script>
{% endblock content %} {% endblock content %}

View File

@@ -258,7 +258,7 @@
<li><a href="/audio/tracks" data-nav="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li> <li><a href="/audio/tracks" data-nav="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/about" data-nav="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li> <li><a href="/about" data-nav="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
{% if logged_in_admin %} {% if logged_in_admin %}
<li><a href="/admin/dashboard" class="t-yellow" data-nav="/admin">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li> <li><a href="/admin/dashboard" hx-boost="false" class="t-yellow" data-nav="/admin">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li> <li>
<form method="post" action="/admin/logout" hx-boost="false"> <form method="post" action="/admin/logout" hx-boost="false">
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button> <button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
@@ -284,7 +284,7 @@
<li><a href="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li> <li><a href="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li> <li><a href="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
{% if logged_in_admin %} {% if logged_in_admin %}
<li><a href="/admin/dashboard" class="t-yellow">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li> <li><a href="/admin/dashboard" hx-boost="false" class="t-yellow">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li> <li>
<form method="post" action="/admin/logout"> <form method="post" action="/admin/logout">
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button> <button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>

View File

@@ -11,7 +11,7 @@
<p class="term-sub">// {{ articles | length }} {{ t(key="blog-sub", lang=lang | default(value='sk')) }}</p> <p class="term-sub">// {{ articles | length }} {{ t(key="blog-sub", lang=lang | default(value='sk')) }}</p>
</div> </div>
<div class="term-cmd-actions"> <div class="term-cmd-actions">
<a href="/admin/blog/articles" class="btn btn-outline btn-sm">[ {{ t(key="blog-manage", lang=lang | default(value='sk')) }} ]</a> <a href="/admin/blog/articles" hx-boost="false" class="btn btn-outline btn-sm">[ {{ t(key="blog-manage", lang=lang | default(value='sk')) }} ]</a>
</div> </div>
</header> </header>
@@ -24,9 +24,6 @@
<span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span> <span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if article.featured_image_id %}
<img src="/images/{{ article.featured_image_id }}" alt="" class="mb-3 max-h-64 w-full rounded object-cover">
{% endif %}
<h2 class="card-title text-base"> <h2 class="card-title text-base">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a> <a href="/blog/{{ article.slug }}">{{ article.title }}</a>
</h2> </h2>
@@ -62,9 +59,6 @@
<span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span> <span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if article.featured_image_id %}
<img src="/images/{{ article.featured_image_id }}" alt="" class="mb-3 max-h-64 w-full rounded object-cover">
{% endif %}
<h2 class="card-title text-base"> <h2 class="card-title text-base">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a> <a href="/blog/{{ article.slug }}">{{ article.title }}</a>
</h2> </h2>

View File

@@ -21,14 +21,11 @@
<span class="term-head-meta term-tag is-blue">{{ t(key="readonly", lang=lang | default(value='sk')) }}</span> <span class="term-head-meta term-tag is-blue">{{ t(key="readonly", lang=lang | default(value='sk')) }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if article.featured_image_id %}
<img src="/images/{{ article.featured_image_id }}" alt="" class="mb-4 max-h-[28rem] w-full rounded object-cover">
{% endif %}
{% if article.excerpt %} {% if article.excerpt %}
<p class="term-prose t-yellow"># {{ article.excerpt }}</p> <p class="term-prose t-yellow"># {{ article.excerpt }}</p>
<div class="border-t border-base-300 pt-4"></div> <div class="border-t border-base-300 pt-4"></div>
{% endif %} {% endif %}
<div class="term-prose whitespace-pre-line">{{ article.content }}</div> <div class="blog-content term-prose">{{ article.content | safe }}</div>
</div> </div>
</article> </article>
{% else %} {% else %}
@@ -48,14 +45,11 @@
<span class="term-head-meta term-tag is-blue">{{ t(key="readonly", lang=lang | default(value='sk')) }}</span> <span class="term-head-meta term-tag is-blue">{{ t(key="readonly", lang=lang | default(value='sk')) }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if article.featured_image_id %}
<img src="/images/{{ article.featured_image_id }}" alt="" class="mb-4 max-h-[28rem] w-full rounded object-cover">
{% endif %}
{% if article.excerpt %} {% if article.excerpt %}
<p class="term-prose t-yellow"># {{ article.excerpt }}</p> <p class="term-prose t-yellow"># {{ article.excerpt }}</p>
<div class="border-t border-base-300 pt-4"></div> <div class="border-t border-base-300 pt-4"></div>
{% endif %} {% endif %}
<div class="term-prose whitespace-pre-line">{{ article.content }}</div> <div class="blog-content term-prose">{{ article.content | safe }}</div>
</div> </div>
</article> </article>
{% endif %} {% endif %}

View File

@@ -11,7 +11,7 @@
<p class="term-sub">// {{ t(key="about-sub", lang=lang | default(value='sk')) }}</p> <p class="term-sub">// {{ t(key="about-sub", lang=lang | default(value='sk')) }}</p>
</div> </div>
<div class="term-cmd-actions"> <div class="term-cmd-actions">
<a href="/admin/about" class="btn btn-outline btn-sm">[ {{ t(key="edit", lang=lang | default(value='sk')) }} ]</a> <a href="/admin/about" hx-boost="false" class="btn btn-outline btn-sm">[ {{ t(key="edit", lang=lang | default(value='sk')) }} ]</a>
</div> </div>
</header> </header>