quill editor

This commit is contained in:
Priec
2026-06-23 12:05:06 +02:00
parent 1cf330e4e8
commit 8085052b2b
15 changed files with 473 additions and 11 deletions

View File

@@ -77,3 +77,108 @@
/* Hide Alpine x-cloak elements until Alpine initializes. */ /* Hide Alpine x-cloak elements until Alpine initializes. */
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
/* === Rich text editor (Quill "snow") =======================
* Vendored Quill (assets/static/vendor/quill) drives the admin
* product short/long description fields. Stock snow already suits
* the light theme; the admin panel is locked to data-theme="dark",
* so the rules below repaint the toolbar/editor for dark. Editor
* height is per-instance via the --rich-min-height custom prop set
* by the ui::rich_editor macro.
* ============================================================ */
.rich-editor .ql-editor {
min-height: var(--rich-min-height, 12rem);
font-size: 0.95rem;
line-height: 1.6;
}
.ql-editor.ql-blank::before { font-style: normal; }
[data-theme="dark"] .rich-editor { background: var(--color-surface-dark-alt); }
[data-theme="dark"] .ql-toolbar.ql-snow,
[data-theme="dark"] .ql-container.ql-snow { border-color: var(--color-outline-dark); }
[data-theme="dark"] .ql-toolbar.ql-snow { background: var(--color-surface-dark); }
[data-theme="dark"] .ql-container.ql-snow { color: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-stroke,
[data-theme="dark"] .ql-snow .ql-stroke-miter { stroke: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-fill,
[data-theme="dark"] .ql-snow .ql-stroke.ql-fill { fill: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-picker { color: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-picker-options {
background: var(--color-surface-dark);
border-color: var(--color-outline-dark);
}
/* active / hover toolbar state -> primary accent */
[data-theme="dark"] .ql-snow.ql-toolbar button:hover,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item:hover,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item.ql-selected { color: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke { stroke: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-fill,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-fill { fill: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow .ql-tooltip {
background-color: var(--color-surface-dark);
border-color: var(--color-outline-dark);
color: var(--color-on-surface-dark);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.45);
}
[data-theme="dark"] .ql-snow .ql-tooltip input[type=text] {
background: var(--color-surface-dark-alt);
border-color: var(--color-outline-dark);
color: var(--color-on-surface-dark);
}
[data-theme="dark"] .ql-snow .ql-editor a,
[data-theme="dark"] .ql-snow .ql-tooltip a { color: var(--color-primary-dark); }
/* Image size controls under the editor. */
.rich-image-size-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.rich-image-size-controls.hidden { display: none; }
.rich-image-size-controls button {
border: 1px solid var(--color-outline);
border-radius: var(--radius-radius);
padding: 0.3rem 0.65rem;
line-height: 1;
font-size: 0.8rem;
}
.rich-image-size-controls button:hover { border-color: var(--color-primary); color: var(--color-primary); }
[data-theme="dark"] .rich-image-size-controls button { border-color: var(--color-outline-dark); }
[data-theme="dark"] .rich-image-size-controls button:hover {
border-color: var(--color-primary-dark);
color: var(--color-primary-dark);
}
/* Image sizing classes shared by the editor and rendered output. */
.rich-editor img, .rich-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1rem auto;
border-radius: var(--radius-radius);
}
.rich-editor img { cursor: pointer; }
.rich-image-small { width: min(100%, 18rem); }
.rich-image-medium { width: min(100%, 34rem); }
.rich-image-full { width: 100%; }
/* === Rendered rich content (storefront product description) =
* Inherits text color from context so it works in both themes;
* only structural spacing + link/heading treatment is set here. */
.rich-content { line-height: 1.7; }
.rich-content h2 { margin: 1.25rem 0 0.6rem; font-size: 1.3rem; font-weight: 700; }
.rich-content h3 { margin: 1rem 0 0.5rem; font-size: 1.1rem; font-weight: 700; }
.rich-content p, .rich-content ul, .rich-content ol { margin: 0.6rem 0; }
.rich-content ul { list-style: disc; padding-left: 1.4rem; }
.rich-content ol { list-style: decimal; padding-left: 1.4rem; }
.rich-content a { color: var(--color-primary); text-decoration: underline; }
[data-theme="dark"] .rich-content a { color: var(--color-primary-dark); }
.rich-content :first-child { margin-top: 0; }
.rich-content :last-child { margin-bottom: 0; }
/* Compact rich blurb for product cards: neutralize block spacing so the
* line-clamp truncation stays tidy regardless of the authored markup. */
.rich-blurb :where(p, ul, ol, h2, h3) { margin: 0; }
.rich-blurb :where(ul, ol) { padding-left: 1.1rem; }

View File

@@ -172,7 +172,7 @@ release-date = Release date
cover-image = Cover image cover-image = Cover image
description = Description description = Description
short-description = Short description short-description = Short description
short-description-hint = Shown on product cards (max 200 characters). short-description-hint = Shown on product cards. Keep it short.
songs-in-album = Songs in this album songs-in-album = Songs in this album
admin-new-album-desc = Fill in the details, then tick the songs to include. 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. cover-help = Optional - png, jpg, webp or gif; shown on the album page.

View File

@@ -172,7 +172,7 @@ release-date = Dátum vydania
cover-image = Obrázok obalu cover-image = Obrázok obalu
description = Popis description = Popis
short-description = Krátky popis short-description = Krátky popis
short-description-hint = Zobrazuje sa na kartách produktov (max 200 znakov). short-description-hint = Zobrazuje sa na kartách produktov. Najlepšie krátke.
songs-in-album = Skladby v albume songs-in-album = Skladby v albume
admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť. admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť.
cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu. cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,174 @@
// Quill-based rich text editor, ported from the universal_web blog editor and
// adapted to this shop: each editor lives in a `[data-rich-field]` wrapper so a
// single form can host several (e.g. short + long description); image uploads go
// to this app's /images/upload and carry the CSRF token the middleware expects.
(function () {
function setImageSize(image, size) {
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
image.style.removeProperty('width');
image.style.removeProperty('height');
image.classList.add('rich-image-' + size);
}
function setImageWidth(image, width) {
var px = parseInt(width, 10);
if (!Number.isFinite(px) || px < 40) return;
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-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('rich-image-small')
&& !image.classList.contains('rich-image-medium')
&& !image.classList.contains('rich-image-full')
) {
image.classList.add('rich-image-full');
}
});
}
// The CSRF middleware accepts the token as an X-CSRF-Token header; read it from
// the form's hidden _csrf field (rendered by ui::csrf_field()).
function csrfToken(field) {
var form = field.closest('form');
var input = form && form.querySelector('input[name="_csrf"]');
return input ? input.value : '';
}
function initField(field) {
var editorEl = field.querySelector('[data-rich-editor]');
var contentInput = field.querySelector('[data-rich-content]');
var status = field.querySelector('[data-rich-status]');
var imageControls = field.querySelector('[data-image-size-controls]');
var imageWidthInput = field.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: editorEl.dataset.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);
// Quill leaves an empty editor as "<p><br></p>"; store empty instead so the
// server sees a blank (nullable) value rather than stray markup.
var html = editor.root.innerHTML;
contentInput.value = editor.getText().trim() === '' && !editor.root.querySelector('img')
? ''
: html;
}
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',
headers: { 'X-CSRF-Token': csrfToken(field) }
});
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);
var form = field.closest('form');
if (form) form.addEventListener('submit', syncContent);
syncContent();
}
function initAll(root) {
(root || document).querySelectorAll('[data-rich-field]').forEach(function (field) {
if (field.dataset.richReady) return;
field.dataset.richReady = '1';
initField(field);
});
}
document.addEventListener('DOMContentLoaded', function () { initAll(document); });
// Re-init after htmx swaps a fragment containing an editor into the page.
document.addEventListener('htmx:afterSwap', function (event) { initAll(event.target); });
})();

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

