diff --git a/CLAUDE.md b/CLAUDE.md index 8221bdad..d2eb902f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ with minimal maintenance and to be accessible to junior newcomers. We minimize the number of dependencies. We prefer simple patterns over clever ones. JavaScript is kept to a minimum, we do not use build steps. -We prefer plain HTML first, hotwire turbo second, hotwire stimulus third and other +We prefer plain HTML first, htmx second, Alpine.js third and other JS libs only when necessary ## Commands diff --git a/catalog/templates/catalog/course.html b/catalog/templates/catalog/course.html index c2e155c8..510a780b 100644 --- a/catalog/templates/catalog/course.html +++ b/catalog/templates/catalog/course.html @@ -31,43 +31,47 @@

Partager un document - {% if following %} - -
- {% csrf_token %} - -
-
- {% else %} - -
- {% csrf_token %} - -
-
- {% endif %} + {% partial favorite %} {% endblock header %} +{% partialdef favorite inline %} + {% if following %} +
+
+ {% csrf_token %} + +
+
+ {% else %} +
+
+ {% csrf_token %} + +
+
+ {% endif %} +{% endpartialdef %} + {% block content %} -
+
{% if documents %}
style="border-style: solid" type="text" name="cours" placeholder="Chercher par titre de document..." - data-course-filter-target="query" - data-action="input->course-filter#filter" + x-ref="query" + @input="filter()" > @@ -111,7 +114,6 @@

