diff --git a/freesound/settings.py b/freesound/settings.py index 415aa17f8..33a8ba632 100644 --- a/freesound/settings.py +++ b/freesound/settings.py @@ -1450,6 +1450,14 @@ # Collections ENABLE_COLLECTIONS = False MAX_SOUNDS_PER_COLLECTION = 250 +MAX_FEATURED_SOUNDS_PER_COLLECTION = 6 +COLLECTION_SORT_OPTIONS = { + "featured": ("Featured first", "featured_order"), + "created_desc": ("Date added (newest first)", "-collectionsound__created"), + "created_asc": ("Date added (oldest first)", "collectionsound__created"), + "name": ("Name", "original_filename"), +} +COLLECTION_SORT_DEFAULT = "featured" # ------------------------------------------------------------------------------- # Import local settings diff --git a/freesound/static/bw-frontend/src/components/addSoundsModal.js b/freesound/static/bw-frontend/src/components/addSoundsModal.js index 3fa7caee8..28e8a696b 100644 --- a/freesound/static/bw-frontend/src/components/addSoundsModal.js +++ b/freesound/static/bw-frontend/src/components/addSoundsModal.js @@ -5,70 +5,20 @@ import { } from '../components/objectSelector'; import { serializedIdListToIntList, combineIdsLists } from '../utils/data'; -const handleAddSoundsModal = ( - modalId, - modalUrl, - selectedSoundsDestinationElement, - onSoundsSelectedCallback -) => { - handleGenericModal( - modalUrl, - modalContainer => { - const inputElement = modalContainer.getElementsByTagName('input')[0]; - inputElement.addEventListener('keypress', function (event) { - if (event.key === 'Enter') { - event.preventDefault(); - const baseUrl = modalUrl.split('?')[0]; - const soundIdsToExclude = combineIdsLists( - serializedIdListToIntList( - selectedSoundsDestinationElement.dataset.selectedIds - ), - serializedIdListToIntList( - selectedSoundsDestinationElement.dataset.unselectedIds - ) - ).join(','); - handleAddSoundsModal( - modalId, - `${baseUrl}?q=${inputElement.value}&exclude=${soundIdsToExclude}`, - selectedSoundsDestinationElement, - onSoundsSelectedCallback - ); - } - }); - - const objectSelectorElement = modalContainer.getElementsByClassName( - 'bw-object-selector-container' - )[0]; - initializeObjectSelector(objectSelectorElement, element => { - addSelectedSoundsButton.disabled = element.dataset.selectedIds == ''; - }); - - const addSelectedSoundsButton = - modalContainer.getElementsByTagName('button')[0]; - addSelectedSoundsButton.disabled = true; - addSelectedSoundsButton.addEventListener('click', evt => { - const selectableSoundElements = [ - ...modalContainer.getElementsByClassName('bw-selectable-object'), - ]; - selectableSoundElements.forEach(element => { - const checkbox = element.querySelectorAll('input.bw-checkbox')[0]; - if (checkbox.checked) { - const clonedCheckbox = checkbox.cloneNode(); // Cloning the node will remove the event listeners which refer to the "old" sound selector - delete clonedCheckbox.dataset.initialized; // This will force re-initialize the element when added to the new sounds selector - clonedCheckbox.checked = false; - checkbox.parentNode.replaceChild(clonedCheckbox, checkbox); - element.classList.remove('selected'); - selectedSoundsDestinationElement.appendChild(element.parentNode); - } - }); - onSoundsSelectedCallback(objectSelectorElement.dataset.selectedIds); - dismissModal(modalId); - }); - }, - undefined, - true, - true - ); +// Read the sidecar fields off a server-rendered card so the dynamic flow can +// push freshly-added sounds into the editor's store. ``date_added`` is the +// client-side timestamp; the server stamps its own when the form is saved. +const extractSoundFromCard = card => { + const player = card.querySelector('.bw-player'); + if (!player) return null; + const d = player.dataset; + return { + id: parseInt(d.soundId, 10), + name: d.title || '', + username: d.username || '', + duration: parseFloat(d.duration) || 0, + date_added: new Date().toISOString(), + }; }; const prepareAddSoundsModalAndFields = container => { @@ -153,18 +103,47 @@ const prepareAddSoundsModalAndFields = container => { addSoundsButton.addEventListener('click', evt => { evt.preventDefault(); - handleAddSoundsModal( + const getExcludeIds = () => + combineIdsLists( + serializedIdListToIntList( + selectedSoundsDestinationElement.dataset.selectedIds + ), + serializedIdListToIntList( + selectedSoundsDestinationElement.dataset.unselectedIds + ) + ).join(','); + openAddSoundsModal( 'addSoundsModal', addSoundsButton.dataset.modalUrl, - selectedSoundsDestinationElement, - selectedSoundIds => { + addSoundsButton.dataset.modalUrl, + getExcludeIds, + modalContainer => { + const objectSelectorElement = modalContainer.getElementsByClassName( + 'bw-object-selector-container' + )[0]; + const selectableSoundElements = [ + ...modalContainer.getElementsByClassName('bw-selectable-object'), + ]; + selectableSoundElements.forEach(element => { + const checkbox = element.querySelectorAll('input.bw-checkbox')[0]; + if (checkbox.checked) { + const clonedCheckbox = checkbox.cloneNode(); + delete clonedCheckbox.dataset.initialized; + clonedCheckbox.checked = false; + checkbox.parentNode.replaceChild(clonedCheckbox, checkbox); + element.classList.remove('selected'); + selectedSoundsDestinationElement.appendChild(element.parentNode); + } + }); const selectedSoundsHiddenInput = document.getElementById( addSoundsButton.dataset.selectedSoundsHiddenInputId ); const currentSoundIds = serializedIdListToIntList( selectedSoundsHiddenInput.value ); - const newSoundIds = serializedIdListToIntList(selectedSoundIds); + const newSoundIds = serializedIdListToIntList( + objectSelectorElement.dataset.selectedIds + ); const combinedIds = combineIdsLists(currentSoundIds, newSoundIds); selectedSoundsHiddenInput.value = combinedIds.join(','); if ( @@ -190,4 +169,86 @@ const prepareAddSoundsModalAndFields = container => { }); }; -export { prepareAddSoundsModalAndFields }; +const openAddSoundsModal = ( + modalId, + modalUrl, + url, + getExcludeIds, + onSoundsConfirmed +) => { + handleGenericModal( + url, + modalContainer => { + const inputElement = modalContainer.getElementsByTagName('input')[0]; + inputElement.addEventListener('keypress', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + const baseUrl = modalUrl.split('?')[0]; + const excludeIds = getExcludeIds(); + openAddSoundsModal( + modalId, + modalUrl, + `${baseUrl}?q=${inputElement.value}&exclude=${excludeIds}`, + getExcludeIds, + onSoundsConfirmed + ); + } + }); + + const objectSelectorElement = modalContainer.getElementsByClassName( + 'bw-object-selector-container' + )[0]; + const addSelectedSoundsButton = + modalContainer.getElementsByTagName('button')[0]; + addSelectedSoundsButton.disabled = true; + initializeObjectSelector(objectSelectorElement, element => { + addSelectedSoundsButton.disabled = element.dataset.selectedIds == ''; + }); + + addSelectedSoundsButton.addEventListener('click', () => { + onSoundsConfirmed(modalContainer); + dismissModal(modalId); + }); + }, + undefined, + true, + true + ); +}; + +const prepareAddSoundsModalDynamic = ( + container, + getExcludeIds, + onSoundsConfirmed +) => { + const addSoundsButton = container.querySelector( + '[data-toggle="add-sounds-modal"]' + ); + if (!addSoundsButton) return; + + const onConfirmed = modalContainer => { + const sounds = [ + ...modalContainer.querySelectorAll('.bw-selectable-object'), + ].reduce((acc, element) => { + const checkbox = element.querySelector('input.bw-checkbox'); + if (checkbox && checkbox.checked) { + acc.push(extractSoundFromCard(element)); + } + return acc; + }, []); + onSoundsConfirmed(sounds); + }; + + addSoundsButton.addEventListener('click', evt => { + evt.preventDefault(); + openAddSoundsModal( + 'addSoundsModal', + addSoundsButton.dataset.modalUrl, + addSoundsButton.dataset.modalUrl, + getExcludeIds, + onConfirmed + ); + }); +}; + +export { prepareAddSoundsModalAndFields, prepareAddSoundsModalDynamic }; diff --git a/freesound/static/bw-frontend/src/components/index.js b/freesound/static/bw-frontend/src/components/index.js index b77d2089c..39f1019e4 100644 --- a/freesound/static/bw-frontend/src/components/index.js +++ b/freesound/static/bw-frontend/src/components/index.js @@ -17,3 +17,6 @@ import './uiThemeDetector'; import { initializeStuffInContainer } from '../utils/initHelper'; initializeStuffInContainer(document, true, true); + +// Exposed so htmx-init.js can rehydrate swapped-in subtrees +window.initializeStuffInContainer = initializeStuffInContainer; diff --git a/freesound/static/bw-frontend/src/components/objectSelector.js b/freesound/static/bw-frontend/src/components/objectSelector.js index da80c0d26..db4207103 100644 --- a/freesound/static/bw-frontend/src/components/objectSelector.js +++ b/freesound/static/bw-frontend/src/components/objectSelector.js @@ -34,7 +34,7 @@ const initializeObjectSelector = (selectorElement, onChangeCallback) => { ]; selectableObjectElements.forEach(element => { const checkbox = element.querySelectorAll('input.bw-checkbox')[0]; - if (checkbox.dataset.initialized === undefined) { + if (checkbox && checkbox.dataset.initialized === undefined) { debouncedUpdateObjectSelectorDataProperties( element.parentNode.parentNode ); @@ -91,4 +91,73 @@ const initializeObjectSelector = (selectorElement, onChangeCallback) => { }); } }; -export { initializeObjectSelector, updateObjectSelectorDataProperties }; + +// --------------------------------------------------------------------------- +// Visual-state helper for .with-actions containers +// --------------------------------------------------------------------------- +const updateActionUI = (container, actionName, isActive) => { + const btn = container.querySelector('[data-action="' + actionName + '"]'); + if (!btn) return; + + btn.classList.toggle('active', isActive); + + const containerClass = btn.dataset.containerActiveClass; + if (containerClass) { + container.classList.toggle(containerClass, isActive); + } + + const activeTitle = btn.dataset.activeTitle; + if (activeTitle) { + if (!btn.dataset.originalTitle) { + btn.dataset.originalTitle = btn.title || ''; + } + btn.title = isActive ? activeTitle : btn.dataset.originalTitle; + } + + const disables = btn.dataset.disables; + if (disables) { + const targetBtn = container.querySelector( + '[data-action="' + disables + '"]' + ); + if (targetBtn) { + targetBtn.disabled = isActive; + } + } + + btn.blur(); +}; + +const initializeObjectSelectorActions = (parentElement, store) => { + const containers = parentElement.querySelectorAll( + '.bw-selectable-object.with-actions' + ); + containers.forEach(container => { + if (container.dataset.actionsInitialized) return; + container.dataset.actionsInitialized = 'true'; + + const objectId = parseInt(container.dataset.objectId, 10); + + // Restore persisted state for all registered actions + store.actionNames.forEach(name => { + updateActionUI(container, name, store.has(objectId, name)); + }); + + // Bind action buttons identified by data-action attribute + container.querySelectorAll('[data-action]').forEach(btn => { + btn.addEventListener('click', evt => { + evt.preventDefault(); + const nowActive = store.toggleAction(objectId, btn.dataset.action); + if (nowActive !== undefined) { + updateActionUI(container, btn.dataset.action, nowActive); + } + }); + }); + }); +}; + +export { + initializeObjectSelector, + updateObjectSelectorDataProperties, + initializeObjectSelectorActions, + updateActionUI, +}; diff --git a/freesound/static/bw-frontend/src/htmx-init.js b/freesound/static/bw-frontend/src/htmx-init.js new file mode 100644 index 000000000..f99ade06d --- /dev/null +++ b/freesound/static/bw-frontend/src/htmx-init.js @@ -0,0 +1,22 @@ +import htmx from 'htmx.org'; + +window.htmx = htmx; + +(function () { + document.body.addEventListener('htmx:configRequest', event => { + event.detail.headers['X-CSRFToken'] = + document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + }); + + // htmx:load fires for every new subtree added to the DOM by htmx (including after swap). + // Rehydrate players, modals, rating widgets, etc. via the project's generic initializer. + document.body.addEventListener('htmx:load', event => { + if (window.initializeStuffInContainer) { + const newElement = event.detail.elt; + // Skip document.body since it's already initialized on page load by components/index.js + if (newElement && newElement !== document.body) { + window.initializeStuffInContainer(newElement, true, false); + } + } + }); +})(); diff --git a/freesound/static/bw-frontend/src/index.js b/freesound/static/bw-frontend/src/index.js index 2991436f9..67c8b97ff 100644 --- a/freesound/static/bw-frontend/src/index.js +++ b/freesound/static/bw-frontend/src/index.js @@ -10,3 +10,5 @@ import '../styles/index-light.scss'; import './components'; import './utils/polyfills'; + +import './htmx-init'; diff --git a/freesound/static/bw-frontend/src/pages/collectionEdit.js b/freesound/static/bw-frontend/src/pages/collectionEdit.js index ff7f1864a..fc754cce0 100644 --- a/freesound/static/bw-frontend/src/pages/collectionEdit.js +++ b/freesound/static/bw-frontend/src/pages/collectionEdit.js @@ -1,5 +1,75 @@ import { prepareAddMaintainersModalAndFields } from '../components/collectionsModal'; -import { prepareAddSoundsModalAndFields } from '../components/addSoundsModal'; +import { prepareAddSoundsModalDynamic } from '../components/addSoundsModal'; +import { SoundStateStore } from '../utils/soundStateStore'; +import { SoundGridEditor } from '../utils/soundGridEditor'; + +const soundsData = JSON.parse( + document.getElementById('sounds-data').textContent +); +const pageConfig = JSON.parse( + document.getElementById('page-config').textContent +); +const initialFeaturedIds = soundsData + .filter(s => Number.isInteger(s.featured_order)) + .sort((a, b) => a.featured_order - b.featured_order) + .map(s => s.id); + +const store = new SoundStateStore(['added', 'remove', 'featured'], { + maxFeatured: pageConfig.max_featured || Infinity, +}).load(soundsData, { featured: initialFeaturedIds }); + +const featuredCountEl = document.getElementById('featured-count'); + +const editor = new SoundGridEditor({ + store, + countEl: document.getElementById('element-count'), + searchInput: document.getElementById('edit-collection-search'), +}); + +const updateFeaturedUI = () => { + const count = store.featuredCount(); + const atLimit = count >= (pageConfig.max_featured || Infinity); + + if (featuredCountEl) featuredCountEl.textContent = count; + + // Disable/enable non-featured buttons across the grid + const grid = document.getElementById('sounds-grid'); + if (grid) { + grid.querySelectorAll('[data-action="featured"]').forEach(btn => { + const container = btn.closest('[data-object-id]'); + const id = container ? parseInt(container.dataset.objectId, 10) : NaN; + const isFeatured = store.has(id, 'featured'); + const isRemoved = store.has(id, 'remove'); + btn.disabled = isRemoved || (!isFeatured && atLimit); + }); + } +}; +updateFeaturedUI(); +store.onChange((_id, name) => { + if (name === 'featured' || name === 'remove') updateFeaturedUI(); +}); +editor.onAfterSwap(updateFeaturedUI); prepareAddMaintainersModalAndFields(document); -prepareAddSoundsModalAndFields(document); + +prepareAddSoundsModalDynamic( + document, + () => store.ids().join(','), + sounds => sounds.forEach(s => store.add(s.id, s)) +); + +const collectionForm = document.getElementById('collection-form'); +if (collectionForm) { + collectionForm.addEventListener('submit', () => { + const addedInput = document.getElementById('added_sound_ids'); + const removedInput = document.getElementById('removed_sound_ids'); + const featuredInput = document.getElementById('featured_sounds'); + + if (addedInput) + addedInput.value = store.idsWithAction('added', true).join(','); + if (removedInput) + removedInput.value = store.idsWithAction('remove').join(','); + if (featuredInput) + featuredInput.value = editor.featuredIdsForSubmit().join(','); + }); +} diff --git a/freesound/static/bw-frontend/src/utils/soundGridEditor.js b/freesound/static/bw-frontend/src/utils/soundGridEditor.js new file mode 100644 index 000000000..2d8af54f3 --- /dev/null +++ b/freesound/static/bw-frontend/src/utils/soundGridEditor.js @@ -0,0 +1,236 @@ +// Client-side sound grid for collection edit. Sort/search/paginate locally +// against pending state in the store; card HTML for the current page is +// fetched from ``render-cards`` and swapped in by htmx, with the paginator +// arriving as an OOB block in the same response. Per-card button state is +// restored from the store after each swap so the server stays unaware of +// pending flags. + +import debounce from 'lodash.debounce'; + +import { initializeObjectSelectorActions } from '../components/objectSelector'; + +export class SoundGridEditor { + // opts: { store, renderCardsUrl?, countEl?, searchInput? }. ``renderCardsUrl`` + // falls back to #sounds-section's data-render-cards-url when omitted. + constructor(opts) { + const configEl = document.getElementById('page-config'); + const config = configEl ? JSON.parse(configEl.textContent) : {}; + + this.store = opts.store; + this.renderCardsUrl = + opts.renderCardsUrl || + (document.getElementById('sounds-section') || {}).dataset + ?.renderCardsUrl || + ''; + this.sectionEl = document.getElementById('sounds-section'); + this.gridEl = document.getElementById('sounds-grid'); + this.countEl = opts.countEl || null; + this.searchInput = opts.searchInput || null; + this.sortSelect = document.getElementById('sort-select'); + this.pageSize = config.sounds_per_page || 20; + + this.currentPage = 1; + this.currentSort = this.sortSelect ? this.sortSelect.value : 'featured'; + this.currentSearch = this.searchInput ? this.searchInput.value.trim() : ''; + this._sortedCache = null; + + this._bindEvents(); + this._autoRender = debounce(() => this.renderPage(), 0); + // Order is sticky: only the sort dropdown re-sorts. Toggling featured/remove + // leaves the cached order alone; newly-added sounds get appended so they + // show up without disturbing existing positions. + this.store.onChange((id, name) => { + if (name === 'added') { + if (this._sortedCache) { + const meta = this.store + .allSoundsWithMeta() + .find(s => s.id === id); + if (meta) this._sortedCache.data.push(meta); + } + this._autoRender(); + } else if (this.countEl && name === 'remove') { + this.countEl.textContent = this.store.presentCount(); + } + }); + + if (this.countEl) this.countEl.textContent = this.store.presentCount(); + this.renderPage(); + } + + getFilteredSorted() { + if (!this._sortedCache || this._sortedCache.sort !== this.currentSort) { + const sorted = this.store.allSoundsWithMeta().slice(); + const comparator = this._getComparator(this.currentSort); + if (comparator) sorted.sort(comparator); + this._sortedCache = { data: sorted, sort: this.currentSort }; + } + + if (this.currentSearch) { + const q = this.currentSearch.toLowerCase(); + return this._sortedCache.data.filter(s => this._matchesSearch(s, q)); + } + + return this._sortedCache.data; + } + + renderPage() { + const filtered = this.getFilteredSorted(); + const totalPages = Math.max(1, Math.ceil(filtered.length / this.pageSize)); + + if (this.currentPage > totalPages) this.currentPage = totalPages; + if (this.currentPage < 1) this.currentPage = 1; + + const offset = (this.currentPage - 1) * this.pageSize; + const pageIds = filtered + .slice(offset, offset + this.pageSize) + .map(s => s.id); + + const params = new URLSearchParams({ + ids: pageIds.join(','), + page: String(this.currentPage), + total: String(totalPages), + }); + if (this.currentSearch) params.set('q', this.currentSearch); + + window.htmx.ajax('GET', `${this.renderCardsUrl}?${params}`, { + target: this.gridEl, + swap: 'innerHTML', + }); + } + + featuredIdsForSubmit() { + const comparator = this._getComparator('featured'); + return this.store + .allSoundsWithMeta() + .filter( + sound => + this.store.has(sound.id, 'featured') && + !this.store.has(sound.id, 'remove') + ) + .sort(comparator) + .map(sound => sound.id); + } + + _matchesSearch(sound, queryLower) { + return ( + (sound.name || '').toLowerCase().includes(queryLower) || + (sound.username || '').toLowerCase().includes(queryLower) + ); + } + + _getComparator(key) { + const store = this.store; + switch (key) { + case 'featured': + return (a, b) => { + const af = store.has(a.id, 'featured'); + const bf = store.has(b.id, 'featured'); + if (af !== bf) return af ? -1 : 1; + if (af && bf) { + const aOrder = Number.isInteger(a.featured_order) + ? a.featured_order + : Number.MAX_SAFE_INTEGER; + const bOrder = Number.isInteger(b.featured_order) + ? b.featured_order + : Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) return aOrder - bOrder; + } + return new Date(a.date_added || 0) - new Date(b.date_added || 0); + }; + case 'created_desc': + return (a, b) => + new Date(b.date_added || 0) - new Date(a.date_added || 0); + case 'created_asc': + return (a, b) => + new Date(a.date_added || 0) - new Date(b.date_added || 0); + case 'name': + // Match Python ``str.lower()`` codepoint ordering (see _sort_collection_sounds). + return (a, b) => { + const an = (a.name || '').toLowerCase(); + const bn = (b.name || '').toLowerCase(); + if (an < bn) return -1; + if (an > bn) return 1; + return 0; + }; + default: + return null; + } + } + + onAfterSwap(fn) { + this._afterSwapCallbacks = this._afterSwapCallbacks || []; + this._afterSwapCallbacks.push(fn); + } + + _hydrateSwappedGrid() { + initializeObjectSelectorActions(this.gridEl, this.store); + if (this.countEl) this.countEl.textContent = this.store.presentCount(); + if (this._afterSwapCallbacks) this._afterSwapCallbacks.forEach(fn => fn()); + } + + _bindEvents() { + // Paginator clicks go through JS so the (URL-less) sort/search state + // survives. Delegating on #sounds-section keeps the handler working + // across OOB swaps that replace #sounds-pagination. + if (this.sectionEl) { + this.sectionEl.addEventListener('click', evt => { + const link = evt.target.closest('#sounds-pagination a[data-page]'); + if (!link) return; + const nextPage = parseInt(link.dataset.page, 10); + if (!Number.isFinite(nextPage) || nextPage < 1) return; + evt.preventDefault(); + this.currentPage = nextPage; + this.renderPage(); + }); + } + + this.gridEl.addEventListener('htmx:afterSwap', () => { + this._hydrateSwappedGrid(); + }); + + this.gridEl.addEventListener('click', evt => { + const clearLink = evt.target.closest('[data-clear-search]'); + if (!clearLink) return; + evt.preventDefault(); + if (this.searchInput) this.searchInput.value = ''; + this.currentSearch = ''; + this.currentPage = 1; + this.renderPage(); + }); + + if (this.searchInput) { + const handleSearch = () => { + this.currentSearch = this.searchInput.value.trim(); + this.currentPage = 1; + this.renderPage(); + }; + this.searchInput.addEventListener('keydown', evt => { + if (evt.key === 'Enter') { + evt.preventDefault(); + handleSearch(); + } + }); + this.searchInput.addEventListener('search', handleSearch); + } + + if (this.sortSelect) { + const applySort = () => { + this.currentSort = this.sortSelect.value; + this._sortedCache = null; + this.currentPage = 1; + this.renderPage(); + }; + let savedValue = this.sortSelect.value; + this.sortSelect.addEventListener('mousedown', () => { + savedValue = this.sortSelect.value; + this.sortSelect.selectedIndex = -1; + }); + this.sortSelect.addEventListener('change', applySort); + this.sortSelect.addEventListener('blur', () => { + if (this.sortSelect.selectedIndex === -1) { + this.sortSelect.value = savedValue; + } + }); + } + } +} diff --git a/freesound/static/bw-frontend/src/utils/soundStateStore.js b/freesound/static/bw-frontend/src/utils/soundStateStore.js new file mode 100644 index 000000000..39f45ada1 --- /dev/null +++ b/freesound/static/bw-frontend/src/utils/soundStateStore.js @@ -0,0 +1,114 @@ +// Map-of-Sets store for per-sound transient flags (added/remove/featured) +// on the collection edit page. Sounds aren't actually added or removed until +// the form is submitted; this tracks the pending state in the meantime. +class SoundStateStore { + // ``actionNames`` is e.g. ['added', 'remove', 'featured']. Each becomes a + // Set tracking which sounds carry that flag. ``this.actionNames`` is + // exposed (frozen) for callers that need to enumerate them. + constructor(actionNames = [], { maxFeatured = Infinity } = {}) { + this._meta = new Map(); // id → metadata (also "known ids") + this._sets = new Map(); // actionName → Set + this._listeners = []; + this.maxFeatured = maxFeatured; + + actionNames.forEach(name => this._sets.set(name, new Set())); + this.actionNames = Object.freeze([...actionNames]); + } + + // Bulk-init: register sounds, then apply { actionName: [ids] }. + load(soundsArray, flagged = {}) { + soundsArray.forEach(s => this._meta.set(s.id, s)); + for (const [name, ids] of Object.entries(flagged)) { + const set = this._sets.get(name); + if (set) { + ids.forEach(id => { + if (this._meta.has(id)) set.add(id); + }); + } + } + return this; + } + + // Register a newly-added sound with the 'added' flag and notify listeners. + add(id, meta) { + if (!this._meta.has(id)) { + this._meta.set(id, meta); + const added = this._sets.get('added'); + if (added) { + added.add(id); + this._notify(id, 'added', true); + } + } + return this; + } + + featuredCount() { + const featured = this._sets.get('featured'); + if (!featured) return 0; + const removed = this._sets.get('remove'); + if (!removed || removed.size === 0) return featured.size; + let count = 0; + for (const id of featured) { + if (!removed.has(id)) count++; + } + return count; + } + + // Returns the new active state, or undefined if action/id isn't tracked. + // Returns undefined (no-op) when featuring would exceed the limit. + toggleAction(id, name) { + const set = this._sets.get(name); + if (!set || !this._meta.has(id)) return undefined; + const active = !set.has(id); + if (active && name === 'featured' && this.featuredCount() >= this.maxFeatured) { + return undefined; + } + if (active) set.add(id); + else set.delete(id); + this._notify(id, name, active); + return active; + } + + has(id, name) { + const set = this._sets.get(name); + return set ? set.has(id) : false; + } + + ids() { + return Array.from(this._meta.keys()); + } + + // Tracked sounds minus those with the 'remove' flag set. + presentCount() { + const removed = this._sets.get('remove'); + return this._meta.size - (removed ? removed.size : 0); + } + + // All ids carrying ``name``. Pass excludeRemoved=true to skip removed sounds. + idsWithAction(name, excludeRemoved = false) { + const set = this._sets.get(name); + if (!set) return []; + const removed = excludeRemoved ? this._sets.get('remove') : null; + if (!removed || removed.size === 0) return Array.from(set); + const result = []; + for (const id of set) { + if (!removed.has(id)) result.push(id); + } + return result; + } + + allSoundsWithMeta() { + return Array.from(this._meta.values()); + } + + onChange(listener) { + this._listeners.push(listener); + return this; + } + + _notify(id, name, active) { + this._listeners.forEach(fn => fn(id, name, active)); + } +} + +export { SoundStateStore }; diff --git a/freesound/static/bw-frontend/styles/atoms/selectableObject.scss b/freesound/static/bw-frontend/styles/atoms/selectableObject.scss index 22b5e8b30..01b34c860 100644 --- a/freesound/static/bw-frontend/styles/atoms/selectableObject.scss +++ b/freesound/static/bw-frontend/styles/atoms/selectableObject.scss @@ -10,4 +10,142 @@ border: 2px solid $navy-light-grey; transition: border-color 0.3s linear; } + + &.with-actions { + display: flex; + flex-direction: column; + + &.marked-for-removal { + // Disable sound player and all interactions + > div:first-child { + opacity: 0.35; + pointer-events: none; + user-select: none; + } + } + } +} + +.featured-highlight { + background-color: rgba(253, 221, 68, 0.24); +} + +.featured-counter-label { + padding-left: 2px; +} + + +.bw-object-actions { + display: flex; + width: 100%; + padding: 6px 0 0; + gap: 8px; + + .btn-inverse { + flex: 1; + padding: 8px 12px; + font-size: 12px; + text-align: center; + + .label-default { + display: inline; + } + + .label-hover { + display: none; + } + + &:hover { + .label-default { + display: none; + } + + .label-hover { + display: inline; + } + } + } + + .featured-toggle { + .label-active-default, + .label-active-hover { + display: none; + } + + &.active { + background: $yellow; + border-color: $yellow; + color: #141424; // hardcoded dark to stay readable on yellow in dark mode + + .label-default, + .label-hover { + display: none; + } + + .label-active-default { + display: inline; + } + + &:hover { + background: $black; + border-color: $navy-light-grey; + color: $white; + + .label-active-default { + display: none; + } + + .label-active-hover { + display: inline; + } + } + } + } + + .featured-toggle:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + } + + .remove-toggle { + flex: 0 0 auto; + padding: 8px 12px; + min-width: 60px; + + // Force 'trash' to stay visible on hover + &:hover { + background: $red; + border-color: $red; + color: $white; + + .label-default { + display: inline; + } + + .label-hover { + display: none; + } + } + + &.active { + background: transparent; + border-color: $navy-light-grey; + color: $navy-grey; + + .label-default { + display: none; + } + + .label-hover { + display: inline; + } + + &:hover { + background: $almost-white; + border-color: $navy-grey; + color: $black; + } + } + } } diff --git a/freesound/static/bw-frontend/styles/molecules/forms.scss b/freesound/static/bw-frontend/styles/molecules/forms.scss index dce34f1cb..616130032 100644 --- a/freesound/static/bw-frontend/styles/molecules/forms.scss +++ b/freesound/static/bw-frontend/styles/molecules/forms.scss @@ -10,6 +10,8 @@ color: $navy-grey; } + // Keep base label typography, but avoid forcing component labels (stars, radios, filters) + // into full-width layouts. Opt in with .bw-form__label when needed. > label, .bw-form__label { width: 100%; @@ -20,6 +22,7 @@ color: $black; } + // Generic form field style. Search fields are styled by the input atom/wrapper. input:not([type='search']):not(.tags-input):not(.mapboxgl-ctrl-geocoder--input), textarea:not(.tags-input):not(.mapboxgl-ctrl-geocoder--input) { margin-top: 12px; diff --git a/fscollections/admin.py b/fscollections/admin.py index 5f9c5c59a..e47fda79a 100644 --- a/fscollections/admin.py +++ b/fscollections/admin.py @@ -7,9 +7,9 @@ @admin.register(Collection) class CollectionAdmin(admin.ModelAdmin): - fields = ["user", "name", "num_sounds", "public"] + fields = ["user", "name", "num_sounds", "public", "featured_sound_ids"] filter_horizontal = ["sounds"] - list_display = ("name", "user", "num_sounds", "public", "get_sounds") + list_display = ("name", "user", "num_sounds", "public", "get_sounds", "featured_sound_ids") readonly_fields = ["created"] actions = ["make_public", "make_private"] diff --git a/fscollections/forms.py b/fscollections/forms.py index 9e2c4156c..1cbdac2f7 100644 --- a/fscollections/forms.py +++ b/fscollections/forms.py @@ -18,17 +18,38 @@ # See AUTHORS file. # -import re - from django import forms from django.conf import settings from django.contrib.auth.models import User +from django.db import transaction from django.forms import Textarea, TextInput from fscollections.models import Collection, CollectionSound from sounds.models import Sound +class CommaSeparatedIdField(forms.CharField): + """CharField that coerces a comma-separated string of ints into a set (or list).""" + + def __init__(self, *args, as_list=False, **kwargs): + self._as_list = as_list + super().__init__(*args, **kwargs) + + def clean(self, value): + value = super().clean(value) + if not value: + return [] if self._as_list else set() + if self._as_list: + seen = set() + ids = [] + for i in value.replace(" ", "").split(","): + if i.isdigit() and int(i) not in seen: + seen.add(int(i)) + ids.append(int(i)) + return ids + return {int(i) for i in value.replace(" ", "").split(",") if i.isdigit()} + + class SelectCollectionOrNewCollectionForm(forms.Form): """This form unfolds all the available collections for the user in a modal and allows to select one. So far it is only used to add one sound to a collection interacting from the sound player (as previously done @@ -59,6 +80,8 @@ class SelectCollectionOrNewCollectionForm(forms.Form): new_collection_name = forms.CharField(label=None, max_length=128, required=False) use_last_collection = forms.BooleanField(widget=forms.HiddenInput(), required=False, initial=False) + + mark_as_featured = forms.BooleanField(label=False, required=False, initial=False) user_collections = None user_available_collections = None user_full_collections = None @@ -117,31 +140,36 @@ def save(self, *args, **kwargs): collection_to_use, _ = Collection.objects.get_or_create( name="My bookmarks", user=self.user_saving_sound, is_default_collection=True ) - # TODO: what happens if user has more than one is_default_collection? Shouldn't happen but this needs a RESTRICTION elif self.cleaned_data["collection"] == self.NEW_COLLECTION_CHOICE_VALUE: if self.cleaned_data["new_collection_name"] != "": - collection = Collection.objects.create( + collection_to_use = Collection.objects.create( user=self.user_saving_sound, name=self.cleaned_data["new_collection_name"] ) - collection_to_use = collection else: collection_to_use = Collection.objects.get(id=self.cleaned_data["collection"]) else: try: - last_user_collection = Collection.objects.filter(user=self.user_saving_sound).order_by("-created")[0] - collection_to_use = last_user_collection + collection_to_use = Collection.objects.filter(user=self.user_saving_sound).order_by("-created")[0] except IndexError: pass - maintainers_list = list(collection_to_use.maintainers.all().values_list("id", flat=True)) - if self.user_saving_sound == collection_to_use.user: - collection, _ = Collection.objects.get_or_create(name=collection_to_use.name, id=collection_to_use.id) - elif self.user_saving_sound.id in maintainers_list: - collection, _ = Collection.objects.get_or_create(name=collection_to_use.name, id=collection_to_use.id) + if collection_to_use is None: + raise forms.ValidationError("Could not determine which collection to use.") + CollectionSound.objects.get_or_create( - user=self.user_saving_sound, collection=collection, sound=sound, defaults={"status": "OK"} + user=self.user_saving_sound, collection=collection_to_use, sound=sound, defaults={"status": "OK"} ) - return collection + + # Handle mark as featured + if self.cleaned_data.get("mark_as_featured"): + if ( + sound.id not in collection_to_use.featured_sound_ids + and len(collection_to_use.featured_sound_ids) < settings.MAX_FEATURED_SOUNDS_PER_COLLECTION + ): + collection_to_use.featured_sound_ids = collection_to_use.featured_sound_ids + [sound.id] + collection_to_use.save(update_fields=["featured_sound_ids"]) + + return collection_to_use def clean(self): clean_data = super().clean() @@ -190,23 +218,33 @@ def clean(self): class CollectionEditForm(forms.ModelForm): - collection_sounds = forms.CharField( - min_length=1, - widget=forms.widgets.HiddenInput(attrs={"id": "collection_sounds", "name": "collection_sounds"}), + added_sounds = CommaSeparatedIdField( + widget=forms.widgets.HiddenInput(attrs={"id": "added_sound_ids"}), required=False, ) - maintainers = forms.CharField( + removed_sounds = CommaSeparatedIdField( + widget=forms.widgets.HiddenInput(attrs={"id": "removed_sound_ids"}), + required=False, + ) + + maintainers = CommaSeparatedIdField( min_length=1, widget=forms.widgets.HiddenInput(attrs={"id": "maintainers"}), required=False ) + featured_sounds = CommaSeparatedIdField( + as_list=True, + widget=forms.widgets.HiddenInput(attrs={"id": "featured_sounds", "name": "featured_sounds"}), + required=False, + ) + def __init__(self, *args, **kwargs): self.is_owner = kwargs.pop("is_owner", False) self.is_maintainer = kwargs.pop("is_maintainer", False) super().__init__(*args, **kwargs) self.fields["public"].label = "Visibility" - self.fields["collection_sounds"].help_text = ( + self.fields["added_sounds"].help_text = ( f"You have reached the maximum number of sounds available for a collection ({settings.MAX_SOUNDS_PER_COLLECTION}). " "In order to add new sounds, first remove some of the current ones." ) @@ -220,89 +258,104 @@ def __init__(self, *args, **kwargs): for field in self.fields: self.fields[field].disabled = True + def clean_name(self): + return self.cleaned_data["name"].strip() + def clean(self): cleaned_data = super().clean() if not self.is_owner and not self.is_maintainer: self.add_error( field=None, error=forms.ValidationError("You don't have permissions to edit this collection") ) - else: - if cleaned_data["name"] != self.instance.name: - if Collection.objects.filter(user=self.instance.user, name=cleaned_data["name"]).exists(): - self.add_error("name", forms.ValidationError("You already have a collection with this name")) - elif cleaned_data["name"].lower() == "my bookmarks": - self.add_error( - "name", - forms.ValidationError( - "This collection name is reserved for your personal default collection. Please choose another one." - ), - ) - collection_sounds = self.cleaned_data.get("collection_sounds").split(",") - if len(collection_sounds) > settings.MAX_SOUNDS_PER_COLLECTION: + return cleaned_data + + if cleaned_data["name"] != self.instance.name: + if Collection.objects.filter(user=self.instance.user, name=cleaned_data["name"]).exists(): + self.add_error("name", forms.ValidationError("You already have a collection with this name")) + elif cleaned_data["name"].lower() == "my bookmarks": + self.add_error( + "name", + forms.ValidationError( + "This collection name is reserved for your personal default collection. Please choose another one." + ), + ) + + featured = cleaned_data.get("featured_sounds", []) + if len(featured) > settings.MAX_FEATURED_SOUNDS_PER_COLLECTION: + self.add_error( + "featured_sounds", + forms.ValidationError( + f"You can only feature up to {settings.MAX_FEATURED_SOUNDS_PER_COLLECTION} sounds per collection." + ), + ) + + added = cleaned_data.get("added_sounds", set()) + removed = cleaned_data.get("removed_sounds", set()) + current_sound_ids = set( + CollectionSound.objects.filter(collection=self.instance).values_list("sound_id", flat=True) + ) + + valid_added_ids = set(Sound.objects.filter(id__in=added).values_list("id", flat=True)) + invalid_added_ids = added - valid_added_ids + if invalid_added_ids: + self.add_error("added_sounds", forms.ValidationError("Some sounds to add are invalid.")) + added = valid_added_ids + cleaned_data["added_sounds"] = added + + effective_removed = removed & current_sound_ids + effective_added = added - current_sound_ids + net_count = len(current_sound_ids) - len(effective_removed) + len(effective_added) + if net_count > settings.MAX_SOUNDS_PER_COLLECTION: self.add_error( - "collection_sounds", + "added_sounds", forms.ValidationError( f"You have exceeded the maximum number of sounds for a collection ({settings.MAX_SOUNDS_PER_COLLECTION})." ), ) - cleaned_data["collection_sounds"] = collection_sounds[: self.instance.num_sounds] return cleaned_data - def clean_ids_field(self, field): - # this function cleans the sounds and maintainers fields which store temporary changes on the edit URL - new_field = re.sub("[^0-9,]", "", self.cleaned_data[field]) - new_field = re.sub(",+", ",", new_field) - new_field = re.sub("^,+", "", new_field) - new_field = re.sub(",+$", "", new_field) - if len(new_field) > 0: - new_field = {int(usr) for usr in new_field.split(",")} - else: - new_field = set() - return new_field - - def save(self, user_adding_sound): - """This method is used to apply the temporary changes from the edit URL to the DB. - Useful for maintainers and sounds, where several objects are added to the Collection attrs. - This way, the server side does not change until the Save Collection button is clicked. + def save(self, user_adding_sound, **kwargs): + """Apply the pending delta (added/removed sounds and featured list) to the DB. Args: user_adding_sound (User): the user modifying the collection Returns: - collection (Collection): the saved collection with proper modficiations + collection (Collection): the saved collection """ - collection = super().save(commit=False) - new_sounds = self.clean_ids_field("collection_sounds") - current_sounds = list(Sound.objects.filter(collections=collection).values_list("id", flat=True)) - for snd in new_sounds: - if snd not in current_sounds: - sound = Sound.objects.get(id=snd) - CollectionSound.objects.get_or_create( - user=user_adding_sound, sound=sound, collection=collection, defaults={"status": "OK"} + with transaction.atomic(): + collection = super().save(commit=False) + sounds_to_add = self.cleaned_data["added_sounds"] + sounds_to_remove = self.cleaned_data["removed_sounds"] + + if sounds_to_add: + CollectionSound.objects.bulk_create( + [ + CollectionSound(user=user_adding_sound, sound_id=snd, collection=collection, status="OK") + for snd in sounds_to_add + ], + ignore_conflicts=True, ) - else: - current_sounds.remove(snd) - - sounds = Sound.objects.filter(id__in=current_sounds) - CollectionSound.objects.filter(collection=collection, sound__in=sounds).delete() - - new_maintainers = set(self.clean_ids_field("maintainers")) - # if the owner of the collection has been added as a maintainer, discard it - if collection.user.id in new_maintainers: - new_maintainers.remove(collection.user.id) - current_maintainers = list(self.instance.maintainers.values_list("id", flat=True)) - for usr in new_maintainers: - if usr not in current_maintainers: - maintainer = User.objects.get(id=usr) - collection.maintainers.add(maintainer) - else: - current_maintainers.remove(usr) - for usr in current_maintainers: - maintainer = User.objects.get(id=usr) - collection.maintainers.remove(maintainer) - collection.save() - return collection + if sounds_to_remove: + CollectionSound.objects.filter(collection=collection, sound_id__in=sounds_to_remove).delete() + + new_maintainers = self.cleaned_data["maintainers"] + new_maintainers.discard(collection.user.id) + current_maintainers = set(collection.maintainers.values_list("id", flat=True)) + if new_maintainers != current_maintainers: + collection.maintainers.set(new_maintainers) + + featured_ids = self.cleaned_data["featured_sounds"] + final_sound_ids = set( + CollectionSound.objects.filter(collection=collection).values_list("sound_id", flat=True) + ) + collection.featured_sound_ids = [sid for sid in featured_ids if sid in final_sound_ids][ + : settings.MAX_FEATURED_SOUNDS_PER_COLLECTION + ] + + collection.save() + return collection class Meta: model = Collection @@ -315,13 +368,10 @@ class Meta: class CollectionEditFormAsMaintainer(CollectionEditForm): - class Meta(CollectionEditForm.Meta): - fields = CollectionEditForm.Meta.fields + ["collection_sounds"] + ["maintainers"] - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for field in self.fields: - if field != "collection_sounds": + if field not in ("added_sounds", "removed_sounds"): self.fields[field].disabled = True def clean(self): @@ -331,8 +381,9 @@ def clean(self): # and if any change in these is found, an error is raised. To prevent changes in "maintainers" even though it is included # in the form but disabled (to allow the user to view but not to modify the field), the original collection maintainers # are retrieved from DB to ensure no changes are applied to this attribute. - collection_maintainers = list(self.instance.maintainers.values_list("id", flat=True)) - cleaned_data["maintainers"] = (",").join(str(x) for x in collection_maintainers) + cleaned_data["maintainers"] = set(self.instance.maintainers.values_list("id", flat=True)) + # Preserve featured_sounds from the original instance (maintainers cannot edit) + cleaned_data["featured_sounds"] = list(self.instance.featured_sound_ids) for field in CollectionEditForm.Meta.fields: if cleaned_data[field] != getattr(self.instance, field): self.add_error(field, forms.ValidationError("You don't have permissions to edit this field")) @@ -385,12 +436,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def clean(self): - new_maintainers = self.cleaned_data["maintainer"].split(",").replace(" ", "") + new_maintainers = [u.strip() for u in self.cleaned_data["maintainer"].split(",") if u.strip()] for username in new_maintainers: try: new_maintainer = User.objects.get(username=username) if new_maintainer in self.collection.maintainers.all(): raise forms.ValidationError("The user is already a maintainer") - return super().clean() except User.DoesNotExist: raise forms.ValidationError("The user does not exist") + return super().clean() diff --git a/fscollections/migrations/0003_remove_collectionsound_featured_sound_and_more.py b/fscollections/migrations/0003_remove_collectionsound_featured_sound_and_more.py new file mode 100644 index 000000000..5e8104cdc --- /dev/null +++ b/fscollections/migrations/0003_remove_collectionsound_featured_sound_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.19 on 2026-01-21 17:34 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +def backfill_featured_sound_ids(apps, schema_editor): + Collection = apps.get_model("fscollections", "Collection") + CollectionSound = apps.get_model("fscollections", "CollectionSound") + + for collection in Collection.objects.all().iterator(): + featured_ids = list( + CollectionSound.objects.filter(collection=collection, featured_sound=True).values_list("sound_id", flat=True) + ) + if featured_ids: + Collection.objects.filter(id=collection.id).update(featured_sound_ids=featured_ids) + + +def reverse_backfill_featured_sound_ids(apps, schema_editor): + Collection = apps.get_model("fscollections", "Collection") + CollectionSound = apps.get_model("fscollections", "CollectionSound") + + for collection in Collection.objects.exclude(featured_sound_ids=[]).iterator(): + CollectionSound.objects.filter(collection=collection, sound_id__in=collection.featured_sound_ids).update( + featured_sound=True + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("fscollections", "0002_alter_collectionsound_unique_together"), + ] + + operations = [ + migrations.AddField( + model_name="collection", + name="featured_sound_ids", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), blank=True, default=list, size=250 + ), + ), + migrations.RunPython(backfill_featured_sound_ids, reverse_backfill_featured_sound_ids), + migrations.RemoveField( + model_name="collectionsound", + name="featured_sound", + ), + ] diff --git a/fscollections/models.py b/fscollections/models.py index 7fff9cd9a..35c7e7657 100644 --- a/fscollections/models.py +++ b/fscollections/models.py @@ -21,6 +21,7 @@ from urllib.parse import quote from django.contrib.auth.models import User +from django.contrib.postgres.fields import ArrayField from django.db import models from django.db.models import F, Sum from django.db.models.functions import Greatest @@ -30,6 +31,7 @@ from django.urls import reverse from django.utils.text import slugify +from freesound import settings from sounds.models import License, Sound @@ -46,6 +48,9 @@ class Collection(models.Model): num_downloads = models.PositiveIntegerField(default=0) public = models.BooleanField(default=False) is_default_collection = models.BooleanField(default=False) + featured_sound_ids = ArrayField( + models.IntegerField(), size=settings.MAX_FEATURED_SOUNDS_PER_COLLECTION, blank=True, default=list + ) def __str__(self): return f"{self.name}" @@ -121,12 +126,10 @@ def get_total_collection_sounds_length(self): return result["total_duration"] or 0 def save(self, *args, **kwargs): - self.num_sounds = CollectionSound.objects.filter(collection=self).count() - if self.num_sounds > 0: - # this need to be reviewed, featured_sound feature is not fully developed - csound = CollectionSound.objects.filter(collection=self, status="OK").first() - csound.featured_sound = True - csound.save() + # Update num_sounds count + if self.pk: + self.num_sounds = CollectionSound.objects.filter(collection=self).count() + super().save(*args, **kwargs) @@ -135,7 +138,6 @@ class CollectionSound(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) sound = models.ForeignKey(Sound, on_delete=models.CASCADE) collection = models.ForeignKey(Collection, related_name="collectionsound", on_delete=models.CASCADE) - featured_sound = models.BooleanField(default=False) created = models.DateTimeField(db_index=True, auto_now_add=True) STATUS_CHOICES = ( @@ -162,6 +164,22 @@ def update_collection_num_sounds_bulk_changes(sender, instance, **kwargs): Collection.objects.filter(collectionsound=instance).update(num_sounds=Greatest(F("num_sounds") - 1, 0)) +@receiver(post_delete, sender=CollectionSound) +def remove_not_valid_featured_sounds(sender, instance, **kwargs): + """Remove featured_sound_ids that are no longer part of the collection.""" + if instance and instance.collection_id: + collection = instance.collection + if collection.featured_sound_ids: + # Get current sound IDs in the collection + valid_sound_ids = set( + CollectionSound.objects.filter(collection=collection).values_list("sound_id", flat=True) + ) + # Filter out any featured_sound_ids that are not in the collection + valid_featured_ids = [sid for sid in collection.featured_sound_ids if sid in valid_sound_ids] + if valid_featured_ids != collection.featured_sound_ids: + Collection.objects.filter(id=collection.id).update(featured_sound_ids=valid_featured_ids) + + @receiver(post_save, sender=CollectionSound) def mark_sound_dirty_on_collection_add(sender, instance, **kwargs): if instance: diff --git a/fscollections/templatetags/display_collections.py b/fscollections/templatetags/display_collections.py index cc980783f..712006f6c 100644 --- a/fscollections/templatetags/display_collections.py +++ b/fscollections/templatetags/display_collections.py @@ -32,9 +32,10 @@ def display_collection(context, collection_id): collection = get_object_or_404(Collection, id=collection_id) request = context.get("request") - try: - sound = Sound.objects.get(collections=collection, collectionsound__featured_sound=True) - except Sound.DoesNotExist: - sound = None - tvars = {"collection": collection, "ft_sound": sound, "request": request} + if collection.featured_sound_ids: + header_sounds = Sound.objects.bulk_query_id_public(collection.featured_sound_ids) + else: + header_sounds = Sound.objects.bulk_sounds_for_collection(collection.id, limit=1) + + tvars = {"collection": collection, "header_sounds": header_sounds, "request": request} return tvars diff --git a/fscollections/tests.py b/fscollections/tests.py index 0d6a344f7..ab4410abf 100644 --- a/fscollections/tests.py +++ b/fscollections/tests.py @@ -5,7 +5,8 @@ from django.urls import reverse from django.utils.text import slugify -from fscollections.models import Collection +from fscollections.models import Collection, CollectionSound +from fscollections.views import serialize_collection_sounds from utils.test_helpers import create_user_and_sounds @@ -240,7 +241,8 @@ def test_edit_collection_as_user(self): "name": "testcollection", "description": "", "public": False, - "collection_sounds": f"{self.sound.id},{self.sound1.id},{self.sound.id}", + "added_sounds": f"{self.sound.id},{self.sound1.id},{self.sound.id}", + "removed_sounds": "", } resp = self.client.post( reverse("edit-collection", args=[self.collection.id, slugify(self.collection.name)]) + "?ajax=1", form_data @@ -254,7 +256,8 @@ def test_edit_collection_as_user(self): "name": "testcollection", "description": "", "public": False, - "collection_sounds": f"{self.sound.id}", + "added_sounds": "", + "removed_sounds": f"{self.sound1.id}", } resp = self.client.post( reverse("edit-collection", args=[self.collection.id, slugify(self.collection.name)]) + "?ajax=1", form_data @@ -273,7 +276,13 @@ def test_edit_collection_as_user(self): moderation_state="OK", ) sounds_ids = ",".join([str(s.id) for s in added_sounds]) - form_data = {"name": "testcollection", "description": "", "public": False, "collection_sounds": sounds_ids} + form_data = { + "name": "testcollection", + "description": "", + "public": False, + "added_sounds": sounds_ids, + "removed_sounds": "", + } resp = self.client.post( reverse("edit-collection", args=[self.collection.id, slugify(self.collection.name)]) + "?ajax=1", form_data ) @@ -293,7 +302,8 @@ def test_edit_collection_as_maintainer(self): "name": "testcollection", "description": "", "public": False, - "collection_sounds": str(self.sound.id), + "added_sounds": str(self.sound.id), + "removed_sounds": "", } resp = self.client.post( reverse("edit-collection", args=[self.collection.id, slugify(self.collection.name)]) + "?ajax=1", form_data @@ -302,7 +312,8 @@ def test_edit_collection_as_maintainer(self): self.assertEqual(f"/collections/{self.collection.id}-{slugify(self.collection.name)}/", resp.url) self.collection.refresh_from_db() self.assertEqual(1, self.collection.num_sounds) - form_data.pop("collection_sounds") + form_data["added_sounds"] = "" + form_data["removed_sounds"] = str(self.sound.id) resp = self.client.post( reverse("edit-collection", args=[self.collection.id, slugify(self.collection.name)]) + "?ajax=1", form_data ) @@ -334,3 +345,177 @@ def test_edit_collection_as_maintainer(self): self.assertEqual(resp.status_code, 302) self.assertEqual(f"/collections/{self.collection.id}-{slugify(self.collection.name)}/", resp.url) self.assertTrue(Collection.objects.filter(id=self.collection.id).exists()) + + def _edit_url(self, collection=None): + c = collection or self.collection + return reverse("edit-collection", args=[c.id, slugify(c.name)]) + + def _post_edit(self, form_data, collection=None): + return self.client.post(self._edit_url(collection) + "?ajax=1", form_data) + + def _base_form_data(self, **overrides): + data = { + "name": "testcollection", + "description": "", + "public": False, + "added_sounds": "", + "removed_sounds": "", + "featured_sounds": "", + } + data.update(overrides) + return data + + def test_edit_featured_sounds_as_user(self): + self.client.force_login(self.user) + + # Add sounds and feature two of them + resp = self._post_edit( + self._base_form_data( + added_sounds=f"{self.sound.id},{self.sound1.id},{self.sound2.id}", + featured_sounds=f"{self.sound.id},{self.sound1.id}", + ) + ) + self.assertEqual(302, resp.status_code) + self.collection.refresh_from_db() + self.assertEqual(3, self.collection.num_sounds) + self.assertEqual([self.sound.id, self.sound1.id], self.collection.featured_sound_ids) + + # Clear all featured sounds + resp = self._post_edit(self._base_form_data()) + self.assertEqual(302, resp.status_code) + self.collection.refresh_from_db() + self.assertEqual([], self.collection.featured_sound_ids) + + def test_edit_featured_sounds_as_maintainer(self): + self.collection.maintainers.add(self.maintainer) + self.collection.featured_sound_ids = [self.sound.id] + self.collection.save() + + CollectionSound.objects.create(user=self.user, sound=self.sound, collection=self.collection, status="OK") + CollectionSound.objects.create(user=self.user, sound=self.sound1, collection=self.collection, status="OK") + self.collection.refresh_from_db() + + self.client.force_login(self.maintainer) + + # Maintainer tries to change featured sounds — should be ignored + resp = self._post_edit( + self._base_form_data( + featured_sounds=f"{self.sound1.id}", + ) + ) + self.assertEqual(302, resp.status_code) + self.collection.refresh_from_db() + self.assertEqual([self.sound.id], self.collection.featured_sound_ids) + + def test_add_sounds_outside_collection_to_featured(self): + self.client.force_login(self.user) + + _, _, other_sounds = create_user_and_sounds( + num_sounds=1, count_offset=10, user=self.user, processing_state="OK", moderation_state="OK" + ) + sound_not_in_collection = other_sounds[0] + + # Add sound and sound1, but try to feature a sound not in the collection + resp = self._post_edit( + self._base_form_data( + added_sounds=f"{self.sound.id},{self.sound1.id}", + featured_sounds=f"{self.sound.id},{sound_not_in_collection.id}", + ) + ) + self.assertEqual(302, resp.status_code) + self.collection.refresh_from_db() + self.assertEqual([self.sound.id], self.collection.featured_sound_ids) + + def test_remove_sounds_that_are_featured(self): + self.client.force_login(self.user) + + # Add all sounds and feature them + resp = self._post_edit( + self._base_form_data( + added_sounds=f"{self.sound.id},{self.sound1.id},{self.sound2.id}", + featured_sounds=f"{self.sound.id},{self.sound1.id},{self.sound2.id}", + ) + ) + self.assertEqual(302, resp.status_code) + self.collection.refresh_from_db() + self.assertEqual([self.sound.id, self.sound1.id, self.sound2.id], self.collection.featured_sound_ids) + + # Remove a featured sound from collection — it should be removed from featured_sound_ids too + resp = self._post_edit( + self._base_form_data( + removed_sounds=str(self.sound1.id), + featured_sounds=f"{self.sound.id},{self.sound1.id},{self.sound2.id}", + ) + ) + self.assertEqual(302, resp.status_code) + self.collection.refresh_from_db() + self.assertEqual([self.sound.id, self.sound2.id], self.collection.featured_sound_ids) + + def test_collection_htmx_request_renders_sounds_fragment(self): + self.client.force_login(self.user) + CollectionSound.objects.create(user=self.user, sound=self.sound, collection=self.collection, status="OK") + + resp = self.client.get( + reverse("collection", args=[self.collection.id, slugify(self.collection.name)]), + HTTP_HX_REQUEST="true", + ) + + self.assertEqual(200, resp.status_code) + self.assertIn("collections/collection.html", [template.name for template in resp.templates]) + self.assertContains(resp, 'id="sounds-grid"') + + def test_render_collection_cards_preserves_order_and_edit_container_attrs(self): + self.client.force_login(self.user) + cards_url = reverse("collection-render-cards", args=[self.collection.id, slugify(self.collection.name)]) + + resp = self.client.get( + cards_url, + { + "ids": f"{self.sound2.id},{self.sound.id}", + "page": "1", + "total": "1", + }, + HTTP_HX_CURRENT_URL="/collections/test/edit/", + ) + + self.assertEqual(200, resp.status_code) + html = resp.content.decode() + self.assertIn(f'data-max-elements="{settings.MAX_SOUNDS_PER_COLLECTION}"', html) + self.assertLess( + html.index(f'data-object-id="{self.sound2.id}"'), + html.index(f'data-object-id="{self.sound.id}"'), + ) + + def test_render_collection_cards_forbidden_for_non_member(self): + self.client.force_login(self.external_user) + cards_url = reverse("collection-render-cards", args=[self.collection.id, slugify(self.collection.name)]) + resp = self.client.get(cards_url, {"ids": f"{self.sound.id}"}) + self.assertEqual(403, resp.status_code) + + def test_render_collection_cards_empty_state_with_search(self): + """The render-cards endpoint owns the empty-state message so the JS + editor doesn't need a non-htmx render path.""" + self.client.force_login(self.user) + cards_url = reverse("collection-render-cards", args=[self.collection.id, slugify(self.collection.name)]) + resp = self.client.get( + cards_url, + {"ids": "", "page": "1", "total": "1", "q": "nonsense"}, + HTTP_HX_CURRENT_URL="/collections/test/edit/", + ) + self.assertEqual(200, resp.status_code) + self.assertContains(resp, "No sounds found matching") + self.assertContains(resp, "data-clear-search") + + def test_serialized_collection_sounds_include_featured_order(self): + CollectionSound.objects.create(user=self.user, sound=self.sound, collection=self.collection, status="OK") + CollectionSound.objects.create(user=self.user, sound=self.sound1, collection=self.collection, status="OK") + CollectionSound.objects.create(user=self.user, sound=self.sound2, collection=self.collection, status="OK") + self.collection.featured_sound_ids = [self.sound2.id, self.sound.id] + self.collection.save() + + sidecar = serialize_collection_sounds(self.collection) + serialized_by_id = {sound["id"]: sound for sound in sidecar} + + self.assertEqual(0, serialized_by_id[self.sound2.id]["featured_order"]) + self.assertEqual(1, serialized_by_id[self.sound.id]["featured_order"]) + self.assertIsNone(serialized_by_id[self.sound1.id]["featured_order"]) diff --git a/fscollections/urls.py b/fscollections/urls.py index 7cd441223..8c33ad4de 100644 --- a/fscollections/urls.py +++ b/fscollections/urls.py @@ -7,9 +7,19 @@ path("-/", views.collection, name="collection"), path("/add/", views.add_sound_to_collection, name="add-sound-to-collection"), path("create/", views.create_collection, name="create-collection"), + path( + "-/render-cards/", + views.render_collection_cards, + name="collection-render-cards", + ), path("-/edit", views.edit_collection, name="edit-collection"), path("-/delete", views.delete_collection, name="delete-collection"), path("-/download/", views.download_collection, name="download-collection"), + path( + "-/downloaders/", + views.collection_downloaders, + name="collection-downloaders", + ), path("-/licenses/", views.collection_licenses, name="collection-licenses"), path( "-/addsoundsmodal", diff --git a/fscollections/views.py b/fscollections/views.py index 922a57518..8752f4d30 100644 --- a/fscollections/views.py +++ b/fscollections/views.py @@ -19,12 +19,14 @@ # from functools import wraps +from operator import itemgetter +from types import SimpleNamespace from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User -from django.db.models import Q +from django.db.models import Case, IntegerField, Q, Value, When from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse @@ -40,7 +42,7 @@ from sounds.models import Sound from sounds.views import add_sounds_modal_helper from utils.downloads import download_sounds -from utils.pagination import paginate +from utils.pagination import build_paginator_template_context, paginate def resolve_collection_from_url(view_func): @@ -66,22 +68,49 @@ def _wrapped_view(request, collection_id, collection_name, *args, **kwargs): @resolve_collection_from_url def collection(request, collection): user = request.user - is_owner = False - is_maintainer = False - maintainers = [] - is_maintainer = collection.maintainers.filter(username=user.username).exists() is_owner = user == collection.user maintainers = collection.maintainers.all() - tvars = {"collection": collection, "is_owner": is_owner, "is_maintainer": is_maintainer, "maintainers": maintainers} - # one URL needed to display all collections and one URL to display ONE collection at a time - # the collections_for_user can be reused to display ONE collection so give it a thought on full collections display - collection_sounds = Sound.objects.prefetch_related("collections").filter(collections=collection) - paginator = paginate(request, collection_sounds, settings.BOOKMARKS_PER_PAGE) - page_sounds = Sound.objects.ordered_ids([sound.id for sound in paginator["page"].object_list]) - tvars.update(paginator) - tvars["page_collection_and_sound_objects"] = zip(paginator["page"].object_list, page_sounds) + sort_key = request.GET.get("s") or settings.COLLECTION_SORT_DEFAULT + if sort_key not in settings.COLLECTION_SORT_OPTIONS: + sort_key = settings.COLLECTION_SORT_DEFAULT + search = request.GET.get("q", "").strip() + + sounds = Sound.objects.bulk_sounds_for_collection(collection.id) + if search: + sounds = sounds.filter(Q(original_filename__icontains=search) | Q(user__username__icontains=search)) + + if sort_key == "featured": + featured_ids = list(collection.featured_sound_ids or []) + if featured_ids: + ordering = Case( + *[When(id=sid, then=Value(i)) for i, sid in enumerate(featured_ids)], + default=Value(len(featured_ids)), + output_field=IntegerField(), + ) + sounds = sounds.order_by(ordering, "collectionsound__created") + else: + sounds = sounds.order_by("collectionsound__created") + else: + _, sort_field = settings.COLLECTION_SORT_OPTIONS[sort_key] + sounds = sounds.order_by(sort_field) + + pagination = paginate(request, sounds, settings.BOOKMARKS_PER_PAGE) + page_sounds = list(pagination["page"]) + + tvars = { + "collection": collection, + "is_owner": is_owner, + "is_maintainer": is_maintainer, + "maintainers": maintainers, + "sort_options": settings.COLLECTION_SORT_OPTIONS, + "page_sounds": page_sounds, + "featured_sound_ids_set": set(collection.featured_sound_ids or []), + "current_sort": sort_key, + "current_search": search, + } + tvars.update(pagination) return render(request, "collections/collection.html", tvars) @@ -189,90 +218,135 @@ def delete_collection(request, collection): return HttpResponseRedirect(collection.get_absolute_url()) +def serialize_collection_sounds(collection): + """Return lightweight collection sound metadata shipped as client-side JSON.""" + collection_sounds = list(Sound.objects.bulk_sounds_for_collection(collection_id=collection.id)) + cs_dates = dict(CollectionSound.objects.filter(collection=collection).values_list("sound_id", "created")) + featured_order = {sound_id: order for order, sound_id in enumerate(collection.featured_sound_ids)} + return [ + { + "id": sound.id, + "name": sound.original_filename, + "username": sound.username, + "duration": sound.duration, + "date_added": cs_dates.get(sound.id, sound.created), + "featured_order": featured_order.get(sound.id), + } + for sound in collection_sounds + ] + + +@login_required +@resolve_collection_from_url +def render_collection_cards(request, collection): + """Render with-actions sound cards for a caller-specified id list. + + Scoped to a collection: the caller must be its owner or a maintainer. + + Used by the collection-edit grid, where the client is the source of truth + for which sounds appear and in what order. Featured / removed button state + is restored client-side from the editor's store, so this endpoint does not + take a ``featured`` parameter. + + Query params: + - ``ids`` (required): comma-separated integer ids. Order is preserved; + unknown/non-public ids are silently dropped. Capped at + ``settings.MAX_SOUNDS_PER_COLLECTION``. + - ``page``, ``total`` (optional): when both are provided, an + ``hx-swap-oob`` paginator block is emitted alongside the cards so htmx + swaps both regions in a single response. + - ``q`` (optional): the active search query, used only to render an + empty-state message when the supplied id list is empty. + """ + is_owner = request.user == collection.user + is_maintainer = not is_owner and collection.maintainers.filter(id=request.user.id).exists() + if not is_owner and not is_maintainer: + return HttpResponse(status=403) + raw_ids = request.GET.get("ids", "") + ids = [int(x) for x in raw_ids.split(",") if x.isdigit()][: settings.MAX_SOUNDS_PER_COLLECTION] + + sounds_by_id = {s.id: s for s in Sound.objects.bulk_query_id_public(ids)} if ids else {} + sounds = [sounds_by_id[i] for i in ids if i in sounds_by_id] + + tvars = { + "sounds": sounds, + "max_sounds": settings.MAX_SOUNDS_PER_COLLECTION, + "current_search": request.GET.get("q", "").strip(), + } + + raw_page = request.GET.get("page") + raw_total = request.GET.get("total") + if raw_page and raw_total: + try: + total_pages = int(raw_total) + page_num = max(1, min(int(raw_page), max(1, total_pages))) + paginator_ns = SimpleNamespace(num_pages=total_pages) + page_dict = { + "has_previous": page_num > 1, + "has_next": page_num < total_pages, + "previous_page_number": page_num - 1, + "next_page_number": page_num + 1, + } + tvars.update( + build_paginator_template_context( + paginator_ns, page_dict, page_num, base_path=request.path, base_query=request.GET + ) + ) + tvars["has_paginator"] = True + except (ValueError, TypeError): + pass + + return render(request, "collections/_collection_edit_cards.html", tvars) + + @login_required @resolve_collection_from_url def edit_collection(request, collection): - collection_sounds = ",".join([str(s.id) for s in Sound.objects.filter(collections=collection)]) maintainers_query = User.objects.filter(collection_maintainer=collection.id) - collection_maintainers = ",".join([str(u.id) for u in maintainers_query]) - is_owner = False - is_maintainer = False - if request.user == collection.user: - is_owner = True - elif request.user in maintainers_query: - is_maintainer = True - else: + collection_maintainers = ",".join(str(u) for u in maintainers_query.values_list("id", flat=True)) + is_owner = request.user == collection.user + is_maintainer = not is_owner and maintainers_query.filter(id=request.user.id).exists() + if not is_owner and not is_maintainer: return HttpResponseRedirect(collection.get_absolute_url()) - current_sounds = list() - if request.method == "POST": - if is_owner: - form = CollectionEditForm( - request.POST, instance=collection, label_suffix="", is_owner=is_owner, is_maintainer=is_maintainer - ) - elif is_maintainer: - form = CollectionEditFormAsMaintainer( - request.POST, instance=collection, label_suffix="", is_owner=is_owner, is_maintainer=is_maintainer - ) - else: - return HttpResponseRedirect(collection.get_absolute_url()) + FormClass = CollectionEditForm if is_owner else CollectionEditFormAsMaintainer + if request.method == "POST": + form = FormClass( + request.POST, instance=collection, label_suffix="", is_owner=is_owner, is_maintainer=is_maintainer + ) if form.is_valid(): form.save(user_adding_sound=request.user) return HttpResponseRedirect(collection.get_absolute_url()) - else: - # NOTE: in this form's validation, errors are raised for each speific field, so when there is a submission attempt the error - # is displayed within it. However, fields containing errors are removed from the clean data but we are still interested in - # preserving its value. Therefore, we re-initialize a form according to the user's permissions preserving the field's validated data if so, - # and in case of error, we take its value from the POST request. The error messages are then attached to the form so that they're displayed. - errors_data = form.errors - new_form_data = dict() - for field in form.fields: - try: - new_form_data.setdefault(field, form.cleaned_data[field]) - except KeyError: - new_form_data.setdefault( - field, - request.POST.get( - field, - ), - ) - if is_owner: - form = CollectionEditForm( - initial=new_form_data, label_suffix="", is_owner=is_owner, is_maintainer=is_maintainer - ) - elif is_maintainer: - form = CollectionEditFormAsMaintainer( - initial=new_form_data, label_suffix="", is_owner=is_owner, is_maintainer=is_maintainer - ) - form._errors = errors_data else: - if is_owner: - form = CollectionEditForm( - instance=collection, - initial=dict(collection_sounds=collection_sounds, maintainers=collection_maintainers), - label_suffix="", - is_owner=is_owner, - is_maintainer=is_maintainer, - ) - elif is_maintainer: - form = CollectionEditFormAsMaintainer( - instance=collection, - initial=dict(collection_sounds=collection_sounds, maintainers=collection_maintainers), - label_suffix="", - is_owner=is_owner, - is_maintainer=is_maintainer, - ) - current_sounds = Sound.objects.bulk_sounds_for_collection(collection_id=collection.id) - current_maintainers = User.objects.filter(collection_maintainer=collection.id) - form.collection_sound_objects = current_sounds - form.collection_maintainers_objects = current_maintainers + featured_sounds_str = ",".join(str(sid) for sid in collection.featured_sound_ids) + form = FormClass( + instance=collection, + initial=dict(maintainers=collection_maintainers, featured_sounds=featured_sounds_str), + label_suffix="", + is_owner=is_owner, + is_maintainer=is_maintainer, + ) + + form.collection_maintainers_objects = maintainers_query + + sounds_data = serialize_collection_sounds(collection) + tvars = { "form": form, "collection": collection, "is_owner": is_owner, "is_maintainer": is_maintainer, - "max_sounds_per_collection": settings.MAX_SOUNDS_PER_COLLECTION, + "sort_options": settings.COLLECTION_SORT_OPTIONS, + "sounds_data": sounds_data, + "current_sort": request.GET.get("s") or settings.COLLECTION_SORT_DEFAULT, + "current_search": request.GET.get("q", "").strip(), + "render_cards_url": collection.get_url("collection-render-cards"), + "page_config": { + "sounds_per_page": settings.BOOKMARKS_PER_PAGE, + "max_sounds": settings.MAX_SOUNDS_PER_COLLECTION, + "max_featured": settings.MAX_FEATURED_SOUNDS_PER_COLLECTION, + }, } return render(request, "collections/edit_collection.html", tvars) @@ -304,6 +378,35 @@ def download_collection(request, collection): return download_sounds(licenses_url, licenses_content, sounds_list, collection.download_filename) +@resolve_collection_from_url +def collection_downloaders(request, collection): + if not request.GET.get("ajax"): + # If not loaded as a modal, redirect to collection page with parameter to open modal + return HttpResponseRedirect(collection.get_absolute_url() + "?downloaders=1") + + qs = CollectionDownload.objects.filter(collection=collection) + + num_items_per_page = settings.USERS_PER_DOWNLOADS_MODAL_PAGE + pagination = paginate(request, qs, num_items_per_page, object_count=collection.num_downloads) + page = pagination["page"] + + # Get all users+profiles for the user ids + userids = [d.user_id for d in list(page)] + users = User.objects.filter(pk__in=userids).select_related("profile") + user_map = {} + for u in users: + user_map[u.id] = u + + download_list = [] + for d in page: + download_list.append({"created": d.created, "user": user_map[d.user_id]}) + download_list = sorted(download_list, key=itemgetter("created"), reverse=True) + + tvars = {"collection": collection, "download_list": download_list} + tvars.update(pagination) + return render(request, "sounds/modal_downloaders.html", tvars) + + @resolve_collection_from_url def collection_licenses(request, collection): attribution = collection.get_attribution() diff --git a/general/templatetags/bw_templatetags.py b/general/templatetags/bw_templatetags.py index 6daee89ba..93c620383 100644 --- a/general/templatetags/bw_templatetags.py +++ b/general/templatetags/bw_templatetags.py @@ -19,9 +19,6 @@ # import math -import urllib.error -import urllib.parse -import urllib.request from django import template from django.conf import settings @@ -31,6 +28,7 @@ from follow.follow_utils import is_user_following_tag from general.templatetags.plausible import plausible_scripts from ratings.models import SoundRating +from utils.pagination import build_paginator_template_context register = template.Library() @@ -187,75 +185,17 @@ def bw_paginator(context, paginator, page, current_page, request, anchor="", non last page links in addition to those created by the object_list generic view. """ - if paginator is None: - # If paginator object is None, don't go ahead as below calculations will fail. This can happen if show_paginator - # is called and no paginator object is present in view - return {} - - adjacent_pages = 3 - total_wanted = adjacent_pages * 2 + 1 - min_page_num = max(current_page - adjacent_pages, 1) - max_page_num = min(current_page + adjacent_pages + 1, paginator.num_pages + 1) - - num_items = max_page_num - min_page_num - - if num_items < total_wanted and num_items < paginator.num_pages: - if min_page_num == 1: - # we're at the start, increment max_page_num - max_page_num += min(total_wanted - num_items, paginator.num_pages - num_items) - else: - # we're at the end, decrement - min_page_num -= min(total_wanted - num_items, paginator.num_pages - num_items) - - # although paginator objects are 0-based, we use 1-based paging - page_numbers = [n for n in range(min_page_num, max_page_num) if 0 < n <= paginator.num_pages] - params = urllib.parse.urlencode( - [(key.encode("utf-8"), value.encode("utf-8")) for (key, value) in request.GET.items() if key.lower() != "page"] + return build_paginator_template_context( + paginator, + page, + current_page, + base_path=request.path, + base_query=request.GET, + anchor=anchor, + non_grouped_number_of_results=non_grouped_number_of_results, + hx_target=context.get("hx_target", ""), ) - if params == "": - url = request.path + "?page=" - else: - url = request.path + "?" + params + "&page=" - - # The pagination could be over a queryset or over the result of a query to solr, so 'page' could be an object - # if it's the case a query to the DB or a dict if it's the case of a query to solr - if isinstance(page, dict): - url_prev_page = url + str(page["previous_page_number"]) - url_next_page = url + str(page["next_page_number"]) - url_first_page = url + "1" - else: - url_prev_page = None - if page.has_previous(): - url_prev_page = url + str(page.previous_page_number()) - url_next_page = None - if page.has_next(): - url_next_page = url + str(page.next_page_number()) - url_first_page = url + "1" - url_last_page = url + str(paginator.num_pages) - - if page_numbers: - last_is_next = paginator.num_pages - 1 == page_numbers[-1] - else: - last_is_next = False - - return { - "page": page, - "paginator": paginator, - "current_page": current_page, - "page_numbers": page_numbers, - "show_first": 1 not in page_numbers, - "show_last": paginator.num_pages not in page_numbers, - "last_is_next": last_is_next, - "url": url, - "url_prev_page": url_prev_page, - "url_next_page": url_next_page, - "url_first_page": url_first_page, - "url_last_page": url_last_page, - "anchor": anchor, - "non_grouped_number_of_results": non_grouped_number_of_results, - } - @register.inclusion_tag("molecules/maps_js_scripts.html", takes_context=True) def bw_maps_js_scripts(context): diff --git a/package.json b/package.json index a0f478903..5a8e55078 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "classlist-polyfill": "^1.2.0", "core-js": "^3.6.1", "element-closest": "^3.0.1", + "htmx.org": "^2.0.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", "normalize.css": "^8.0.1", diff --git a/sounds/templatetags/display_sound.py b/sounds/templatetags/display_sound.py index 5c3964cbe..45810f0c6 100644 --- a/sounds/templatetags/display_sound.py +++ b/sounds/templatetags/display_sound.py @@ -364,3 +364,16 @@ def display_sound_small_selectable(context, sound, selected=False): } ) return tvars + + +@register.inclusion_tag("sounds/display_sound_with_actions.html", takes_context=True) +def display_sound_small_with_actions(context, sound, is_featured=False): + """Display sound with featured and remove action toggles below it.""" + context = context.get("original_context", context) # This is to allow passing context in nested inclusion tags + tvars = display_sound_small_no_bookmark_no_ratings(context, sound) + tvars.update( + { + "is_featured": is_featured, + } + ) + return tvars diff --git a/sounds/templatetags/sounds_selector.py b/sounds/templatetags/sounds_selector.py index 95e6d8954..423e2e9c2 100644 --- a/sounds/templatetags/sounds_selector.py +++ b/sounds/templatetags/sounds_selector.py @@ -27,18 +27,28 @@ @register.inclusion_tag("molecules/object_selector.html", takes_context=True) -def sounds_selector(context, sounds, max_sounds=None, selected_sound_ids=[], show_select_all_buttons=False): +def sounds_selector( + context, + sounds, + max_sounds=None, + selected_sound_ids=[], + show_select_all_buttons=False, + featured_sound_ids=[], + show_actions=False, +): if sounds: if not isinstance(sounds[0], Sound): # sounds are passed as a list of sound ids, retrieve the Sound objects from DB sounds = Sound.objects.ordered_ids(sounds) for sound in sounds: sound.selected = sound.id in selected_sound_ids + sound.is_featured = sound.id in featured_sound_ids return { "objects": sounds, "type": "sounds", "show_select_all_buttons": show_select_all_buttons, + "show_actions": show_actions, "original_context": context, # This will be used so a nested inclusion tag can get the original context "max_elements": max_sounds, } @@ -63,3 +73,11 @@ def packs_selector_with_select_buttons(context, packs, selected_pack_ids=[]): "show_select_all_buttons": True, "original_context": context, # This will be used so a nested inclusion tag can get the original context } + + +@register.inclusion_tag("molecules/object_selector.html", takes_context=True) +def sounds_selector_with_actions(context, sounds, featured_sound_ids=[], max_sounds=None): + """Displays sounds with featured and remove toggle buttons below each sound.""" + return sounds_selector( + context, sounds, max_sounds=max_sounds, featured_sound_ids=featured_sound_ids, show_actions=True + ) diff --git a/templates/collections/_collection_edit_cards.html b/templates/collections/_collection_edit_cards.html new file mode 100644 index 000000000..3439e3b9f --- /dev/null +++ b/templates/collections/_collection_edit_cards.html @@ -0,0 +1,19 @@ +{% load display_sound %} +{% if sounds %} +
+ {% for sound in sounds %} +
+ {% display_sound_small_with_actions sound %} +
+ {% endfor %} +
+{% elif current_search %} +
+ No sounds found matching "{{ current_search }}". Clear search +
+{% endif %} +{% if has_paginator %} +
+ {% include "molecules/paginator.html" %} +
+{% endif %} diff --git a/templates/collections/collection.html b/templates/collections/collection.html index 5c14b2d60..265c70113 100644 --- a/templates/collections/collection.html +++ b/templates/collections/collection.html @@ -5,8 +5,12 @@ {% load display_user %} {% load util %} -{% block title%}Collections{%endblock title%} -{% block page-title%}{{collection.name}}{%endblock page-title%} +{% block title%}{{collection.name}} - Collection{%endblock title%} +{% block page-title-custom %} +
+