@@ -3,6 +3,9 @@
{% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %} {% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block head %}
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
{% endblock head %}
{% block content %} {% block content %}
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -117,14 +120,14 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="short_description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="short-description", lang=lang | default(value='sk')) }}</label> <span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="short-description", lang=lang | default(value='sk')) }}</span>
<p class="{{ sublabel }}">{{ t(key="short-description-hint", lang=lang | default(value='sk')) }}</p> <p class="{{ sublabel }}">{{ t(key="short-description-hint", lang=lang | default(value='sk')) }}</p>
{{ ui::textarea(name="short_description", id="short_description", rows="2", value=v_short, attrs='maxlength="200"') }} {{ ui::rich_editor(name="short_description", lang=lang | default(value='sk'), value=v_short, min_height="6rem") }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label> <span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</span>
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }} {{ ui::rich_editor(name="description", lang=lang | default(value='sk'), value=v_desc, min_height="16rem") }}
</div> </div>
{# --- Images gallery ------------------------------------------------------- #} {# --- Images gallery ------------------------------------------------------- #}
@@ -216,4 +219,6 @@
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }} {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
</div> </div>
</form> </form>
<script src="/static/vendor/quill/quill.js"></script>
<script src="/static/js/rich-editor.js"></script>
{% endblock content %} {% endblock content %}

View File

@@ -153,6 +153,34 @@
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea> <textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea>
{%- endmacro textarea %} {%- endmacro textarea %}
{# Quill rich-text editor (see /static/js/rich-editor.js + /static/vendor/quill).
The real value rides in a hidden <textarea> the editor keeps in sync, so it
submits like any other field. `value` pre-fills (HTML or plain text). Several
editors may share one form — each is scoped to its own [data-rich-field].
Requires the page to load quill.js + quill.snow.css + rich-editor.js (the
product form does so) and a _csrf field in the form for image uploads. #}
{% macro rich_editor(name, lang, value="", placeholder="", min_height="12rem") -%}
<div data-rich-field>
<textarea name="{{ name }}" data-rich-content class="hidden">{{ value }}</textarea>
<div data-rich-editor class="rich-editor" style="--rich-min-height: {{ min_height }};"{% if placeholder %} data-placeholder="{{ placeholder }}"{% endif %}></div>
<div data-image-size-controls class="rich-image-size-controls mt-2 hidden">
<span class="text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-size", lang=lang) }}</span>
<button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang) }}</button>
<button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang) }}</button>
<button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang) }}</button>
<label class="inline-flex items-center gap-1.5">
<span class="text-xs text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-width-px", lang=lang) }}</span>
<input type="number" min="40" max="1200" step="10" data-image-width
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
</label>
</div>
<p class="mt-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60" data-rich-status
data-uploading="{{ t(key='image-uploading', lang=lang) }}"
data-uploaded="{{ t(key='image-uploaded', lang=lang) }}"
data-error="{{ t(key='image-upload-error', lang=lang) }}"></p>
</div>
{%- endmacro rich_editor %}
{# File input. #} {# File input. #}
{% macro file_input(name, id="", accept="", attrs="", extra="") -%} {% macro file_input(name, id="", accept="", attrs="", extra="") -%}
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/> <input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>

View File

@@ -28,11 +28,13 @@
<!-- Header: Title & Price (stacked so neither overflows the narrow card) --> <!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3> <h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
{# Short blurb for the card; falls back to the full description (clamped) {# Short blurb for the card; falls back to the full description (clamped)
for products without a dedicated short one. Overflow is truncated with an for products without a dedicated short one. Both are authored as rich
text (Quill), so render the stored HTML — `.rich-blurb` strips block
spacing so the line-clamp stays tidy. Overflow is truncated with an
ellipsis: 2 lines in the grid, 3 in the roomier list row. #} ellipsis: 2 lines in the grid, 3 in the roomier list row. #}
{% if product.short_description or product.description %} {% if product.short_description or product.description %}
<p class="line-clamp-2 break-words text-sm text-on-surface/70 dark:text-on-surface-dark/70" <div class="rich-blurb line-clamp-2 break-words text-sm text-on-surface/70 dark:text-on-surface-dark/70"
:class="view === 'list' && 'line-clamp-3'">{% if product.short_description %}{{ product.short_description }}{% else %}{{ product.description }}{% endif %}</p> :class="view === 'list' && 'line-clamp-3'">{% if product.short_description %}{{ product.short_description | safe }}{% else %}{{ product.description | safe }}{% endif %}</div>
{% endif %} {% endif %}
{% if product.on_sale %} {% if product.on_sale %}
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight"> <div class="flex flex-wrap items-baseline gap-x-2 leading-tight">

View File

@@ -83,7 +83,8 @@
</div> </div>
{% if product.description %} {% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div> {# Authored as rich text (Quill) in the admin; render the stored HTML. #}
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
{% endif %} {% endif %}
<template x-if="current.in_stock"> <template x-if="current.in_stock">

View File

@@ -46,6 +46,7 @@ mod m20260622_000004_product_search;
mod m20260622_000005_product_search_aggregate; mod m20260622_000005_product_search_aggregate;
mod m20260622_000006_order_search_indexes; mod m20260622_000006_order_search_indexes;
mod m20260623_000001_add_short_description_to_products; mod m20260623_000001_add_short_description_to_products;
mod m20260623_000002_strip_html_from_product_search;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -96,6 +97,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260622_000005_product_search_aggregate::Migration), Box::new(m20260622_000005_product_search_aggregate::Migration),
Box::new(m20260622_000006_order_search_indexes::Migration), Box::new(m20260622_000006_order_search_indexes::Migration),
Box::new(m20260623_000001_add_short_description_to_products::Migration), Box::new(m20260623_000001_add_short_description_to_products::Migration),
Box::new(m20260623_000002_strip_html_from_product_search::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,94 @@
//! Product descriptions are now authored as rich text (Quill) and stored as
//! HTML. The product search vector (see m20260622_000005) tokenizes the raw
//! description, so without this the markup itself (`p`, `strong`, `li`, `href`,
//! `class`, `ql`, …) would land in the index and pollute matches.
//!
//! Redefine `kompress_build_product_search` so the description is run through a
//! tag-stripping `regexp_replace` before `to_tsvector`, then backfill every
//! product's stored vector. Everything else about the function is unchanged.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_build_product_search(
p_name text, p_description text, p_id integer
) RETURNS tsvector
LANGUAGE sql STABLE AS $func$
SELECT
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(t.name, ' ')
FROM product_product_tags ppt
JOIN product_tags t ON t.id = ppt.product_tag_id
WHERE ppt.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.label, ' ')
FROM product_variants v
WHERE v.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent',
regexp_replace(COALESCE(p_description, ''), '<[^>]+>', ' ', 'g')
), 'C')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.sku, ' ')
FROM product_variants v
WHERE v.product_id = p_id AND v.sku IS NOT NULL
), '')), 'C');
$func$;
-- Backfill: recompute every product's vector with the new definition.
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
// Restore the prior definition (raw, un-stripped description).
let db = m.get_connection();
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_build_product_search(
p_name text, p_description text, p_id integer
) RETURNS tsvector
LANGUAGE sql STABLE AS $func$
SELECT
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(t.name, ' ')
FROM product_product_tags ppt
JOIN product_tags t ON t.id = ppt.product_tag_id
WHERE ppt.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.label, ' ')
FROM product_variants v
WHERE v.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent', COALESCE(p_description, '')), 'C')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.sku, ' ')
FROM product_variants v
WHERE v.product_id = p_id AND v.sku IS NOT NULL
), '')), 'C');
$func$;
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id);
"#,
)
.await?;
Ok(())
}
}