added some QoL like url sharing/count. Fixed CSS typo, added loading, empty state

This commit is contained in:
2026-01-02 14:14:21 +11:00
parent 58cde79e97
commit 441f2a634b
6 changed files with 342 additions and 31 deletions

View File

@@ -3,7 +3,14 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unreal Engine Material Library</title> <meta name="description" content="A curated library of copy-paste ready Unreal Engine material snippets. Find vignettes, scanlines, UV effects, distortions and more for your UE projects.">
<meta name="keywords" content="Unreal Engine, UE5, UE4, materials, shaders, HLSL, snippets, game development, visual effects">
<meta name="author" content="Max Litruv Boonzaayer">
<meta property="og:title" content="UE Material Snippets">
<meta property="og:description" content="A curated library of copy-paste ready Unreal Engine material snippets.">
<meta property="og:type" content="website">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎨</text></svg>">
<title>UE Material Snippets</title>
<link rel="stylesheet" href="./style.css"> <link rel="stylesheet" href="./style.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
</head> </head>
@@ -25,8 +32,14 @@
<input type="text" data-search placeholder="Search materials..."> <input type="text" data-search placeholder="Search materials...">
</div> </div>
<div class="categories-filter" data-categories></div> <div class="categories-filter" data-categories></div>
<button class="clear-filters-button" data-clear-filters style="display: none;">Clear Filters</button>
</div> </div>
</header> </header>
<div class="loading-spinner" data-loading>
<div class="spinner"></div>
<p>Loading materials...</p>
</div>
<div class="result-count" data-result-count></div>
<main data-material-grid></main> <main data-material-grid></main>
</div> </div>
<footer> <footer>
@@ -38,6 +51,7 @@
</div> </div>
</footer> </footer>
</div> </div>
<script src="./main.js"></script> <script src="./main.js"></script>
</body> </body>
</html> </html>

203
main.js
View File

