From 441f2a634b10a9b59dc224b158832ed9fc5185a0 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Fri, 2 Jan 2026 14:14:21 +1100 Subject: [PATCH] added some QoL like url sharing/count. Fixed CSS typo, added loading, empty state --- index.html | 16 +- main.js | 215 ++++++++++++++++++++--- materials.json | 8 +- snippets/{vinette.txt => vignette.txt} | 0 style.css | 134 +++++++++++++- thumbnails/{vinette.png => vignette.png} | Bin 6 files changed, 342 insertions(+), 31 deletions(-) rename snippets/{vinette.txt => vignette.txt} (100%) rename thumbnails/{vinette.png => vignette.png} (100%) diff --git a/index.html b/index.html index 983dbcd..0fd0c88 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,14 @@ - Unreal Engine Material Library + + + + + + + + UE Material Snippets @@ -25,8 +32,14 @@
+ +
+
+

Loading materials...

+
+
+ diff --git a/main.js b/main.js index 2e7d3f1..74b83e8 100644 --- a/main.js +++ b/main.js @@ -1,9 +1,114 @@ +/** Default placeholder image for missing thumbnails (base64 encoded) */ +const FALLBACK_IMAGE = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iIzJBMkEyQSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjNjY2IiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNCI+Tm8gUHJldmlldzwvdGV4dD48L3N2Zz4='; + +/** Animation delay for card transitions in milliseconds */ +const FILTER_TRANSITION_DELAY = 300; + +/** Number of columns for masonry grid */ +const GRID_COLUMN_COUNT = 4; + +/** Duration for copy feedback notification in milliseconds */ +const COPY_NOTIFICATION_DURATION = 1000; + +/** URL separator for categories (pipe doesn't get encoded) */ +const URL_CATEGORY_SEPARATOR = '|'; + document.addEventListener('DOMContentLoaded', async () => { const grid = document.querySelector('[data-material-grid]'); const searchInput = document.querySelector('[data-search]'); const categoriesContainer = document.querySelector('[data-categories]'); + const loadingSpinner = document.querySelector('[data-loading]'); + const clearFiltersButton = document.querySelector('[data-clear-filters]'); + const resultCountEl = document.querySelector('[data-result-count]'); let materialLibrary = []; + + /** + * Shows or hides the loading spinner + * @param {boolean} show - Whether to show the spinner + */ + function setLoading(show) { + loadingSpinner.style.display = show ? 'flex' : 'none'; + grid.style.display = show ? 'none' : 'block'; + } + + /** + * Updates the result count display + * @param {number} count - Number of materials shown + * @param {number} total - Total number of materials + */ + function updateResultCount(count, total) { + if (count === total) { + resultCountEl.innerHTML = `Showing ${count} snippets`; + } else { + resultCountEl.innerHTML = `Showing ${count} of ${total} snippets`; + } + } + + /** + * Reads URL parameters and returns filter state + * @returns {{search: string, categories: string[]}} Filter state from URL + */ + function getFiltersFromURL() { + const params = new URLSearchParams(window.location.search); + const search = params.get('q') || ''; + const categories = params.get('categories') ? params.get('categories').split(URL_CATEGORY_SEPARATOR) : []; + return { search, categories }; + } + + /** + * Updates the URL with current filter state + * @param {string} search - Current search term + * @param {string[]} categories - Currently selected categories + */ + function updateURL(search, categories) { + const params = new URLSearchParams(); + if (search) params.set('q', search); + if (categories.length > 0) params.set('categories', categories.join(URL_CATEGORY_SEPARATOR)); + + const newURL = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + + window.history.replaceState({}, '', newURL); + } + + /** + * Updates the visibility of the clear filters button + */ + function updateClearFiltersVisibility() { + const hasActiveFilters = document.querySelectorAll('.category-button.active').length > 0; + const hasSearchText = searchInput.value.trim().length > 0; + clearFiltersButton.style.display = (hasActiveFilters || hasSearchText) ? 'inline-block' : 'none'; + } + + /** + * Clears all active filters and search text + */ + function clearAllFilters() { + document.querySelectorAll('.category-button.active').forEach(btn => btn.classList.remove('active')); + searchInput.value = ''; + updateClearFiltersVisibility(); + filterMaterials(); + } + + /** + * Shows a copy notification at the given position + * @param {DOMRect} rect - Bounding rectangle for positioning + */ + function showCopyNotification(rect) { + const notification = document.createElement('div'); + notification.className = 'copy-notification'; + notification.textContent = 'Copied!'; + notification.style.left = `${rect.left + rect.width / 2}px`; + notification.style.top = `${rect.top}px`; + + document.body.appendChild(notification); + setTimeout(() => notification.remove(), COPY_NOTIFICATION_DURATION); + } + + setLoading(true); + try { const response = await fetch('materials.json'); const data = await response.json(); @@ -21,25 +126,47 @@ document.addEventListener('DOMContentLoaded', async () => { categoryButtons.forEach(button => { button.addEventListener('click', () => { button.classList.toggle('active'); + updateClearFiltersVisibility(); filterMaterials(); }); }); + + clearFiltersButton.addEventListener('click', clearAllFilters); + + const urlFilters = getFiltersFromURL(); + if (urlFilters.search) { + searchInput.value = urlFilters.search; + } + if (urlFilters.categories.length > 0) { + urlFilters.categories.forEach(cat => { + const btn = categoriesContainer.querySelector(`[data-category="${cat}"]`); + if (btn) btn.classList.add('active'); + }); + } + updateClearFiltersVisibility(); + + setLoading(false); } catch (error) { console.error('Error loading materials:', error); + setLoading(false); grid.innerHTML = '

Error loading materials library.

'; return; } + /** + * Renders the material cards to the grid + * @param {Array} materials - Array of material objects to render + */ function renderMaterials(materials) { grid.innerHTML = materials.map(material => ` -
- ${material.name} +
+ Preview of ${material.name} material effect

${material.name}

${material.author}

${material.description}

- ${material.categories.map(cat => `${cat}`).join(' ')} + ${material.categories.map(cat => `${cat}`).join(' ')}
@@ -47,21 +174,35 @@ document.addEventListener('DOMContentLoaded', async () => { const tags = document.querySelectorAll('.tag'); tags.forEach(tag => { - tag.addEventListener('click', (e) => { + /** + * Handles tag click to activate category filter + * @param {Event} e - Click or keydown event + */ + const handleTagActivation = (e) => { + if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); e.stopPropagation(); const category = tag.dataset.tag; const categoryButton = [...document.querySelectorAll('.category-button')] .find(button => button.dataset.category === category); if (categoryButton) { categoryButton.classList.add('active'); + updateClearFiltersVisibility(); filterMaterials(); } - }); + }; + tag.addEventListener('click', handleTagActivation); + tag.addEventListener('keydown', handleTagActivation); }); reSortMasonry(); } + /** + * Loads a snippet file from the given path + * @param {string} snippetPath - Path to the snippet file + * @returns {Promise} The snippet content + */ async function loadSnippet(snippetPath) { try { const response = await fetch(snippetPath); @@ -72,11 +213,16 @@ document.addEventListener('DOMContentLoaded', async () => { } } + /** + * Filters materials based on search term and selected categories + */ async function filterMaterials() { const searchTerm = searchInput.value.toLowerCase(); const selectedCategories = [...document.querySelectorAll('.category-button.active')] .map(button => button.dataset.category); + updateURL(searchInput.value, selectedCategories); + const filtered = materialLibrary.filter(material => { const matchesSearch = material.name.toLowerCase().includes(searchTerm) || material.description.toLowerCase().includes(searchTerm) || @@ -85,6 +231,8 @@ document.addEventListener('DOMContentLoaded', async () => { return matchesSearch && matchesCategory; }); + updateResultCount(filtered.length, materialLibrary.length); + const allCards = document.querySelectorAll('.material-card'); allCards.forEach(card => { card.classList.add('hide'); @@ -92,9 +240,12 @@ document.addEventListener('DOMContentLoaded', async () => { setTimeout(() => { renderMaterials(filtered); - }, 300); + }, FILTER_TRANSITION_DELAY); } + /** + * Re-sorts the masonry grid layout after content changes + */ function reSortMasonry() { const masonryItems = document.querySelectorAll('.material-card'); let transitionCount = masonryItems.length; @@ -105,31 +256,39 @@ document.addEventListener('DOMContentLoaded', async () => { transitionCount--; if (transitionCount === 0) { grid.style.columnCount = 0; - grid.style.columnCount = 4; + grid.style.columnCount = GRID_COLUMN_COUNT; } }, { once: true }); }); } + /** + * Handles copying a material snippet to clipboard + * @param {HTMLElement} card - The card element that was activated + */ + async function handleCardClick(card) { + const materialName = card.dataset.material; + const material = materialLibrary.find(m => + m.name.toLowerCase().replace(/\s+/g, '-') === materialName + ); + + const snippet = await loadSnippet(material.snippet); + navigator.clipboard.writeText(snippet); + showCopyNotification(card.getBoundingClientRect()); + } + grid.addEventListener('click', async (e) => { const card = e.target.closest('.material-card'); if (card && !e.target.classList.contains('tag')) { - const materialName = card.dataset.material; - const material = materialLibrary.find(m => - m.name.toLowerCase().replace(/\s+/g, '-') === materialName - ); - - const snippet = await loadSnippet(material.snippet); - navigator.clipboard.writeText(snippet); - - const notification = document.createElement('div'); - notification.className = 'copy-notification'; - notification.textContent = 'Copied!'; - notification.style.left = `${e.clientX}px`; - notification.style.top = `${e.clientY - 30}px`; - - document.body.appendChild(notification); - setTimeout(() => notification.remove(), 1000); + await handleCardClick(card); + } + }); + + grid.addEventListener('keydown', async (e) => { + const card = e.target.closest('.material-card'); + if (card && (e.key === 'Enter' || e.key === ' ') && !e.target.classList.contains('tag')) { + e.preventDefault(); + await handleCardClick(card); } }); @@ -137,7 +296,15 @@ document.addEventListener('DOMContentLoaded', async () => { e.preventDefault(); }); - searchInput.addEventListener('input', filterMaterials); + searchInput.addEventListener('input', () => { + updateClearFiltersVisibility(); + filterMaterials(); + }); + updateResultCount(materialLibrary.length, materialLibrary.length); renderMaterials(materialLibrary); + + if (getFiltersFromURL().search || getFiltersFromURL().categories.length > 0) { + filterMaterials(); + } }); diff --git a/materials.json b/materials.json index fcf673d..ee40fa1 100644 --- a/materials.json +++ b/materials.json @@ -2,14 +2,14 @@ "materials": [ { "author": "Litruv", - "name": "Vinette", + "name": "Vignette", "categories": [ "photo", "fx", "vintage" ], - "preview": "./thumbnails/vinette.png", - "snippet": "snippets/vinette.txt", + "preview": "./thumbnails/vignette.png", + "snippet": "snippets/vignette.txt", "description": "Adds a subtle darkened border around the edges of the image for a vintage effect." }, { @@ -17,7 +17,7 @@ "name": "Screen Ratio Corrected UV's", "categories": [ "screen", - "utillity", + "utility", "uv" ], "preview": "./thumbnails/screen_ratio_corrected_uvs.png", diff --git a/snippets/vinette.txt b/snippets/vignette.txt similarity index 100% rename from snippets/vinette.txt rename to snippets/vignette.txt diff --git a/style.css b/style.css index 5591c02..7abc78c 100644 --- a/style.css +++ b/style.css @@ -38,6 +38,7 @@ body { .container { max-width: 1200px; + width: 100%; margin: 0 auto; padding: 20px; flex: 1; @@ -106,7 +107,7 @@ h1 { .category-button:hover { border-color: var(--primary-color); - color: var (--primary-color); + color: var(--primary-color); } .category-button.active:hover { @@ -117,7 +118,18 @@ h1 { column-count: 4; column-gap: 20px; max-width: 1200px; + width: 100%; margin: 0 auto; + min-height: 200px; +} + +[data-material-grid]:empty::after { + content: 'No snippets found'; + display: block; + text-align: center; + color: var(--text-secondary); + padding: 60px 20px; + font-size: 16px; } .material-card { @@ -129,10 +141,16 @@ h1 { border-radius: var(--border-radius); overflow: hidden; box-shadow: var(--box-shadow); - transition: transform 0.2s, opacity var(--transition-duration); + transition: transform 0.2s, opacity var(--transition-duration), border-color 0.2s, box-shadow 0.2s; cursor: pointer; opacity: 1; animation: fadeIn var(--transition-duration) ease-in; + position: relative; +} + +.material-card:focus { + outline: 2px solid var(--primary-color); + outline-offset: 2px; } .material-card.hide { @@ -205,6 +223,11 @@ h1 { color: var(--text-primary); } +.tag:focus { + outline: 2px solid var(--primary-color); + outline-offset: 1px; +} + .copy-notification { position: fixed; background: var(--primary-color); @@ -321,3 +344,110 @@ footer { .add-snippet-button:hover { background-color: #218838; } + +/* Loading Spinner */ +.loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + gap: 16px; +} + +.loading-spinner p { + color: var(--text-secondary); + font-size: var(--font-size); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Clear Filters Button */ +.clear-filters-button { + background: transparent; + border: 1px solid var(--primary-color); + color: var(--primary-color); + padding: var(--padding); + border-radius: var(--border-radius); + cursor: pointer; + transition: all 0.2s; + font-size: var(--font-size); +} + +.clear-filters-button:hover { + background: var(--primary-color); + color: var(--text-primary); +} + +/* Result Count */ +.result-count { + text-align: right; + color: var(--text-secondary); + font-size: var(--font-size); + margin-bottom: 20px; +} + +.result-count span { + color: var(--primary-color); + font-weight: 500; +} + +/* Mobile Responsiveness */ +@media (max-width: 1200px) { + [data-material-grid] { + column-count: 3; + } +} + +@media (max-width: 900px) { + [data-material-grid] { + column-count: 2; + } + + .header-content { + flex-direction: column; + gap: 15px; + text-align: center; + } +} + +@media (max-width: 600px) { + [data-material-grid] { + column-count: 1; + } + + .container { + padding: 15px; + } + + h1 { + font-size: 22px; + } + + .categories-filter { + justify-content: flex-start; + } + + .category-button { + padding: 8px 12px; + font-size: 12px; + } + + .add-snippet-button { + width: 100%; + justify-content: center; + } +} diff --git a/thumbnails/vinette.png b/thumbnails/vignette.png similarity index 100% rename from thumbnails/vinette.png rename to thumbnails/vignette.png