Collection: {{ collection.name }}

+
+{% endblock page-title-custom %} {% block page-content %}
@@ -20,7 +24,7 @@ Edit collection {% endif %} {% if collection.num_sounds > 0%} - Download Collection + Download collection {% endif %}
@@ -51,29 +55,88 @@
{{collection.description}}
{% endif %} -
-
Sounds in this collection
- {% if page.object_list %} -
- {% for collectionsound, sound in page_collection_and_sound_objects %} -
- {% display_sound_small sound %} +
+
+
+
+
+ + +
+
+
+ Sort by: + +
- {% endfor %}
- {% else %} - There aren't any sounds in this collection yet 😟 - {% endif %} - {% bw_paginator paginator page current_page request "collection" %} -
+
+
+ {% if page_sounds %} +
+ {% for sound in page_sounds %} + + {% endfor %} +
+ {% elif current_search %} +
+ No sounds found matching "{{ current_search }}". + Clear search +
+ {% endif %} +
+
+ {% with hx_target="#sounds-section" %} + {% bw_paginator paginator page current_page request %} + {% endwith %} +
+
+ {% if collection.public and collection.num_sounds > 0 %} {% endif %} - +
- + {% endblock page-content %} diff --git a/templates/collections/collection_stats_section.html b/templates/collections/collection_stats_section.html index 8d5d0374d..986931334 100644 --- a/templates/collections/collection_stats_section.html +++ b/templates/collections/collection_stats_section.html @@ -6,13 +6,13 @@ {% if collection.public %} {% bw_icon 'eye' 'text-light-grey' %} Public {% else %} {% bw_icon 'eye-blocked' 'text-light-grey' %} Private {% endif %}collection
  • - {% bw_icon 'wave' 'text-light-grey' %} {{ collection.num_sounds|formatnumber }} sound{{ collection.num_sounds|pluralize }} + {% bw_icon 'wave' 'text-light-grey' %} {{ collection.num_sounds|formatnumber }} sound{{ collection.num_sounds|pluralize }}
  • {% bw_icon 'clock' 'text-light-grey' %} {{ collection.get_total_collection_sounds_length|smart_duration_with_units }}
  • - {% bw_icon 'download' 'text-light-grey' %}{{ collection.num_downloads}} download{{ collection.num_downloads|pluralize }} + {% bw_icon 'download' 'text-light-grey' %}{{ collection.num_downloads|formatnumber }} download{{ collection.num_downloads|pluralize }}
  • diff --git a/templates/collections/display_collection.html b/templates/collections/display_collection.html index 1a899c27f..d8699d124 100644 --- a/templates/collections/display_collection.html +++ b/templates/collections/display_collection.html @@ -3,52 +3,54 @@ {% load display_sound %} {% load bw_templatetags %} -
    - {% if ft_sound %} -
    -
    - {% display_sound_small_no_info_no_buttons ft_sound %} +
    + - {% else %} -
    - {% endif %} -
    - +
    -
    - {% if collection.description %} - {% with collection.description|striptags|safe as preprocessed_description %} -
    = 10%}title="{{ preprocessed_description|truncatewords_html:200|force_escape }}"{% endif %}> - {{ preprocessed_description|truncatewords_html:10 }} -
    - {% endwith %} - {% else %} - This collection has no description. - {% endif %} -
    -
    -
    -
    -
    -
    - {% bw_user_avatar collection.user.profile.locations.avatar.S.url collection.user.username 32 %} -
    - {{ collection.user | truncate_string:15 }} -
    + -
    -
    - {{ collection.num_sounds|formatnumber }} + +
    + {{ collection.created|date:"F jS, Y" }} +
    +
    + {% if collection.description %} + {% with collection.description|striptags|safe as preprocessed_description %} +
    = 55%}title="{{ preprocessed_description|truncatewords_html:200|force_escape }}"{% endif %}> + {{ preprocessed_description|truncatechars_html:55 }} +
    + {% endwith %} + {% endif %} +
    diff --git a/templates/collections/edit_collection.html b/templates/collections/edit_collection.html index a7ba458a9..333c64ed1 100644 --- a/templates/collections/edit_collection.html +++ b/templates/collections/edit_collection.html @@ -1,8 +1,7 @@ {% extends "simple_page.html" %} {% load static %} -{% load util %} {% load bw_templatetags %} -{% load sounds_selector %} +{% load display_sound %} {% load users_selector %} {% block title %}Edit collection - {{ collection.name }}{% endblock %} @@ -17,7 +16,7 @@

    {{ collection.name }}

    -
    {% csrf_token %} + {% csrf_token %} {% if collection.is_default_collection %}
    {% bw_icon "notification" %}This is your default collection. You cannot change its name, description or public/private status. @@ -49,21 +48,46 @@

    {{ collection.name }}

    {% endif %} {% users_selector form.collection_maintainers_objects%}
    - - + +
    -
    - {{ form.collection_sounds }} - -
    {{form.collection_sounds.errors}}
    - {% sounds_selector form.collection_sound_objects max_sounds=max_sounds_per_collection%} -
    - - +
    + {# Initial sound IDs are derived from sounds-data JSON in JS #} + {{ form.added_sounds }} + {{ form.removed_sounds }} + {{ form.featured_sounds }} + {% if form.featured_sounds.errors %} +
    {{ form.featured_sounds.errors }}
    + {% endif %} +
    +
    + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    {{form.added_sounds.errors}}
    +
    +
    +
    +
    +
    +
    - +
    {% if is_owner or is_maintainer%} @@ -71,10 +95,14 @@

    {{ collection.name }}

    {% endif %} {% if is_owner%} - +
    + +
    {% endif %}
    +{{ sounds_data|json_script:"sounds-data" }} +{{ page_config|json_script:"page-config" }} {% endblock %} {% block extrabody %} diff --git a/templates/collections/modal_add_sound_to_collection.html b/templates/collections/modal_add_sound_to_collection.html index 059e11bbf..678011c42 100644 --- a/templates/collections/modal_add_sound_to_collection.html +++ b/templates/collections/modal_add_sound_to_collection.html @@ -46,7 +46,15 @@

    Add sound to collection

    {% endif %}
    {% csrf_token %} - {{ form }} +
    + {{ form.collection }} + +
    + {{ form.new_collection_name }} + {{ form.use_last_collection }}
    diff --git a/templates/molecules/object_selector.html b/templates/molecules/object_selector.html index af89d5493..60ca7e93a 100644 --- a/templates/molecules/object_selector.html +++ b/templates/molecules/object_selector.html @@ -6,7 +6,10 @@
    {% for object in objects %}
    - {% if type == "sounds" %}{% display_sound_small_selectable object object.selected %} + {% if type == "sounds" %} + {% if show_actions %}{% display_sound_small_with_actions object object.is_featured %} + {% else %}{% display_sound_small_selectable object object.selected %} + {% endif %} {% elif type == "packs" %}{% display_pack_small_selectable object object.selected %} {% elif type == "users" %}{% display_user_small_selectable object object.selected %} {% endif %} @@ -14,3 +17,4 @@ {% endfor %}
    + diff --git a/templates/molecules/paginator.html b/templates/molecules/paginator.html index 723022f23..cebc568f2 100644 --- a/templates/molecules/paginator.html +++ b/templates/molecules/paginator.html @@ -1,18 +1,21 @@ {% load bw_templatetags %} +{# Dual consumption: when hx_target is set (read-only collection page) links are htmx-boosted #} +{# and the href is followed. When hx_target is absent (edit page) JS intercepts clicks via #} +{# data-page and the href is never followed. Both paths rely on data-page being present. #} {% if page_numbers|length > 1 %} -
      +
        {% if page.has_previous %}
      • - + {% bw_icon 'arrow-left' 'white' %}
      • {% endif %} {% if show_first %} -
      • 1
      • +
      • 1
      • ...
      • {% endif %} @@ -22,18 +25,18 @@ {{ num }} {% else %} -
      • {{ num }}
      • +
      • {{ num }}
      • {% endif %} {% endfor %} {% if show_last %} {% if not last_is_next %}
      • ...
      • {% endif %} -
      • {{ paginator.num_pages }}
      • +
      • {{ paginator.num_pages }}
      • {% endif %} {% if page.has_next %}
      • - + {% bw_icon 'arrow' %}
      • diff --git a/templates/sounds/display_sound_with_actions.html b/templates/sounds/display_sound_with_actions.html new file mode 100644 index 000000000..25ca5e4fd --- /dev/null +++ b/templates/sounds/display_sound_with_actions.html @@ -0,0 +1,18 @@ +{% load bw_templatetags %} +
        +
        + {% include "sounds/display_sound.html" %} +
        +
        + + +
        +
        diff --git a/templates/sounds/modal_downloaders.html b/templates/sounds/modal_downloaders.html index ae7757b83..1c8b3459d 100644 --- a/templates/sounds/modal_downloaders.html +++ b/templates/sounds/modal_downloaders.html @@ -10,7 +10,7 @@ {% block body %}
        -

        Users that downloaded {% if sound %}{{ sound.original_filename }}{% elif pack %}{{ pack.name }}{% endif %} ({{paginator.count}})

        +

        Users that downloaded {% if sound %}{{ sound.original_filename }}{% elif pack %}{{ pack.name }}{% elif collection %}{{ collection.name }}{% endif %} ({{paginator.count}})

        {% if paginator.count > 0 %} @@ -32,7 +32,7 @@
        Downloaded on {{group.grouper}}
        {% else %}
        -
        Looks like no one has downloaded this {% if sound %}sound{% elif pack %}pack{% endif %} so far... 😟
        +
        Looks like no one has downloaded this {% if sound %}sound{% elif pack %}pack{% elif collection %}collection{% endif %} so far... 😟
        {% endif %}
        diff --git a/templates/sounds/player.html b/templates/sounds/player.html index 670421ba6..55b03772c 100644 --- a/templates/sounds/player.html +++ b/templates/sounds/player.html @@ -1,6 +1,8 @@