@@ -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 () => { document.addEventListener('DOMContentLoaded', async () => {
const grid = document.querySelector('[data-material-grid]'); const grid = document.querySelector('[data-material-grid]');
const searchInput = document.querySelector('[data-search]'); const searchInput = document.querySelector('[data-search]');
const categoriesContainer = document.querySelector('[data-categories]'); 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 = []; 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 <span>${count}</span> snippets`;
} else {
resultCountEl.innerHTML = `Showing <span>${count}</span> of <span>${total}</span> 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 { try {
const response = await fetch('materials.json'); const response = await fetch('materials.json');
const data = await response.json(); const data = await response.json();
@@ -21,25 +126,47 @@ document.addEventListener('DOMContentLoaded', async () => {
categoryButtons.forEach(button => { categoryButtons.forEach(button => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
button.classList.toggle('active'); button.classList.toggle('active');
updateClearFiltersVisibility();
filterMaterials(); 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) { } catch (error) {
console.error('Error loading materials:', error); console.error('Error loading materials:', error);
setLoading(false);
grid.innerHTML = '<p class="error-message">Error loading materials library.</p>'; grid.innerHTML = '<p class="error-message">Error loading materials library.</p>';
return; return;
} }
/**
* Renders the material cards to the grid
* @param {Array} materials - Array of material objects to render
*/
function renderMaterials(materials) { function renderMaterials(materials) {
grid.innerHTML = materials.map(material => ` grid.innerHTML = materials.map(material => `
<div class="material-card show" data-material="${material.name.toLowerCase().replace(/\s+/g, '-')}"> <div class="material-card show" data-material="${material.name.toLowerCase().replace(/\s+/g, '-')}" tabindex="0" role="button" aria-label="Copy ${material.name} snippet">
<img class="material-preview" src="${material.preview}" alt="${material.name}"> <img class="material-preview" src="${material.preview}" alt="Preview of ${material.name} material effect" onerror="this.src='${FALLBACK_IMAGE}'">
<div class="material-info"> <div class="material-info">
<h3>${material.name}</h3> <h3>${material.name}</h3>
<p class="author">${material.author}</p> <p class="author">${material.author}</p>
<p>${material.description}</p> <p>${material.description}</p>
<div class="material-tags"> <div class="material-tags">
${material.categories.map(cat => `<span class="tag" data-tag="${cat}">${cat}</span>`).join(' ')} ${material.categories.map(cat => `<span class="tag" data-tag="${cat}" tabindex="0" role="button" aria-label="Filter by ${cat}">${cat}</span>`).join(' ')}
</div> </div>
</div> </div>
</div> </div>
@@ -47,21 +174,35 @@ document.addEventListener('DOMContentLoaded', async () => {
const tags = document.querySelectorAll('.tag'); const tags = document.querySelectorAll('.tag');
tags.forEach(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(); e.stopPropagation();
const category = tag.dataset.tag; const category = tag.dataset.tag;
const categoryButton = [...document.querySelectorAll('.category-button')] const categoryButton = [...document.querySelectorAll('.category-button')]
.find(button => button.dataset.category === category); .find(button => button.dataset.category === category);
if (categoryButton) { if (categoryButton) {
categoryButton.classList.add('active'); categoryButton.classList.add('active');
updateClearFiltersVisibility();
filterMaterials(); filterMaterials();
} }
}); };
tag.addEventListener('click', handleTagActivation);
tag.addEventListener('keydown', handleTagActivation);
}); });
reSortMasonry(); reSortMasonry();
} }
/**
* Loads a snippet file from the given path
* @param {string} snippetPath - Path to the snippet file
* @returns {Promise<string>} The snippet content
*/
async function loadSnippet(snippetPath) { async function loadSnippet(snippetPath) {
try { try {
const response = await fetch(snippetPath); 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() { async function filterMaterials() {
const searchTerm = searchInput.value.toLowerCase(); const searchTerm = searchInput.value.toLowerCase();
const selectedCategories = [...document.querySelectorAll('.category-button.active')] const selectedCategories = [...document.querySelectorAll('.category-button.active')]
.map(button => button.dataset.category); .map(button => button.dataset.category);
updateURL(searchInput.value, selectedCategories);
const filtered = materialLibrary.filter(material => { const filtered = materialLibrary.filter(material => {
const matchesSearch = material.name.toLowerCase().includes(searchTerm) || const matchesSearch = material.name.toLowerCase().includes(searchTerm) ||
material.description.toLowerCase().includes(searchTerm) || material.description.toLowerCase().includes(searchTerm) ||
@@ -85,6 +231,8 @@ document.addEventListener('DOMContentLoaded', async () => {
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}); });
updateResultCount(filtered.length, materialLibrary.length);
const allCards = document.querySelectorAll('.material-card'); const allCards = document.querySelectorAll('.material-card');
allCards.forEach(card => { allCards.forEach(card => {
card.classList.add('hide'); card.classList.add('hide');
@@ -92,9 +240,12 @@ document.addEventListener('DOMContentLoaded', async () => {
setTimeout(() => { setTimeout(() => {
renderMaterials(filtered); renderMaterials(filtered);
}, 300); }, FILTER_TRANSITION_DELAY);
} }
/**
* Re-sorts the masonry grid layout after content changes
*/
function reSortMasonry() { function reSortMasonry() {
const masonryItems = document.querySelectorAll('.material-card'); const masonryItems = document.querySelectorAll('.material-card');
let transitionCount = masonryItems.length; let transitionCount = masonryItems.length;
@@ -105,15 +256,17 @@ document.addEventListener('DOMContentLoaded', async () => {
transitionCount--; transitionCount--;
if (transitionCount === 0) { if (transitionCount === 0) {
grid.style.columnCount = 0; grid.style.columnCount = 0;
grid.style.columnCount = 4; grid.style.columnCount = GRID_COLUMN_COUNT;
} }
}, { once: true }); }, { once: true });
}); });
} }
grid.addEventListener('click', async (e) => { /**
const card = e.target.closest('.material-card'); * Handles copying a material snippet to clipboard
if (card && !e.target.classList.contains('tag')) { * @param {HTMLElement} card - The card element that was activated
*/
async function handleCardClick(card) {
const materialName = card.dataset.material; const materialName = card.dataset.material;
const material = materialLibrary.find(m => const material = materialLibrary.find(m =>
m.name.toLowerCase().replace(/\s+/g, '-') === materialName m.name.toLowerCase().replace(/\s+/g, '-') === materialName
@@ -121,15 +274,21 @@ document.addEventListener('DOMContentLoaded', async () => {
const snippet = await loadSnippet(material.snippet); const snippet = await loadSnippet(material.snippet);
navigator.clipboard.writeText(snippet); navigator.clipboard.writeText(snippet);
showCopyNotification(card.getBoundingClientRect());
}
const notification = document.createElement('div'); grid.addEventListener('click', async (e) => {
notification.className = 'copy-notification'; const card = e.target.closest('.material-card');
notification.textContent = 'Copied!'; if (card && !e.target.classList.contains('tag')) {
notification.style.left = `${e.clientX}px`; await handleCardClick(card);
notification.style.top = `${e.clientY - 30}px`; }
});
document.body.appendChild(notification); grid.addEventListener('keydown', async (e) => {
setTimeout(() => notification.remove(), 1000); 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(); e.preventDefault();
}); });
searchInput.addEventListener('input', filterMaterials); searchInput.addEventListener('input', () => {
updateClearFiltersVisibility();
filterMaterials();
});
updateResultCount(materialLibrary.length, materialLibrary.length);
renderMaterials(materialLibrary); renderMaterials(materialLibrary);
if (getFiltersFromURL().search || getFiltersFromURL().categories.length > 0) {
filterMaterials();
}
}); });

View File

@@ -2,14 +2,14 @@
"materials": [ "materials": [
{ {
"author": "Litruv", "author": "Litruv",
"name": "Vinette", "name": "Vignette",
"categories": [ "categories": [
"photo", "photo",
"fx", "fx",
"vintage" "vintage"
], ],
"preview": "./thumbnails/vinette.png", "preview": "./thumbnails/vignette.png",
"snippet": "snippets/vinette.txt", "snippet": "snippets/vignette.txt",
"description": "Adds a subtle darkened border around the edges of the image for a vintage effect." "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", "name": "Screen Ratio Corrected UV's",
"categories": [ "categories": [
"screen", "screen",
"utillity", "utility",
"uv" "uv"
], ],
"preview": "./thumbnails/screen_ratio_corrected_uvs.png", "preview": "./thumbnails/screen_ratio_corrected_uvs.png",

134
style.css
View File

@@ -38,6 +38,7 @@ body {
.container { .container {
max-width: 1200px; max-width: 1200px;
width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
flex: 1; flex: 1;
@@ -106,7 +107,7 @@ h1 {
.category-button:hover { .category-button:hover {
border-color: var(--primary-color); border-color: var(--primary-color);
color: var (--primary-color); color: var(--primary-color);
} }
.category-button.active:hover { .category-button.active:hover {
@@ -117,7 +118,18 @@ h1 {
column-count: 4; column-count: 4;
column-gap: 20px; column-gap: 20px;
max-width: 1200px; max-width: 1200px;
width: 100%;
margin: 0 auto; 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 { .material-card {
@@ -129,10 +141,16 @@ h1 {
border-radius: var(--border-radius); border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
box-shadow: var(--box-shadow); 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; cursor: pointer;
opacity: 1; opacity: 1;
animation: fadeIn var(--transition-duration) ease-in; animation: fadeIn var(--transition-duration) ease-in;
position: relative;
}
.material-card:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
} }
.material-card.hide { .material-card.hide {
@@ -205,6 +223,11 @@ h1 {
color: var(--text-primary); color: var(--text-primary);
} }
.tag:focus {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
.copy-notification { .copy-notification {
position: fixed; position: fixed;
background: var(--primary-color); background: var(--primary-color);
@@ -321,3 +344,110 @@ footer {
.add-snippet-button:hover { .add-snippet-button:hover {
background-color: #218838; 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;
}
}

View File

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB