Improve gallery sharing and admin tagging

This commit is contained in:
chris 2025-12-26 12:50:25 -05:00
parent 3a679eb03c
commit a3b8593133
7 changed files with 225 additions and 50 deletions

View File

@ -5,3 +5,16 @@
.low-tag-card {
box-shadow: 0 0 0 2px #ffdd57 inset;
}
#bulkPanel {
position: sticky;
top: 16px;
z-index: 10;
box-shadow: 0 12px 24px rgba(17, 17, 17, 0.08);
}
@media (max-width: 768px) {
#bulkPanel {
top: 8px;
}
}

View File

@ -21,6 +21,7 @@ document.addEventListener('DOMContentLoaded', () => {
const captionInput = document.getElementById('captionInput');
const captionToTagsButton = document.getElementById('captionToTags');
const manageGallery = document.getElementById('manage-gallery');
const manageSearchInput = document.getElementById('manageSearchInput');
const editModal = document.getElementById('editModal');
const editPhotoId = document.getElementById('editPhotoId');
const editCaption = document.getElementById('editCaption');
@ -55,7 +56,17 @@ document.addEventListener('DOMContentLoaded', () => {
const responseDiv = document.getElementById('response');
const backendUrl = (() => {
const { protocol } = window.location;
const { protocol, hostname } = window.location;
const productionHosts = new Set([
'beachpartyballoons.com',
'www.beachpartyballoons.com',
'preview.beachpartyballoons.com',
'photobackend.beachpartyballoons.com'
]);
const isProduction = productionHosts.has(hostname);
if (!isProduction) {
return 'http://localhost:5001';
}
const backendHostname = 'photobackend.beachpartyballoons.com';
return `${protocol}//${backendHostname}`; // No explicit port needed as it's on 443
})();
@ -69,7 +80,8 @@ document.addEventListener('DOMContentLoaded', () => {
presets: [],
labels: {},
maxTags: DEFAULT_MAX_TAGS,
existing: []
existing: [],
tagCounts: {}
};
let adminPassword = '';
const storedPassword = localStorage.getItem('bpb-admin-password');
@ -80,6 +92,11 @@ document.addEventListener('DOMContentLoaded', () => {
const mapped = tagMeta.aliases?.[slug] || slug;
return mapped;
};
const resolveSearchTag = (value) => {
const slug = slugifyTag(value);
if (!slug) return '';
return tagMeta.aliases?.[slug] || slug;
};
const displayTag = (slug) => {
if (!slug) return '';
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
@ -165,6 +182,7 @@ document.addEventListener('DOMContentLoaded', () => {
labels: {},
maxTags: DEFAULT_MAX_TAGS,
existing: [],
tagCounts: {},
...data
};
updateTagSuggestions();
@ -209,11 +227,27 @@ document.addEventListener('DOMContentLoaded', () => {
function renderManageGallery() {
manageGallery.innerHTML = '';
if (!photos.length) {
manageGallery.innerHTML = '<div class="column"><p class="has-text-grey">No photos yet. Upload a photo to get started.</p></div>';
const query = String(manageSearchInput?.value || '').trim().toLowerCase();
const normalizedQuery = resolveSearchTag(query);
const filtered = query
? photos.filter(photo => {
const caption = String(photo.caption || '').toLowerCase();
const tags = Array.isArray(photo.tags) ? photo.tags : [];
const tagText = tags.map(displayTag).join(' ').toLowerCase();
return caption.includes(query)
|| tags.some(tag => String(tag || '').toLowerCase().includes(query))
|| (normalizedQuery && tags.some(tag => String(tag || '').toLowerCase() === normalizedQuery))
|| tagText.includes(query);
})
: photos;
if (!filtered.length) {
const message = query
? 'No photos match your search.'
: 'No photos yet. Upload a photo to get started.';
manageGallery.innerHTML = `<div class="column"><p class="has-text-grey">${message}</p></div>`;
return;
}
photos.forEach(photo => {
filtered.forEach(photo => {
const tagCount = Array.isArray(photo.tags) ? photo.tags.length : 0;
const tagStatusClass = tagCount <= 2 ? 'is-warning' : 'is-light';
const lowTagClass = tagCount <= 2 ? 'low-tag-card' : '';
@ -466,6 +500,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
if (manageSearchInput) {
manageSearchInput.addEventListener('input', () => renderManageGallery());
}
selectAllPhotosBtn.addEventListener('click', (e) => {
e.preventDefault();
toggleSelectAll();
@ -582,13 +620,28 @@ document.addEventListener('DOMContentLoaded', () => {
xhr.send(formData);
});
const getTagCount = (slug) => Number(tagMeta.tagCounts?.[slug] || 0);
const sortTagsByCount = (a, b) => {
const countDiff = getTagCount(b.slug) - getTagCount(a.slug);
if (countDiff !== 0) return countDiff;
return (a.label || '').localeCompare(b.label || '');
};
const sortSlugsByCount = (a, b) => {
const countDiff = getTagCount(b) - getTagCount(a);
if (countDiff !== 0) return countDiff;
return displayTag(a).localeCompare(displayTag(b));
};
function updateTagSuggestions() {
if (!tagSuggestions) return;
tagSuggestions.innerHTML = '';
const mainSorted = [...(tagMeta.main || [])].sort(sortTagsByCount);
const otherSorted = [...(tagMeta.other || [])].sort(sortTagsByCount);
const existingSorted = [...(tagMeta.existing || [])].sort(sortSlugsByCount);
const suggestions = [
...(tagMeta.main || []),
...(tagMeta.other || []),
...((tagMeta.existing || []).map(slug => ({ slug, label: displayTag(slug) })))
...mainSorted,
...otherSorted,
...existingSorted.map(slug => ({ slug, label: displayTag(slug) }))
];
const seen = new Set();
suggestions.forEach(tag => {
@ -604,8 +657,12 @@ document.addEventListener('DOMContentLoaded', () => {
function updateQuickTags() {
if (!quickTagButtons) return;
const presetButtons = (tagMeta.presets || []).map(preset => `<button type="button" class="button is-light is-rounded" data-preset="${preset.name}">${preset.name} preset</button>`);
const mainButtons = (tagMeta.main || []).map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
const otherButtons = (tagMeta.other || []).map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
const mainButtons = [...(tagMeta.main || [])]
.sort(sortTagsByCount)
.map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
const otherButtons = [...(tagMeta.other || [])]
.sort(sortTagsByCount)
.map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
}

View File

@ -73,8 +73,8 @@
<div class="field">
<label class="label">Photo</label>
<div class="control">
<input class="input has-background-light has-text-black" type="file" id="photoInput" accept="image/*" multiple required>
<p class="help is-size-7 has-text-grey">Select one or many images; each will be converted to WebP automatically.</p>
<input class="input has-background-light has-text-black" type="file" id="photoInput" accept="image/*,.heic,.heif" multiple required>
<p class="help is-size-7 has-text-grey">Select one or many images (including HEIC/HEIF); each will be converted to WebP automatically.</p>
</div>
</div>
<div class="field">
@ -111,6 +111,12 @@
</div>
<span class="tag is-info is-light"><i class="fas fa-images mr-2"></i>Gallery</span>
</div>
<div class="field mb-4">
<label class="label is-size-7 has-text-dark">Search by caption or tag</label>
<div class="control">
<input class="input is-small has-background-light has-text-dark" type="text" id="manageSearchInput" placeholder="e.g. classic, wedding, arch">
</div>
</div>
<div class="box has-background-light mb-4" id="bulkPanel" style="display: none;">
<div class="columns is-vcentered is-mobile">
<div class="column is-narrow">

View File

@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
];
let photos = [];
let tagMeta = { labels: {}, tags: [] };
let tagMeta = { labels: {}, tags: [], aliases: {} };
const tagLabel = (slug) => {
if (!slug) return '';
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
@ -56,6 +56,26 @@ document.addEventListener('DOMContentLoaded', () => {
}
return [];
};
const slugifyTag = (value) => String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const resolveTagSlug = (value) => {
const raw = String(value || '').trim();
if (!raw) return '';
const lowerRaw = raw.toLowerCase();
const aliases = tagMeta.aliases || {};
if (aliases[lowerRaw]) {
return aliases[lowerRaw];
}
const labels = tagMeta.labels || {};
for (const [slug, label] of Object.entries(labels)) {
if (label && label.toLowerCase() === lowerRaw) {
return slug;
}
}
return slugifyTag(raw) || lowerRaw;
};
const apiBaseCandidates = (() => {
const protocol = window.location.protocol;
@ -88,7 +108,7 @@ document.addEventListener('DOMContentLoaded', () => {
const response = await fetchWithTimeout(`${baseUrl}/photos/tags`, 3000);
if (!response.ok) return;
const data = await response.json();
tagMeta = { labels: {}, tags: [], ...data };
tagMeta = { labels: {}, tags: [], aliases: {}, ...data };
} catch (err) {
// Metadata is optional; fall back to raw tag text if unavailable.
}
@ -117,7 +137,12 @@ document.addEventListener('DOMContentLoaded', () => {
photos = fallbackPhotos;
rebuildFilterButtons();
}
renderFlatGallery(photos);
const hashTag = getHashTag();
if (hashTag) {
applyTagFilter(hashTag, false);
} else {
applyTagFilter('all', false);
}
}
function updateResultCount(count) {
@ -138,9 +163,7 @@ document.addEventListener('DOMContentLoaded', () => {
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tag = btn.dataset.tag;
filterByTag(tag.toLowerCase());
filterBtns.forEach(otherBtn => otherBtn.classList.remove('is-active'));
btn.classList.add('is-active');
applyTagFilter(tag, true);
});
});
}
@ -211,10 +234,11 @@ document.addEventListener('DOMContentLoaded', () => {
].filter(Boolean).join(', ')
: '';
const photoTags = normalizeTags(photo.tags);
const readableTags = photoTags.map(tagLabel);
const photoCard = document.createElement('div');
photoCard.className = 'gallery-item';
const tagBadges = readableTags.map(tag => `<span class="tag-chip" data-tag="${tag}"><i class="fa-solid fa-wand-magic-sparkles"></i>${tag}</span>`).join('');
const tagBadges = photoTags
.map(tag => `<span class="tag-chip" data-tag="${tag}"><i class="fa-solid fa-wand-magic-sparkles"></i>${tagLabel(tag)}</span>`)
.join('');
photoCard.innerHTML = `
<div class="gallery-photo">
<img loading="lazy" ${srcset ? `srcset="${srcset}" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 45vw, 90vw"` : ''} src="${src}" alt="${photo.caption}" data-caption="${photo.caption}" data-tags="${photoTags.join(',')}" data-full-src="${src}" decoding="async">
@ -248,11 +272,7 @@ document.addEventListener('DOMContentLoaded', () => {
chip.addEventListener('click', (e) => {
e.stopPropagation();
const tagText = chip.dataset.tag || '';
const slug = normalizeTags(tagText)[0] || tagText.toLowerCase();
filterByTag(slug);
const matchingBtn = Array.from(filterBtns).find(btn => btn.dataset.tag === slug);
filterBtns.forEach(btn => btn.classList.remove('is-active'));
if (matchingBtn) matchingBtn.classList.add('is-active');
applyTagFilter(tagText, true);
});
});
});
@ -260,6 +280,7 @@ document.addEventListener('DOMContentLoaded', () => {
function filterPhotos() {
const searchTerm = searchInput.value.toLowerCase();
const normalizedSearch = resolveTagSlug(searchTerm);
// Deactivate tag buttons when searching
filterBtns.forEach(btn => btn.classList.remove('is-active'));
if (searchTerm) {
@ -268,7 +289,9 @@ document.addEventListener('DOMContentLoaded', () => {
const captionMatch = photo.caption.toLowerCase().includes(searchTerm);
const tagMatch = photoTags.some(tag => {
const label = tagLabel(tag).toLowerCase();
return tag.toLowerCase().includes(searchTerm) || label.includes(searchTerm);
return tag.toLowerCase().includes(searchTerm)
|| label.includes(searchTerm)
|| (normalizedSearch && tag.toLowerCase() === normalizedSearch);
});
return captionMatch || tagMatch;
});
@ -281,17 +304,46 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
function filterByTag(tag) {
searchInput.value = '';
if (tag === 'all') {
renderFlatGallery(photos);
function setActiveFilterButton(tag) {
filterBtns.forEach(btn => btn.classList.toggle('is-active', btn.dataset.tag === tag));
}
function setHashTag(tag) {
const url = new URL(window.location.href);
if (!tag || tag === 'all') {
url.hash = '';
} else {
url.hash = encodeURIComponent(tag);
}
history.replaceState(null, '', url);
}
function filterByTag(tag, updateHash = true) {
searchInput.value = '';
const slug = resolveTagSlug(tag);
if (!slug || slug === 'all') {
renderFlatGallery(photos);
setActiveFilterButton('all');
if (updateHash) setHashTag('');
return;
}
const filteredPhotos = photos.filter(photo => {
const photoTags = normalizeTags(photo.tags);
return photoTags.some(t => t.toLowerCase() === tag);
return photoTags.some(t => t.toLowerCase() === slug);
});
renderFlatGallery(filteredPhotos);
setActiveFilterButton(slug);
if (updateHash) setHashTag(slug);
}
function getHashTag() {
const hash = window.location.hash || '';
if (!hash) return '';
return resolveTagSlug(decodeURIComponent(hash.replace(/^#/, '')).trim());
}
function applyTagFilter(tag, updateHash = true) {
filterByTag(tag, updateHash);
}
function openModal(imageElement) {
@ -321,11 +373,7 @@ document.addEventListener('DOMContentLoaded', () => {
chip.addEventListener('click', (e) => {
e.stopPropagation();
const tagText = chip.dataset.tag || '';
const slug = normalizeTags(tagText)[0] || tagText.toLowerCase();
filterByTag(slug);
const matchingBtn = Array.from(filterBtns).find(btn => btn.dataset.tag === slug);
filterBtns.forEach(btn => btn.classList.remove('is-active'));
if (matchingBtn) matchingBtn.classList.add('is-active');
applyTagFilter(tagText, true);
closeModal();
});
});
@ -373,5 +421,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
renderSkeletonLoader();
window.addEventListener('hashchange', () => {
const tag = getHashTag() || 'all';
applyTagFilter(tag, false);
});
fetchPhotos();
});

View File

@ -2,6 +2,8 @@ const MAIN_TAGS = [
{ slug: 'arch', label: 'Arch', aliases: ['arches', 'archway'] },
{ slug: 'garland', label: 'Garland', aliases: ['organic', 'organic-garland'] },
{ slug: 'columns', label: 'Columns', aliases: ['pillars'] },
{ slug: 'centerpiece', label: 'Centerpiece', aliases: ['table', 'tablescape'] },
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
{ slug: 'birthday', label: 'Birthday', aliases: ['bday', 'birthday-party'] },
{ slug: 'baby-shower', label: 'Baby Shower', aliases: ['baby', 'shower'] },
{ slug: 'gifts', label: 'Gifts', aliases: ['presents'] },
@ -11,27 +13,54 @@ const MAIN_TAGS = [
const OTHER_TAGS = [
{ slug: 'classic', label: 'Classic', aliases: [] },
{ slug: 'organic', label: 'Organic', aliases: [] },
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
{ slug: 'hoop', label: 'Hoop', aliases: ['ring'] },
{ slug: 'helium', label: 'Helium', aliases: [] },
{ slug: 'air-filled', label: 'Air-filled', aliases: ['airfilled', 'air'] },
{ slug: 'reunion', label: 'Reunion', aliases: [] },
{ slug: 'corporate', label: 'Corporate', aliases: ['business', 'office'] },
{ slug: 'holiday', label: 'Holiday', aliases: ['christmas', 'halloween', 'easter'] },
{ slug: 'holiday', label: 'Holiday', aliases: ['holidays'] },
{ slug: 'christmas', label: 'Christmas', aliases: ['xmas', 'x-mas'] },
{ slug: 'halloween', label: 'Halloween', aliases: [] },
{ slug: 'easter', label: 'Easter', aliases: [] },
{ slug: 'valentines', label: "Valentine's Day", aliases: ['valentine', 'valentine-day', 'valentines-day'] },
{ slug: 'new-years', label: "New Year's", aliases: ['new-year', 'nye', 'new-years-eve', 'new-year-eve'] },
{ slug: 'thanksgiving', label: 'Thanksgiving', aliases: ['turkey-day'] },
{ slug: 'july-4th', label: 'July 4th', aliases: ['fourth-of-july', '4th-of-july', 'fourth', 'independence-day'] },
{ slug: 'st-patricks', label: "St. Patrick's Day", aliases: ['st-pattys', 'st-paddys', 'st-patricks-day'] },
{ slug: 'mothers-day', label: "Mother's Day", aliases: ['mothers', 'mom-day'] },
{ slug: 'fathers-day', label: "Father's Day", aliases: ['fathers', 'dad-day'] },
{ slug: 'graduation-party', label: 'Graduation Party', aliases: ['grad-party', 'graduation-party'] },
{ slug: 'marquee', label: 'Marquee Letters', aliases: ['letters', 'marquee-letters'] },
{ slug: 'delivery', label: 'Delivery', aliases: ['deliver', 'delivered'] },
{ slug: 'pickup', label: 'Pickup', aliases: ['pick-up', 'collect'] },
{ slug: 'neon', label: 'Neon', aliases: ['led', 'light', 'lights'] },
];
const TAG_DEFINITIONS = [...MAIN_TAGS, ...OTHER_TAGS];
const TAG_PRESETS = [
{ name: 'Birthday', tags: ['birthday', 'arch', 'garland'] },
{ name: 'Baby Shower', tags: ['baby-shower', 'garland', 'gifts'] },
{ name: 'Graduation', tags: ['graduation', 'arch', 'classic'] },
{ name: 'Corporate', tags: ['corporate', 'columns', 'delivery'] },
{ name: 'Holiday', tags: ['holiday', 'garland', 'marquee'] }
{ name: 'Arch - Classic', tags: ['arch', 'classic'] },
{ name: 'Arch - Organic', tags: ['arch', 'organic'] },
{ name: 'Arch - Hoop', tags: ['arch', 'hoop'] },
{ name: 'Garland - Organic', tags: ['garland', 'organic'] },
{ name: 'Columns', tags: ['columns'] },
{ name: 'Centerpiece - Helium', tags: ['centerpiece', 'helium'] },
{ name: 'Centerpiece - Air-filled', tags: ['centerpiece', 'air-filled'] },
{ name: 'Sculpture', tags: ['sculpture'] },
{ name: 'Marquee', tags: ['marquee'] }
];
const MAX_TAGS = 8;
const HOLIDAY_SUBTAGS = new Set([
'christmas',
'halloween',
'easter',
'valentines',
'new-years',
'thanksgiving',
'july-4th',
'st-patricks',
'mothers-day',
'fathers-day'
]);
const slugifyTag = (tag) => {
return String(tag || '')
@ -68,6 +97,11 @@ const normalizeTags = (incomingTags = []) => {
}
});
if (normalized.some(tag => HOLIDAY_SUBTAGS.has(tag)) && !seen.has('holiday')) {
normalized.push('holiday');
seen.add('holiday');
}
return { normalized, rejected: [] };
};

View File

@ -66,7 +66,15 @@ router.route('/').get((req, res) => {
router.route('/tags').get(async (_req, res) => {
try {
const existing = await Photo.distinct('tags');
const tagCountsArray = await Photo.aggregate([
{ $unwind: '$tags' },
{ $group: { _id: '$tags', count: { $sum: 1 } } }
]);
const tagCounts = tagCountsArray.reduce((acc, item) => {
acc[item._id] = item.count;
return acc;
}, {});
const existing = tagCountsArray.map(item => item._id);
res.json({
tags: TAG_DEFINITIONS,
main: MAIN_TAGS,
@ -76,6 +84,7 @@ router.route('/tags').get(async (_req, res) => {
maxTags: MAX_TAGS,
labels: labelLookup,
existing: existing || [],
tagCounts,
});
} catch (err) {
console.error('Error fetching tag metadata:', err);
@ -88,6 +97,7 @@ router.route('/tags').get(async (_req, res) => {
maxTags: MAX_TAGS,
labels: labelLookup,
existing: [],
tagCounts: {},
});
}
});

View File

@ -10,6 +10,8 @@ const whitelist = [
'https://beachpartyballoons.com',
'https://www.beachpartyballoons.com',
'https://photobackend.beachpartyballoons.com', // Dedicated backend hostname
'http://localhost:3052',
'http://127.0.0.1:3052',
'http://localhost:3050',
'http://127.0.0.1:3050',
'http://localhost:8080' // Common local dev port