{% for document in documents %}
  • {% if request.user|has_write_perm_on:document %} + hx-boost="false"> @@ -218,7 +220,7 @@

    {% endif %} + hx-boost="false"> regarde dans les archives !

  • -

    Il n’y a encore rien dans ce cours…

    +

    Il n'y a encore rien dans ce cours…

    Les documents que tu trouves sur DocHub ont été partagés par des étudiants comme toi et nous. N'hésite pas à partager tes notes, des questions d'examens ou même juste un scan des questions du dernier TP. C'est grâce à des petits gestes comme ça que diff --git a/catalog/templates/catalog/like-dislike.html b/catalog/templates/catalog/like-dislike.html index 6dff9526..417d57c1 100644 --- a/catalog/templates/catalog/like-dislike.html +++ b/catalog/templates/catalog/like-dislike.html @@ -1,13 +1,14 @@ {% load like_tags %} - +

    diff --git a/catalog/views.py b/catalog/views.py index 215355c8..bd72f066 100644 --- a/catalog/views.py +++ b/catalog/views.py @@ -76,6 +76,15 @@ def set_follow_course(request, slug: str, action: str) -> HttpResponse: else: course.followed_by.remove(request.user) course.save() + + if request.htmx: + following = course.followed_by.filter(id=request.user.id).exists() + return render( + request, + "catalog/course.html#favorite", + {"course": course, "following": following}, + ) + nextpage = request.GET.get("next", reverse("catalog:course_show", args=[slug])) return HttpResponseRedirect(nextpage) diff --git a/documents/forms.py b/documents/forms.py index 370f21c8..503520f6 100644 --- a/documents/forms.py +++ b/documents/forms.py @@ -28,7 +28,7 @@ class Meta: attrs={ "class": "form-select", "data-placeholder": "Ajoute des tags", - "data-controller": "tom-select", + "data-tom-select": "", } ), "staff_pick": forms.CheckboxInput( diff --git a/documents/templates/documents/document_edit.html b/documents/templates/documents/document_edit.html index daa22930..02a47512 100644 --- a/documents/templates/documents/document_edit.html +++ b/documents/templates/documents/document_edit.html @@ -72,11 +72,11 @@
    Modération
    {% if doc.hidden %} + hx-confirm="Est-tu certain de vouloir rendre visible ce document ?"/> {% else %} + hx-confirm="Est-tu certain de vouloir cacher ce document ?"/> {% endif %}
    diff --git a/documents/templates/documents/document_report.html b/documents/templates/documents/document_report.html index b69e5354..df293983 100644 --- a/documents/templates/documents/document_report.html +++ b/documents/templates/documents/document_report.html @@ -14,8 +14,7 @@ {% partialdef report_modal %} {% include "documents/document_report.html#report_card" %} @@ -64,7 +63,7 @@
    Signaler un problème
    @@ -159,12 +161,12 @@ {% endwith %}
    - +
    Essaye de rendre le titre clair pour que d'autres puissent trouver ton document. diff --git a/documents/templates/documents/viewer.html b/documents/templates/documents/viewer.html index 8c9b2f3e..2cd5fa99 100644 --- a/documents/templates/documents/viewer.html +++ b/documents/templates/documents/viewer.html @@ -37,7 +37,7 @@

    {% if document.state == "DONE" and not document.is_unconvertible %} - @@ -50,7 +50,7 @@

    {% endif %} {% if document.file_type != '.pdf' %} - @@ -75,9 +75,11 @@

    {% endif %} -

    - -
    - {% csrf_token %} - Utile ? - - -
    -
    - @@ -151,6 +108,56 @@

    {% endblock header %} +{% partialdef vote inline %} +
    +
    + {% csrf_token %} + Utile ? + + +
    +
    +{% endpartialdef %} + {% block content %} {% if document.is_unconvertible %} {% elif document.state == "DONE" %} -
    -
    +
    +
    diff --git a/documents/views.py b/documents/views.py index aa411bc4..ad547d6e 100644 --- a/documents/views.py +++ b/documents/views.py @@ -247,6 +247,14 @@ def document_vote(request, pk): vote.vote_type = request.POST.get("vote_type") vote.save() + if request.htmx: + user_vote = Vote.objects.filter(document=document, user=request.user).first() + return render( + request, + "documents/viewer.html#vote", + {"document": document, "user_vote": user_vote}, + ) + return redirect(document.get_absolute_url()) diff --git a/pyproject.toml b/pyproject.toml index b99bdad4..78fc9746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "django-compressor>=4.5.1", "django-crispy-forms>=2.3", "django-environ>=0.12.0", + "django-htmx>=1.21.0", "django-mptt>=0.17.0", "django-pipeline>=4.0.0", "furl>=2.1.4", diff --git a/static/main.js b/static/main.js index 1d45257e..5be8f4a2 100644 --- a/static/main.js +++ b/static/main.js @@ -1,8 +1,8 @@ import _ from 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm'; - -import {Controller, Application} from 'https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm'; -import {Autocomplete} from 'https://cdn.jsdelivr.net/npm/stimulus-autocomplete@3.1.0/+esm'; import tomSelect from 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/+esm'; +import {getDocument, GlobalWorkerOptions} from 'https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.449/+esm'; + +GlobalWorkerOptions.workerSrc = "https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.449/build/pdf.worker.mjs" function normalize(s) { let r = s.toLowerCase(); @@ -42,294 +42,245 @@ function humanFileSize(bytes, dp = 1) { } function cleanName(name) { - // Returns the name withtout dashes, underscores and removes the extension + // Returns the name without dashes, underscores and removes the extension return name.replace(/[-_]/g, ' ').replace(/\.[^.]+$/, ''); - } -class CourseFilter extends Controller { - static targets = ["query", "tag", "filterable"] +// Alpine.js components +document.addEventListener('alpine:init', () => { - filter(event) { - let normalizedFilterTerm = normalize(this.queryTarget.value) - let selectedTags = this.tagTargets - .filter((el) => el.checked) - .map((el) => el.getAttribute("data-tag-name")); + // Course filter: client-side filtering of documents by text query and tags + Alpine.data('courseFilter', () => ({ + filter() { + const query = this.$refs.query ? this.$refs.query.value : ''; + const normalizedQuery = normalize(query); - this.filterableTargets.forEach((el, i) => { - let key = el.getAttribute("data-filter-key"); - let tags = el.getAttribute("data-tags").split(" "); + const tags = Array.from(this.$el.querySelectorAll('[data-tag-name]')) + .filter(el => el.checked) + .map(el => el.getAttribute('data-tag-name')); - let normalizedTitle = normalize(key) + this.$el.querySelectorAll('[data-filter-key]').forEach(el => { + const key = el.getAttribute('data-filter-key'); + const elTags = el.getAttribute('data-tags').split(' '); + const normalizedTitle = normalize(key); - let containsText = normalizedTitle.includes(normalizedFilterTerm); - let containsTags = _.difference(selectedTags, tags).length === 0; - el.classList.toggle("d-none", !containsText || !containsTags) - }) - } -} - -class Search extends Controller { - static targets = ["input", "output", "submit"] - - initialize() { - this.search = _.debounce(this.search, 200, {trailing: true}) - } + const containsText = normalizedTitle.includes(normalizedQuery); + const containsTags = _.difference(tags, elTags).length === 0; + el.classList.toggle('d-none', !containsText || !containsTags); + }); + } + })); + + // Upload: file upload with drag-and-drop preview + Alpine.data('upload', () => ({ + dragging: false, + + handleFile(event) { + const files = this.$refs.input.files; + if (files.length > 0) { + this.$refs.input.setAttribute('filled', ''); + const file = files[0]; + this.$refs.name.value = cleanName(file.name); + this.$refs.originalname.textContent = file.name; + this.$refs.size.textContent = humanFileSize(file.size); + this.$refs.form.classList.remove('upload--hide'); + } else { + this.$refs.input.removeAttribute('filled'); + this.$refs.form.classList.add('upload--hide'); + } + this.dragging = false; + }, - search(event) { - this.outputTarget.value = this.inputTarget.value - this.submitTarget.click(); - } -} + enter(event) { + event.preventDefault(); + this.dragging = true; + }, -import {getDocument, GlobalWorkerOptions} from 'https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.449/+esm'; -GlobalWorkerOptions.workerSrc = "https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.449/build/pdf.worker.mjs" + leave(event) { + event.preventDefault(); + this.dragging = false; + } + })); + + // Share: Web Share API + Alpine.data('share', (shareUrl) => ({ + supported: false, + + init() { + this.supported = 'share' in navigator; + }, + + async doShare() { + const url = new URL(shareUrl, window.location); + console.log('Sharing', url.href); + try { + await navigator.share({ url: url.href }); + } catch (error) { + if (error.toString().includes('AbortError')) { + console.info('Share aborted by user'); + } else { + throw error; + } + } + } + })); + // Modal trigger: opens a element + Alpine.data('modalTrigger', (targetId) => ({ + open(event) { + if (event.ctrlKey || event.metaKey || event.shiftKey || event.button === 1) { + return; + } + event.preventDefault(); + const dialog = document.getElementById(targetId); + if (dialog) { + dialog.showModal(); + } + } + })); +}); -class Viewer extends Controller { - static targets = ["renderer", "loader"] - static values = {src: String, loaded: Boolean, error: Boolean} - pageSizeLogDebounce = false; - static options = { - threshold: 0, // default - } +// PDF Viewer: initialized via data attribute, not Alpine (too complex for Alpine's reactive model) +function initViewer(el) { + const src = el.dataset.viewerSrc; + const renderer = el.querySelector('[data-viewer-renderer]'); + const loader = el.querySelector('[data-viewer-loader]'); + let pages = {}; + let pageSizeLogDebounce = false; - async connect() { - let loadingTask = getDocument(this.srcValue); + async function load() { + const loadingTask = getDocument(src); - loadingTask.onProgress = async (data) => { - let percent = Math.round(data.loaded / data.total * 100) - this.loaderTarget.setAttribute("value", percent); - } + loadingTask.onProgress = (data) => { + const percent = Math.round(data.loaded / data.total * 100); + loader.setAttribute('value', percent); + }; + let pdf; try { - this.pdf = await loadingTask.promise; + pdf = await loadingTask.promise; } catch (e) { - console.log("Error while loading remote PDF", e); - this.errorValue = true; - this.loadedValue = true; + console.log('Error while loading remote PDF', e); + el.setAttribute('data-error', ''); + el.setAttribute('data-loaded', ''); return; } - this.loadedValue = true; + el.setAttribute('data-loaded', ''); - console.log("PDF loaded with ", this.pdf.numPages, " pages"); - console.debug(this.pdf); + console.log('PDF loaded with', pdf.numPages, 'pages'); + console.debug(pdf); - this.pages = {}; + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + const wrapper = entry.target; + const pageNumber = parseInt(wrapper.getAttribute('data-page')); + const isRendered = wrapper.getElementsByTagName('canvas')[0] !== undefined; - let options = { - rootMargin: '0px', - threshold: 0 - } + if (!isRendered && entry.isIntersecting) { + console.log('Rendering page', pageNumber); + renderPage(pageNumber, wrapper); + } + if (isRendered && !entry.isIntersecting) { + console.log('Removing page', pageNumber); + removePage(wrapper); + } + }); + }, { rootMargin: '0px', threshold: 0 }); - this.observer = new IntersectionObserver(this.intersectionCallback.bind(this), options); + const wrappers = []; - let wrappers = []; + for (let i = 1; i <= pdf.numPages; i++) { + pages[i] = await pdf.getPage(i); - for (let i = 1; i <= this.pdf.numPages; i++) { - this.pages[i] = await this.pdf.getPage(i); - - let wrapper = document.createElement("div"); - wrapper.classList.add("page-wrapper"); - wrapper.style['aspectRatio'] = this.getPageRatio(i); - wrapper.setAttribute("data-viewer-page-param", i) + const wrapper = document.createElement('div'); + wrapper.classList.add('page-wrapper'); + wrapper.style['aspectRatio'] = getPageRatio(i); + wrapper.setAttribute('data-page', i); wrappers.push(wrapper); - this.rendererTarget.appendChild(wrapper); - + renderer.appendChild(wrapper); } - // only add all the pages to the observer after they are all created so we - // avoid listening to all the events while the pages are being created - // and the DOM reflows each time - wrappers.map((el) => this.observer.observe(el)); - + wrappers.forEach(w => observer.observe(w)); } - intersectionCallback(event) { - event.map(entry => { - let wrapper = entry.target - let pageNumber = parseInt(wrapper.getAttribute("data-viewer-page-param")) - let isRendered = wrapper.getElementsByTagName("canvas")[0] !== undefined; - - if (!isRendered && entry.isIntersecting) { - console.log("Rendering page", pageNumber) - this.renderPage(pageNumber, wrapper); - } - if (isRendered && !entry.isIntersecting) { - console.log("Removing page", pageNumber) - this.removePage(wrapper); - } - }) - } - - getPageSizes(i) { - let page = this.pages[i]; - let viewport = page.getViewport({scale: 1,}); - - // retina support - let screenRatio = window.devicePixelRatio || 1 - - let scale = screenRatio * (this.rendererTarget.clientWidth / viewport.width) - - let width = Math.floor(viewport.width * scale); - let height = Math.floor(viewport.height * scale); - - if (!this.pageSizeLogDebounce) { - this.pageSizeLogDebounce = true; - console.log(`Page ${i} canvas resolution is ${width}x${height}`) + function getPageSizes(i) { + const page = pages[i]; + const viewport = page.getViewport({ scale: 1 }); + const screenRatio = window.devicePixelRatio || 1; + const scale = screenRatio * (renderer.clientWidth / viewport.width); + const width = Math.floor(viewport.width * scale); + const height = Math.floor(viewport.height * scale); + + if (!pageSizeLogDebounce) { + pageSizeLogDebounce = true; + console.log(`Page ${i} canvas resolution is ${width}x${height}`); } - return {width, height, scale} + return { width, height, scale }; } - getPageRatio(i) { - const {width, height} = this.getPageSizes(i) + function getPageRatio(i) { + const { width, height } = getPageSizes(i); return `${width} / ${height}`; } - async renderPage(i, wrapper) { - - let canvas = document.createElement("canvas") + async function renderPage(i, wrapper) { + const canvas = document.createElement('canvas'); wrapper.appendChild(canvas); - let page = this.pages[i]; - - const {width, height, scale} = this.getPageSizes(i) + const page = pages[i]; + const { width, height, scale } = getPageSizes(i); - canvas.width = width - canvas.height = height - canvas.style.width = "100%"; + canvas.width = width; + canvas.height = height; + canvas.style.width = '100%'; - // Render PDF page into canvas context. - let renderContext = { + const renderContext = { canvasContext: canvas.getContext('2d'), transform: [scale, 0, 0, scale, 0, 0], - viewport: page.getViewport({scale: 1,}), + viewport: page.getViewport({ scale: 1 }), }; await page.render(renderContext); - wrapper.setAttribute("data-viewer-ready", "") + wrapper.setAttribute('data-viewer-ready', ''); } - removePage(wrapper) { - let canvas = wrapper.getElementsByTagName("canvas")[0] + function removePage(wrapper) { + const canvas = wrapper.getElementsByTagName('canvas')[0]; if (canvas !== undefined) canvas.remove(); - wrapper.removeAttribute("data-viewer-ready") - } -} - -class Upload extends Controller { - static targets = ["input", "inputwrapper", "name", "originalname", "size", "form"] - - input(event) { - console.log("File upload", event); - let files = this.inputTarget.files; - if (files.length > 0) { - this.inputTarget.setAttribute("filled", "") - let file = files[0]; - this.nameTarget.value = cleanName(file.name) - this.originalnameTarget.textContent = file.name - this.sizeTarget.textContent = humanFileSize(file.size); - - this.formTarget.classList.remove("upload--hide") - } else { - this.inputTarget.removeAttribute("filled") - this.formTarget.classList.add("upload--hide") - } - this.leave(null); - } - - enter(event) { - event.preventDefault() - this.inputwrapperTarget.setAttribute("active", "") - } - - leave(event) { - if (event !== null) { - event.preventDefault() - } - this.inputwrapperTarget.removeAttribute("active") + wrapper.removeAttribute('data-viewer-ready'); } + load(); } -class TomSelect extends Controller { - async connect() { - new tomSelect(this.element, {hidePlaceholder: true}); - } +// Initialize viewers on page load and after htmx swaps +function initViewers() { + document.querySelectorAll('[data-viewer-src]:not([data-viewer-initialized])').forEach(el => { + el.setAttribute('data-viewer-initialized', ''); + initViewer(el); + }); } -class Share extends Controller { - static values = { - shareUrl: String - } - - connect() { - if ("share" in navigator) { - this.element.classList.remove("d-none") - } - } - - async share() { - const url = new URL(this.shareUrlValue, window.location); - console.log("Sharing", url.href) - try { - await navigator.share({ - url: url.href, - }) - } catch (error) { - if (error.toString().includes('AbortError')) { - // Yes, checking the string representation of the error is hideous, - // but I don't know how to do better and AbortError is undefined - console.info("Share aborted by user") - } else { - throw error; - } - } - } - -} - -class Modal extends Controller { - close() { - this.element.close(); - } -} - -class ModalTrigger extends Controller { - static values = { - target: String - } - - open(event) { - // Allow browser default behavior when modifier keys are pressed - // (Ctrl+click, Cmd+click, Shift+click, or middle-click) - if (event.ctrlKey || event.metaKey || event.shiftKey || event.button === 1) { - return; - } - - event.preventDefault(); - const dialog = document.getElementById(this.targetValue); - if (dialog) { - dialog.showModal(); - } - } +// Initialize tom-select widgets +function initTomSelects() { + document.querySelectorAll('[data-tom-select]:not([data-tom-select-initialized])').forEach(el => { + el.setAttribute('data-tom-select-initialized', ''); + new tomSelect(el, { hidePlaceholder: true }); + }); } -const application = Application.start() - -application.register("course-filter", CourseFilter); -application.register("search", Search); -application.register("viewer", Viewer); -application.register("upload", Upload); -application.register('autocomplete', Autocomplete); -application.register('tom-select', TomSelect); -application.register('share', Share); -application.register('modal', Modal); -application.register('modal-trigger', ModalTrigger); +// Run initializers on page load and after htmx content swaps +document.addEventListener('DOMContentLoaded', () => { + initViewers(); + initTomSelects(); +}); -application.debug = true; +document.addEventListener('htmx:afterSettle', () => { + initViewers(); + initTomSelects(); +}); diff --git a/uv.lock b/uv.lock index 24284acb..9f8df2f4 100644 --- a/uv.lock +++ b/uv.lock @@ -451,6 +451,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, ] +[[package]] +name = "django-htmx" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/f2/8c3e28a5eed8e5226835c762892bfef74eda7e8629c65b49c186098eb303/django_htmx-1.27.0.tar.gz", hash = "sha256:036e5da801bfdf5f1ca815f21592cfb9f004a898f330c842f15e55c70e301a75", size = 65362, upload-time = "2025-11-28T23:18:55.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/ac/25d28489dc43224e260f4ebee7565f7ef1efe12af0f284a89500c19f75e2/django_htmx-1.27.0-py3-none-any.whl", hash = "sha256:13e1e13b87d39b57f95aae6e4987cb3df056d0b1373a41f4a94504a00298ffd8", size = 62126, upload-time = "2025-11-28T23:18:53.57Z" }, +] + [[package]] name = "django-js-asset" version = "3.1.2" @@ -575,6 +588,7 @@ dependencies = [ { name = "django-compressor" }, { name = "django-crispy-forms" }, { name = "django-environ" }, + { name = "django-htmx" }, { name = "django-jsonfield" }, { name = "django-jsonfield-compat" }, { name = "django-mptt" }, @@ -634,6 +648,7 @@ requires-dist = [ { name = "django-compressor", specifier = ">=4.5.1" }, { name = "django-crispy-forms", specifier = ">=2.3" }, { name = "django-environ", specifier = ">=0.12.0" }, + { name = "django-htmx", specifier = ">=1.21.0" }, { name = "django-jsonfield", specifier = ">=1.4.1" }, { name = "django-jsonfield-compat", specifier = ">=0.4.4" }, { name = "django-mptt", specifier = ">=0.17.0" }, diff --git a/www/settings.py b/www/settings.py index 23b3ec16..b9557675 100644 --- a/www/settings.py +++ b/www/settings.py @@ -47,6 +47,7 @@ "tags", "search", "moderation", + "django_htmx", ] CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" @@ -63,6 +64,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_htmx.middleware.HtmxMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", ] diff --git a/www/templates/base.html b/www/templates/base.html index c84a774b..f63d8504 100644 --- a/www/templates/base.html +++ b/www/templates/base.html @@ -11,15 +11,17 @@ - + + + - + - +