Reorganize gallery, optimize builds, add backups

This commit is contained in:
chris 2025-11-25 16:22:29 -05:00
parent b2a3e5d605
commit c340cd2eaf
28 changed files with 1596 additions and 211 deletions

18
.gitignore vendored
View File

@ -27,11 +27,15 @@ lerna-debug.log*
.DS_Store
Thumbs.db
/assets/pics/gallery/centerpiece/
/assets/pics/gallery/sculpture/
/assets/pics/gallery/classic/
/assets/pics/gallery/organic/
gallery/centerpiece/index.html
gallery/organic/index.html
gallery/classic/index.html
/assets/pics/gallery/centerpiece/
/assets/pics/gallery/sculpture/
/assets/pics/gallery/classic/
/assets/pics/gallery/organic/
gallery/centerpiece/index.html
gallery/organic/index.html
gallery/classic/index.html
gallery/sculpture/index.html
# Build artifacts and backups
public/build/
backups/

View File

@ -13,6 +13,9 @@ RUN npm install
# Bundle app source
COPY . .
# Build optimized frontend assets
RUN npm run build
# Make port 3050 available to the world outside this container
EXPOSE 3050

View File

@ -1,3 +1,7 @@
#clearSelection:hover {
color: #f14668;
}
.low-tag-card {
box-shadow: 0 0 0 2px #ffdd57 inset;
}

View File

@ -60,9 +60,48 @@ document.addEventListener('DOMContentLoaded', () => {
return `${protocol}//${backendHostname}`; // No explicit port needed as it's on 443
})();
const LAST_TAGS_KEY = 'bpb-last-tags';
const DEFAULT_MAX_TAGS = 8;
let tagMeta = {
tags: [],
main: [],
other: [],
aliases: {},
presets: [],
labels: {},
maxTags: DEFAULT_MAX_TAGS,
existing: []
};
let adminPassword = '';
const storedPassword = localStorage.getItem('bpb-admin-password');
const getAdminPassword = () => adminPassword || localStorage.getItem('bpb-admin-password') || '';
const slugifyTag = (tag) => String(tag || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').trim();
const canonicalizeTag = (tag) => {
const slug = slugifyTag(tag);
const mapped = tagMeta.aliases?.[slug] || slug;
return mapped;
};
const displayTag = (slug) => {
if (!slug) return '';
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};
const canonicalToDisplayString = (canonicalArr) => canonicalArr.map(displayTag).join(', ');
const normalizeTagsInput = (value) => {
const raw = String(value || '')
.split(',')
.map(t => t.trim())
.filter(Boolean);
const seen = new Set();
const canonical = [];
raw.forEach(tag => {
const mapped = canonicalizeTag(tag);
if (mapped && !seen.has(mapped) && canonical.length < (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
seen.add(mapped);
canonical.push(mapped);
}
});
return canonical;
};
const showAdmin = () => {
adminContent.style.display = 'block';
@ -91,6 +130,7 @@ document.addEventListener('DOMContentLoaded', () => {
adminPassword = passwordVal;
localStorage.setItem('bpb-admin-password', adminPassword);
showAdmin();
fetchTagMeta();
fetchPhotos();
fetchStatus();
preloadLastTags();
@ -103,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => {
adminPassword = storedPassword;
passwordInput.value = storedPassword;
showAdmin();
fetchTagMeta();
fetchPhotos();
fetchStatus();
preloadLastTags();
@ -110,6 +151,30 @@ document.addEventListener('DOMContentLoaded', () => {
showLogin();
}
async function fetchTagMeta() {
try {
const response = await fetch(`${backendUrl}/photos/tags`);
if (!response.ok) return;
const data = await response.json();
tagMeta = {
tags: [],
main: [],
other: [],
aliases: {},
presets: [],
labels: {},
maxTags: DEFAULT_MAX_TAGS,
existing: [],
...data
};
updateTagSuggestions();
updateQuickTags();
preloadLastTags();
} catch (error) {
console.error('Error fetching tag metadata:', error);
}
}
// --- Tab Switching ---
tabs.forEach(tab => {
tab.addEventListener('click', () => {
@ -149,15 +214,19 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
photos.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' : '';
const readableTags = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag);
const photoCard = `
<div class="column is-half-tablet is-one-third-desktop is-one-quarter-widescreen">
<div class="card has-background-light" data-photo-id="${photo._id}">
<div class="card has-background-light ${lowTagClass}" data-photo-id="${photo._id}">
<div class="card-content py-2 px-3 is-flex is-align-items-center is-justify-content-space-between">
<label class="checkbox is-size-7">
<input type="checkbox" class="select-photo-checkbox" data-photo-id="${photo._id}" ${selectedPhotoIds.has(photo._id) ? 'checked' : ''}>
Select
</label>
<span class="tag is-light">${photo.tags.length} tag${photo.tags.length === 1 ? '' : 's'}</span>
<span class="tag ${tagStatusClass}">${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}</span>
</div>
<div class="card-image">
<figure class="image is-3by2">
@ -166,7 +235,7 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
<div class="card-content">
<p class="has-text-dark"><strong class="has-text-dark">Caption:</strong> ${photo.caption}</p>
<p class="has-text-dark"><strong class="has-text-dark">Tags:</strong> ${photo.tags.join(', ')}</p>
<p class="has-text-dark"><strong class="has-text-dark">Tags:</strong> ${readableTags.join(', ')}</p>
</div>
<footer class="card-footer">
<a href="#" class="card-footer-item edit-button">Edit</a>
@ -184,7 +253,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (photo) {
editPhotoId.value = photo._id;
editCaption.value = photo.caption;
editTags.value = photo.tags.join(', ');
const readable = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag).join(', ');
editTags.value = readable;
editModal.classList.add('is-active');
}
}
@ -222,9 +292,18 @@ document.addEventListener('DOMContentLoaded', () => {
async function handleSaveChanges() {
const photoId = editPhotoId.value;
const canonicalTags = normalizeTagsInput(editTags.value);
if (!canonicalTags.length) {
alert('Please include at least one valid tag.');
return;
}
if (canonicalTags.length > (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
alert(`Keep tags under ${tagMeta.maxTags || DEFAULT_MAX_TAGS}.`);
return;
}
const updatedPhoto = {
caption: editCaption.value,
tags: editTags.value
caption: editCaption.value.trim(),
tags: canonicalTags.join(', ')
};
try {
@ -239,6 +318,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (response.ok) {
closeEditModal();
fetchPhotos(); // Refresh the gallery
fetchTagMeta();
} else {
alert('Failed to save changes.');
}
@ -295,16 +375,22 @@ document.addEventListener('DOMContentLoaded', () => {
openBulkDeleteModal();
}
function parseTagsString(str) {
return str.split(',').map(t => t.trim()).filter(Boolean);
}
async function bulkApplyEdits() {
if (!selectedPhotoIds.size) return;
const newCaption = bulkCaption.value.trim();
const tagStr = bulkTags.value.trim();
const hasCaption = newCaption.length > 0;
const hasTags = tagStr.length > 0;
const maxTagsAllowed = tagMeta.maxTags || DEFAULT_MAX_TAGS;
const incomingCanonical = hasTags ? normalizeTagsInput(tagStr) : [];
if (hasTags && !incomingCanonical.length) {
alert('Bulk tags must include at least one valid option from the list.');
return;
}
if (incomingCanonical.length > maxTagsAllowed) {
alert(`Please keep bulk tags under ${maxTagsAllowed}.`);
return;
}
if (!hasCaption && !hasTags) {
alert('Enter a caption and/or tags to apply.');
return;
@ -318,8 +404,11 @@ document.addEventListener('DOMContentLoaded', () => {
const existingTags = Array.isArray(photo.tags) ? photo.tags : [];
let finalTags = existingTags;
if (hasTags) {
const incoming = parseTagsString(tagStr);
finalTags = append ? Array.from(new Set([...existingTags, ...incoming])) : incoming;
const merged = append ? Array.from(new Set([...existingTags, ...incomingCanonical])) : incomingCanonical;
if (!merged.length || merged.length > maxTagsAllowed) {
throw new Error('Tag limit exceeded or invalid.');
}
finalTags = merged;
}
const payload = {
caption: hasCaption ? newCaption : photo.caption,
@ -332,6 +421,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
}));
fetchPhotos();
fetchTagMeta();
clearSelection();
bulkCaption.value = '';
bulkTags.value = '';
@ -407,7 +497,19 @@ document.addEventListener('DOMContentLoaded', () => {
const formData = new FormData();
files.forEach(file => formData.append('photos', file));
formData.append('caption', captionInput.value);
formData.append('tags', tagsInput.value);
const canonicalTags = normalizeTagsInput(tagsInput.value);
if (!canonicalTags.length) {
uploadStatus.textContent = 'Please choose at least one tag from the suggestions.';
uploadStatus.classList.add('has-text-danger');
return;
}
if (canonicalTags.length > (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
uploadStatus.textContent = `Use ${tagMeta.maxTags || DEFAULT_MAX_TAGS} tags or fewer.`;
uploadStatus.classList.add('has-text-danger');
return;
}
tagsInput.value = canonicalToDisplayString(canonicalTags);
formData.append('tags', canonicalTags.join(', '));
const xhr = new XMLHttpRequest();
@ -447,8 +549,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
uploadStatus.textContent = result?.message || `Uploaded ${uploadedCount || files.length} photo${(uploadedCount || files.length) === 1 ? '' : 's'} successfully!` + (skippedCount ? ` Skipped ${skippedCount} duplicate${skippedCount === 1 ? '' : 's'}.` : '');
uploadStatus.classList.add('has-text-success');
localStorage.setItem(LAST_TAGS_KEY, tagsInput.value.trim());
localStorage.setItem(LAST_TAGS_KEY, canonicalTags.join(', '));
fetchPhotos();
fetchTagMeta();
uploadForm.reset();
preloadLastTags();
} catch (jsonError) {
@ -481,62 +584,67 @@ document.addEventListener('DOMContentLoaded', () => {
function updateTagSuggestions() {
if (!tagSuggestions) return;
const uniqueTags = new Set();
photos.forEach(photo => {
const rawTags = Array.isArray(photo.tags) ? photo.tags : String(photo.tags || '').split(',');
rawTags.map(t => t.trim()).filter(Boolean).forEach(t => uniqueTags.add(t));
});
tagSuggestions.innerHTML = '';
Array.from(uniqueTags).sort().forEach(tag => {
const suggestions = [
...(tagMeta.main || []),
...(tagMeta.other || []),
...((tagMeta.existing || []).map(slug => ({ slug, label: displayTag(slug) })))
];
const seen = new Set();
suggestions.forEach(tag => {
if (!tag || !tag.slug || seen.has(tag.slug)) return;
seen.add(tag.slug);
const option = document.createElement('option');
option.value = tag;
option.value = tag.label;
option.dataset.slug = tag.slug;
tagSuggestions.appendChild(option);
});
}
function updateQuickTags() {
if (!quickTagButtons) return;
const tagCounts = {};
photos.forEach(photo => {
const rawTags = Array.isArray(photo.tags) ? photo.tags : String(photo.tags || '').split(',');
rawTags.map(t => t.trim()).filter(Boolean).forEach(tag => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
});
const sorted = Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 8)
.map(entry => entry[0]);
quickTagButtons.innerHTML = sorted.map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag}">${tag}</button>`).join('');
}
function normalizeTagsInput(value) {
return String(value || '')
.split(',')
.map(t => t.trim())
.filter(Boolean);
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>`);
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
}
function addTagToInput(tag) {
const canonical = canonicalizeTag(tag);
if (!canonical) return;
const existing = normalizeTagsInput(tagsInput.value);
if (!existing.includes(tag)) {
existing.push(tag);
tagsInput.value = existing.join(', ');
if (!existing.includes(canonical)) {
existing.push(canonical);
}
tagsInput.value = canonicalToDisplayString(existing);
}
function preloadLastTags() {
const last = localStorage.getItem(LAST_TAGS_KEY);
if (last && tagsInput && !tagsInput.value) {
tagsInput.value = last;
const canonical = normalizeTagsInput(last);
tagsInput.value = canonicalToDisplayString(canonical);
}
}
function applyPresetTags(presetName) {
const preset = (tagMeta.presets || []).find(p => p.name === presetName);
if (!preset) return;
const canonical = normalizeTagsInput((preset.tags || []).join(','));
tagsInput.value = canonicalToDisplayString(canonical);
}
if (quickTagButtons) {
quickTagButtons.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-tag]');
if (!btn) return;
addTagToInput(btn.dataset.tag);
const presetBtn = e.target.closest('button[data-preset]');
const tagBtn = e.target.closest('button[data-tag]');
if (presetBtn) {
applyPresetTags(presetBtn.dataset.preset);
return;
}
if (tagBtn) {
addTagToInput(tagBtn.dataset.tag);
}
});
}

View File

@ -89,7 +89,7 @@
<div class="control">
<input class="input has-background-light has-text-black" type="text" id="tagsInput" placeholder="classic, birthday" list="tagSuggestions" required>
<datalist id="tagSuggestions"></datalist>
<p class="help is-size-7 has-text-grey">Use commas between tags; suggestions come from existing photos.</p>
<p class="help is-size-7 has-text-grey">Pick from the curated list or presets; up to 8 tags per photo.</p>
</div>
<div class="buttons are-small mt-2" id="quickTagButtons" aria-label="Quick tag suggestions">
</div>
@ -195,9 +195,9 @@
<!-- Edit Photo Modal -->
<div id="editModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card">
<div class="modal-card has-background-light">
<header class="modal-card-head">
<p class="modal-card-title has-text-dark">Edit Photo</p>
<p class="modal-card-title has-text-dark has-background-light">Edit Photo</p>
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
@ -205,13 +205,13 @@
<div class="field">
<label class="label">Caption</label>
<div class="control">
<input class="input has-background-light" type="text" id="editCaption">
<input class="input has-background-light has-text-black" type="text" id="editCaption">
</div>
</div>
<div class="field">
<label class="label">Tags</label>
<div class="control">
<input class="input has-background-light" type="text" id="editTags">
<input class="input has-background-light has-text-black" type="text" id="editTags">
</div>
</div>
</section>
@ -241,6 +241,6 @@
</div>
</div>
<script src="admin.js"></script>
<script src="/build/admin.js" defer></script>
</body>
</html>

44
backup.sh Executable file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# Simple backup script for DB + uploads.
# Usage: ./backup.sh [db_name] [mongodb_service_name]
# Defaults: db_name="photogallery", mongodb_service="mongodb"
DB_NAME="${1:-photogallery}"
MONGO_SERVICE="${2:-mongodb}"
TIMESTAMP="$(date +%F-%H%M%S)"
BACKUP_ROOT="backups/photogallery-${TIMESTAMP}"
CONTAINER_DUMP="/data/tmp-backup-${TIMESTAMP}"
mkdir -p "${BACKUP_ROOT}"
compose() {
if command -v docker-compose >/dev/null 2>&1; then
docker-compose "$@"
else
docker compose "$@"
fi
}
echo "👉 Dumping Mongo database '${DB_NAME}' from service '${MONGO_SERVICE}'..."
compose exec "${MONGO_SERVICE}" mongodump --db "${DB_NAME}" --out "${CONTAINER_DUMP}"
echo "👉 Copying DB dump to host..."
compose cp "${MONGO_SERVICE}:${CONTAINER_DUMP}/${DB_NAME}" "${BACKUP_ROOT}/db"
echo "👉 Cleaning up dump inside container..."
compose exec "${MONGO_SERVICE}" rm -rf "${CONTAINER_DUMP}"
echo "👉 Copying uploads directory..."
cp -a "photo-gallery-app/backend/uploads" "${BACKUP_ROOT}/uploads"
ARCHIVE="backups/photogallery-${TIMESTAMP}.tar.gz"
echo "👉 Creating archive ${ARCHIVE} ..."
tar -czf "${ARCHIVE}" -C "backups" "photogallery-${TIMESTAMP}"
echo "✅ Backup complete:"
echo " DB dump: ${BACKUP_ROOT}/db"
echo " Uploads: ${BACKUP_ROOT}/uploads"
echo " Archive: ${ARCHIVE}"
echo "You can delete the unarchived folder to save space after verifying the archive."

View File

@ -1,10 +1,8 @@
version: '3.8'
services:
bpb-website:
build: .
ports:
- "3050:3050"
- "3052:3050"
environment:
ADMIN_PASSWORD: your_secure_password # IMPORTANT: Replace with a strong password
NODE_ENV: production
@ -41,7 +39,7 @@ services:
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
- ./mongodb_data:/data/db
volumes:
mongodb_data:
#volumes:
# mongodb_data:

425
gallery/gallery.css Normal file
View File

@ -0,0 +1,425 @@
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem;
}
.gallery-item {
position: relative;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 4 / 5;
background: #f7f7f2;
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.14), 0 1px 0 rgba(255, 255, 255, 0.5);
transition: transform 0.2s ease, box-shadow 0.2s ease;
opacity: 0;
transform: translateY(12px) scale(0.98);
}
.gallery-item:hover {
transform: translateY(-2px) scale(1.01);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.18), 0 2px 0 rgba(255, 255, 255, 0.6);
}
.gallery-item.is-visible {
opacity: 1;
transform: translateY(0) scale(1);
transition: transform 0.28s ease, box-shadow 0.28s ease, opacity 0.28s ease;
}
.gallery-photo,
.gallery-photo img {
width: 100%;
height: 100%;
display: block;
}
.gallery-photo img {
object-fit: cover;
cursor: pointer;
}
.gallery-overlay {
position: absolute;
inset: auto 0 0 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.65) 100%);
color: #fff;
display: flex;
flex-direction: column;
gap: 0.25rem;
opacity: 0;
padding: 0.75rem 0.8rem;
transition: opacity 0.18s ease;
pointer-events: none;
}
.gallery-item:hover .gallery-overlay,
.gallery-item.touch-active .gallery-overlay {
opacity: 1;
}
.overlay-title {
font-size: 1rem;
font-weight: 800;
line-height: 1.2;
}
.overlay-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.45rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #0e2238;
font-weight: 700;
font-size: 0.68rem;
letter-spacing: 0.01em;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.tag-chip i {
font-size: 0.7rem;
color: #0e2238;
}
.filter-btn {
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.08);
color: #363636;
font-weight: 700;
border-radius: 999px;
padding: 0.55rem 1rem;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.12);
transition: all 0.2s ease;
}
.filter-btn:hover {
border-color: rgba(0, 0, 0, 0.18);
color: #000;
}
.filter-btn.is-active {
background: #00c2b8;
color: #0e2238;
border-color: transparent;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.12);
}
.search-box {
margin-bottom: 1rem;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1), 0 1px 0 rgba(255, 255, 255, 0.5);
position: relative;
overflow: hidden;
padding: 0.9rem 1rem;
}
.filter-scroll {
margin-bottom: 1.1rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 0.25rem 0.35rem;
}
.filter-scroll::-webkit-scrollbar {
display: none;
}
.filter-rows {
display: inline-flex;
flex-direction: column;
gap: 0.45rem;
min-width: 100%;
}
.filter-row {
display: flex;
gap: 0.55rem;
flex-wrap: nowrap;
white-space: nowrap;
}
.filter-row .filter-btn {
white-space: nowrap;
}
.modal-card {
width: auto;
max-width: 90vw;
max-height: 90vh;
border-radius: 16px;
overflow: visible;
background: #fff;
border: none;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2), 0 0 1px rgba(0,0,0,0.1);
position: relative;
padding: 0;
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1), opacity 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-card-head {
display: none;
}
.modal-card-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
background: transparent;
padding: 1rem;
border-radius: 16px;
}
.modal-close-btn {
position: absolute;
top: -18px;
right: -18px;
background: #fff;
color: #4a4a4a;
border: 1px solid #dbdbdb;
border-radius: 50%;
width: 40px;
height: 40px;
font-size: 1.5rem;
font-weight: 300;
line-height: 38px; /* vertically center &times; */
text-align: center;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
cursor: pointer;
z-index: 10;
transition: all 0.2s ease;
}
.modal-close-btn:hover {
background: #f5f5f5;
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
}
.modal-figure {
width: 100%;
margin: 0;
display: flex;
justify-content: center;
}
.modal-image {
max-width: 100%;
max-height: calc(90vh - 120px);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
background: #f5f5f5;
border: none;
transform: translate(var(--modal-img-translate-x, 0), var(--modal-img-translate-y, 0)) scale(var(--modal-img-scale, 0.95));
opacity: 0;
}
.modal-caption-block {
width: 100%;
background: transparent;
border: none;
color: #1a1a1a;
border-radius: 0;
padding: 0.5rem 0.25rem 0;
box-shadow: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.modal-caption-title {
font-weight: 700;
letter-spacing: 0;
font-size: 1.1rem;
margin: 0;
color: #1a1a1a;
}
.modal-caption-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: center;
}
.modal-caption-tags .tag-chip {
background: #f0f0f0;
color: #5a5a5a;
box-shadow: none;
border: 1px solid #e0e0e0;
font-weight: 600;
}
.modal .modal-background {
background: rgba(18, 18, 18, 0.65);
backdrop-filter: blur(10px) saturate(120%);
opacity: 0;
transition: opacity 300ms ease;
}
.modal.show-bg .modal-background {
opacity: 1;
}
.modal.chrome-hidden .modal-card {
transform: scale(0.95);
opacity: 0;
}
.modal.is-active .modal-image {
animation: modalZoomIn 380ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
transform-origin: center center;
}
.modal.is-active .modal-card {
animation: popIn 300ms cubic-bezier(0.4, 0, 0.2, 1) 50ms forwards;
}
.gallery-hero {
background: #00c2b8;
position: relative;
overflow: hidden;
}
.gallery-hero .hero-body {
position: relative;
padding-top: 3rem;
padding-bottom: 3rem;
}
.gallery-kicker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.25);
color: #0e2238;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.gallery-wrap {
background: #e7e6dd;
}
.hero-title-accent {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: rgba(255, 255, 255, 0.16);
padding: 0.4rem 0.8rem;
border-radius: 12px;
}
.hero-title-accent i {
color: #0e2238;
background: #fef6e4;
border-radius: 50%;
padding: 0.35rem;
}
.gallery-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.result-count {
font-weight: 700;
color: #0e2238;
}
.result-count span {
color: #00c2b8;
}
.search-field .input {
border-radius: 8px;
padding-left: 2.5rem;
border: 1px solid rgba(0, 0, 0, 0.12);
box-shadow: none;
height: 2.6rem;
font-size: 0.95rem;
}
.search-field .icon.is-left {
top: 0.1rem;
height: 2.6rem;
display: flex;
align-items: center;
color: #0e2238;
}
.search-field .input:focus {
border-color: rgba(0, 0, 0, 0.2);
box-shadow: 0 0 0 0.08rem rgba(0, 0, 0, 0.08);
}
.help {
color: #5f7287;
font-size: 0.8rem;
}
.card .title.is-5 {
color: #0e2238;
}
.card .subtitle.is-7 {
color: #73859c;
letter-spacing: 0.01em;
}
.skip-to-gallery {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
background: #0e2238;
color: #fff;
padding: 0.55rem 1rem;
border-radius: 999px;
border: none;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16), 0 2px 0 rgba(255, 255, 255, 0.5);
text-decoration: none;
font-weight: 700;
letter-spacing: 0.01em;
}
.skip-to-gallery i {
color: #00c2b8;
}
body.modal-open {
overflow: hidden;
}
body.modal-open main,
body.modal-open nav,
body.modal-open footer {
pointer-events: none;
}
body.modal-open #top {
display: none;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes popIn {
from { transform: translateY(20px) scale(0.95); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes modalZoomIn {
from { opacity: 0; transform: scale(0.9); filter: blur(4px); }
to { opacity: 1; transform: scale(1); filter: blur(0); }
}
@media screen and (max-width: 768px) {
.hero.gallery-hero .hero-body {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.gallery-wrap {
padding: 1rem;
}
.gallery-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.9rem;
}
.gallery-meta {
justify-content: center;
text-align: center;
}
.search-field {
width: 100%;
margin-top: 0.75rem;
}
}
.skeleton-item {
border-radius: 12px;
overflow: hidden;
aspect-ratio: 4 / 5;
background: #e0e0e0;
position: relative;
}
.skeleton-item::before {
content: '';
position: absolute;
top: 0;
left: -150%;
width: 150%;
height: 100%;
background: linear-gradient(to right, transparent 0%, #f0f0f0 50%, transparent 100%);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
100% {
left: 150%;
}
}

377
gallery/gallery.js Normal file
View File

@ -0,0 +1,377 @@
document.addEventListener('DOMContentLoaded', () => {
const gallery = document.getElementById('photo-gallery');
const searchInput = document.getElementById('searchInput');
const filterRows = document.querySelector('.filter-rows');
let filterBtns = Array.from(document.querySelectorAll('.filter-btn'));
const modal = document.getElementById('image-modal');
const modalImg = document.getElementById('modal-image-src');
const modalCaption = document.getElementById('modal-caption');
const modalCaptionTags = document.getElementById('modal-caption-tags');
const modalCloseBtn = modal.querySelector('.modal-close-btn') || modal.querySelector('.delete');
const modalBackground = modal.querySelector('.modal-background');
const resultCountEl = document.getElementById('result-count');
const noResults = document.getElementById('no-results');
const isTouchDevice = 'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0;
const topButton = document.getElementById('top');
const fallbackPhotos = [
{
path: '../assets/pics/gallery/classic/20230617_131551.webp',
caption: "20' Classic Arch",
tags: ['arch', 'classic', 'outdoor', 'wedding']
},
{
path: '../assets/pics/gallery/classic/_Photos_20241207_083534.webp',
caption: 'Classic Columns',
tags: ['columns', 'classic', 'indoor', 'corporate', 'black-tie']
},
{
path: '../assets/pics/gallery/centerpiece/20230108_112718.jpg}.webp',
caption: 'Cocktail Arrangements',
tags: ['centerpiece', 'cocktail', 'tablescape', 'baby-shower']
},
{
path: '../assets/pics/gallery/organic/20241121_200047~2.jpg',
caption: 'Organic Garland',
tags: ['organic', 'garland', 'outdoor', 'birthday']
},
{
path: '../assets/pics/gallery/organic/20250202_133930~2.jpg',
caption: 'Organic Garland (Pastel)',
tags: ['organic', 'garland', 'pastel']
}
];
let photos = [];
let tagMeta = { labels: {}, tags: [] };
const tagLabel = (slug) => {
if (!slug) return '';
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
return slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
};
const normalizeTags = (tags) => {
if (Array.isArray(tags)) return tags;
if (typeof tags === 'string') {
return tags.split(',').map(tag => tag.trim()).filter(Boolean);
}
return [];
};
const apiBaseCandidates = (() => {
const protocol = window.location.protocol;
const host = window.location.hostname;
const hints = [
window.GALLERY_API_URL || '',
'https://photobackend.beachpartyballoons.com',
`${protocol}//${host}:5000`,
`${protocol}//${host}:5001`,
];
// Remove duplicates/empties
return [...new Set(hints.filter(Boolean))];
})();
let activeApiBase = '';
const fetchWithTimeout = async (url, timeoutMs = 4000) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
};
async function fetchTagMetadata(baseUrl) {
if (!baseUrl) return;
try {
const response = await fetchWithTimeout(`${baseUrl}/photos/tags`, 3000);
if (!response.ok) return;
const data = await response.json();
tagMeta = { labels: {}, tags: [], ...data };
} catch (err) {
// Metadata is optional; fall back to raw tag text if unavailable.
}
}
async function fetchPhotos() {
try {
let data = null;
for (const base of apiBaseCandidates) {
try {
const response = await fetchWithTimeout(`${base}/photos`, 3500);
if (!response.ok) continue;
data = await response.json();
activeApiBase = base;
await fetchTagMetadata(activeApiBase);
break;
} catch (err) {
// Try the next candidate quickly; don't block the UI.
continue;
}
}
photos = Array.isArray(data) && data.length ? data : fallbackPhotos;
rebuildFilterButtons();
} catch (error) {
console.error('Error fetching photos:', error);
photos = fallbackPhotos;
rebuildFilterButtons();
}
renderFlatGallery(photos);
}
function updateResultCount(count) {
if (!resultCountEl) return;
const total = photos.length;
const totalText = total ? `${total}` : '0';
const countText = count !== undefined ? `${count}` : totalText;
if (count === 0) {
resultCountEl.innerHTML = `<span>${countText}</span> photos shown • ${totalText} total`;
return;
}
resultCountEl.innerHTML = count === total
? `<span>${countText}</span> photos on display`
: `<span>${countText}</span> shown • ${totalText} total`;
}
function attachFilterListeners() {
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');
});
});
}
function rebuildFilterButtons() {
if (!filterRows) return;
const tagCounts = {};
photos.forEach(photo => {
normalizeTags(photo.tags).forEach(tag => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
});
const sorted = Object.entries(tagCounts)
.filter(([, count]) => count > 1)
.sort((a, b) => b[1] - a[1] || tagLabel(a[0]).localeCompare(tagLabel(b[0])));
const buttons = [`<div class="control"><button class="button filter-btn is-active" data-tag="all">All</button></div>`];
sorted.forEach(([slug, count]) => {
const label = `${tagLabel(slug)}${count ? ` (${count})` : ''}`;
buttons.push(`<div class="control"><button class="button filter-btn" data-tag="${slug}">${label}</button></div>`);
});
const rows = [];
const maxPerRow = 7;
const maxRows = 2;
const maxButtons = maxPerRow * maxRows;
const limitedButtons = buttons.slice(0, maxButtons);
for (let i = 0; i < limitedButtons.length; i += maxPerRow) {
rows.push(`<div class="filter-row">${buttons.slice(i, i + 7).join('')}</div>`);
}
filterRows.innerHTML = rows.join('');
filterBtns = Array.from(filterRows.querySelectorAll('.filter-btn'));
attachFilterListeners();
}
function renderFlatGallery(photoArray) {
gallery.innerHTML = ''; // Clear skeleton or old photos
updateResultCount(photoArray.length);
if (photoArray.length === 0) {
if (noResults) {
gallery.appendChild(noResults);
noResults.style.display = 'block';
}
return;
}
if (noResults) {
noResults.style.display = 'none';
}
photoArray.forEach((photo, idx) => {
const resolveUrl = (p) => {
if (typeof p !== 'string') return '';
if (p.startsWith('http') || p.startsWith('assets') || p.startsWith('/assets') || p.startsWith('../assets')) return p;
const base = activeApiBase
|| 'https://photobackend.beachpartyballoons.com'
|| `${window.location.protocol}//${window.location.hostname}:5000`;
const path = p.startsWith('/') ? p.slice(1) : p;
return `${base.replace(/\/$/, '')}/${path}`;
};
const src = resolveUrl(photo.path);
const srcset = photo.variants
? [
photo.variants.thumb ? `${resolveUrl(photo.variants.thumb)} 640w` : null,
photo.variants.medium ? `${resolveUrl(photo.variants.medium)} 1200w` : null,
src ? `${src} 2000w` : null
].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('');
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">
</div>
<div class="gallery-overlay">
<div class="overlay-bottom">
<p class="overlay-title">${photo.caption}</p>
<div class="overlay-tags" aria-label="Tags for ${photo.caption}">${tagBadges}</div>
</div>
</div>
`;
gallery.appendChild(photoCard);
setTimeout(() => requestAnimationFrame(() => photoCard.classList.add('is-visible')), idx * 70);
const imgEl = photoCard.querySelector('img');
imgEl.addEventListener('click', (e) => {
const card = e.currentTarget.closest('.gallery-item');
if (isTouchDevice && card) {
if (!card.classList.contains('touch-active')) {
card.classList.add('touch-active');
setTimeout(() => card.classList.remove('touch-active'), 2200);
return;
}
}
openModal(e.target);
if (card) card.classList.remove('touch-active');
});
const tagChips = photoCard.querySelectorAll('.tag-chip');
tagChips.forEach(chip => {
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');
});
});
});
}
function filterPhotos() {
const searchTerm = searchInput.value.toLowerCase();
// Deactivate tag buttons when searching
filterBtns.forEach(btn => btn.classList.remove('is-active'));
if (searchTerm) {
const filteredPhotos = photos.filter(photo => {
const photoTags = normalizeTags(photo.tags);
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 captionMatch || tagMatch;
});
renderFlatGallery(filteredPhotos);
} else {
renderFlatGallery(photos);
// Reactivate 'All' button if search is cleared
const allBtn = document.querySelector('.filter-btn[data-tag="all"]');
if (allBtn) allBtn.classList.add('is-active');
}
}
function filterByTag(tag) {
searchInput.value = '';
if (tag === 'all') {
renderFlatGallery(photos);
} else {
const filteredPhotos = photos.filter(photo => {
const photoTags = normalizeTags(photo.tags);
return photoTags.some(t => t.toLowerCase() === tag);
});
renderFlatGallery(filteredPhotos);
}
}
function openModal(imageElement) {
const rect = imageElement.getBoundingClientRect();
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const imgCenterX = rect.left + rect.width / 2;
const imgCenterY = rect.top + rect.height / 2;
const translateX = imgCenterX - centerX;
const translateY = imgCenterY - centerY;
const scaleStartRaw = Math.max(
rect.width / (window.innerWidth * 0.8),
rect.height / (window.innerHeight * 0.8),
0.55
);
const scaleStart = Math.min(Math.max(scaleStartRaw, 0.72), 0.96);
document.documentElement.style.setProperty('--modal-img-translate-x', `${translateX}px`);
document.documentElement.style.setProperty('--modal-img-translate-y', `${translateY}px`);
document.documentElement.style.setProperty('--modal-img-scale', scaleStart.toFixed(3));
modalImg.src = imageElement.dataset.fullSrc || imageElement.src;
modalCaption.textContent = imageElement.dataset.caption;
if (modalCaptionTags) {
const tags = (imageElement.dataset.tags || '').split(',').filter(Boolean);
modalCaptionTags.innerHTML = tags.map(t => `<span class="tag-chip" data-tag="${t}"><i class="fa-solid fa-wand-magic-sparkles"></i>${tagLabel(t)}</span>`).join('');
const chips = modalCaptionTags.querySelectorAll('.tag-chip');
chips.forEach(chip => {
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');
closeModal();
});
});
}
modal.classList.remove('show-bg');
modal.classList.add('chrome-hidden');
modal.classList.add('is-active');
document.documentElement.classList.add('is-clipped');
document.body.classList.add('modal-open');
if (topButton) topButton.style.display = 'none';
// Fade in chrome and background immediately after paint
requestAnimationFrame(() => {
modal.classList.add('show-bg');
modal.classList.remove('chrome-hidden');
});
}
function closeModal() {
modal.classList.remove('is-active');
modal.classList.remove('show-bg');
modal.classList.remove('chrome-hidden');
document.documentElement.classList.remove('is-clipped');
document.body.classList.remove('modal-open');
document.documentElement.style.removeProperty('--modal-img-translate-x');
document.documentElement.style.removeProperty('--modal-img-translate-y');
document.documentElement.style.removeProperty('--modal-img-scale');
if (topButton) {
const shouldShow = document.body.scrollTop > 130 || document.documentElement.scrollTop > 130;
topButton.style.display = shouldShow ? 'block' : 'none';
}
}
searchInput.addEventListener('keyup', filterPhotos);
if (modalCloseBtn) modalCloseBtn.addEventListener('click', closeModal);
modalBackground.addEventListener('click', closeModal);
function renderSkeletonLoader() {
gallery.innerHTML = ''; // Clear gallery
for (let i = 0; i < 8; i++) {
const skeletonItem = document.createElement('div');
skeletonItem.className = 'skeleton-item';
gallery.appendChild(skeletonItem);
}
}
renderSkeletonLoader();
fetchPhotos();
});

View File

@ -2,33 +2,27 @@
<html lang="en">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png">
<link rel="manifest" href="../assets/favicon/site.webmanifest">
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png">
<link rel="manifest" href="../assets/favicon/site.webmanifest">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Beach Party Balloons - Your go-to shop for stunning balloon decorations, walk-in arrangements, and deliveries in CT.">
<title>Beach Party Balloons</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<meta name="description" content="Beach Party Balloons - Photo Gallery">
<title>Beach Party Balloons - Gallery</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="gallery.css">
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="../">
<a class="navbar-item" href="/">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
@ -41,29 +35,28 @@
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item " href="../">
<a class="navbar-item" href="/">
Home
</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
Shop
</a>
<a class="navbar-item" href="../about/">
<a class="navbar-item" href="/about/">
About Us
</a>
<a class="navbar-item" href="../faq/">
<a class="navbar-item" href="/faq/">
FAQ
</a>
<a class="navbar-item" href="../terms/">
<a class="navbar-item" href="/terms/">
Terms
</a>
<!-- <div class="navbar-item "> -->
<a class="navbar-item is-tab is-active" href="../gallery/">
<a class="navbar-item is-tab is-active" href="/gallery/">
Gallery
</a>
<a class="navbar-item" href="../color/">
<a class="navbar-item" href="/color/">
Colors
</a>
<a class="navbar-item" href="../contact/">
<a class="navbar-item" href="/contact/">
Contact
</a>
@ -74,59 +67,93 @@
</div>
</div>
</nav>
<div class="update">
<div id="message"></div>
</div>
<main class="section gallery-wrap">
<div class="container">
<h1 class="title is-2 has-text-centered mb-4 has-text-dark">Gallery</h1>
<div class="has-text-centered">
<a class="skip-to-gallery has-background-light has-text-dark" href="#photo-gallery">
<i class="fa-solid fa-images"></i>
Jump to photos
</a>
</div>
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
<div class="box search-box">
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap gap-sm">
<div>
<p class="is-size-5 has-text-weight-semibold">Search</p>
</div>
<div class="field has-icons-left search-field">
<p class="control has-icons-left is-expanded">
<input class="input search-input has-background-light has-text-dark" type="text" id="searchInput" placeholder="Search by tags or description" aria-label="Search gallery by tags or description">
<span class="icon is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
</div>
<p class="help is-size-7 has-text-grey mt-2">Examples: classic, organic, indoor, holiday</p>
</div>
<div class="columns is-desktop">
<div class="column">
<div class="image-container">
<a href="classic/"><img class="image is-16by9" src="../assets/pics/classic/classic-cover.webp" alt="">
<div class="overlay">
<p class="has-text-white has-text-weight-bold is-size-2-desktop">Classic Balloon Décor</p>
<div class="gallery-meta">
<p class="result-count" id="result-count">Loading gallery...</p>
</div>
</a>
<div class="filter-scroll">
<div class="filter-rows">
<div class="filter-row">
<div class="control"><button class="button filter-btn is-active" data-tag="all">All</button></div>
<div class="control"><button class="button filter-btn" data-tag="classic">Classic</button></div>
<div class="control"><button class="button filter-btn" data-tag="organic">Organic</button></div>
<div class="control"><button class="button filter-btn" data-tag="centerpiece">Centerpiece</button></div>
<div class="control"><button class="button filter-btn" data-tag="sculpture">Sculpture</button></div>
<div class="control"><button class="button filter-btn" data-tag="arch">Arch</button></div>
<div class="control"><button class="button filter-btn" data-tag="garland">Garland</button></div>
</div>
<div class="filter-row">
<div class="control"><button class="button filter-btn" data-tag="indoor">Indoor</button></div>
<div class="control"><button class="button filter-btn" data-tag="outdoor">Outdoor</button></div>
<div class="control"><button class="button filter-btn" data-tag="corporate">Corporate</button></div>
<div class="control"><button class="button filter-btn" data-tag="wedding">Wedding</button></div>
<div class="control"><button class="button filter-btn" data-tag="birthday">Birthday</button></div>
<div class="control"><button class="button filter-btn" data-tag="holiday">Holiday</button></div>
<div class="control"><button class="button filter-btn" data-tag="halloween">Halloween</button></div>
<div class="control"><button class="button filter-btn" data-tag="easter">Easter</button></div>
<div class="control"><button class="button filter-btn" data-tag="nautical">Nautical</button></div>
<div class="control"><button class="button filter-btn" data-tag="pastel">Pastel</button></div>
</div>
</div>
<div class="column">
<div class="image-container">
<a href="organic/"><img class="image is-16by9" src="../assets/pics/organic/organic-cover.webp" alt="">
<div class="overlay">
<p class="has-text-white has-text-weight-bold is-size-2-desktop">Organic Balloon Décor</p>
</div>
</a>
<div id="photo-gallery" class="gallery-grid">
<div id="no-results" class="has-text-centered" style="display: none; width: 100%; grid-column: 1 / -1;">
<p class="is-size-5 has-text-grey">No photos found matching your criteria.</p>
</div>
<!-- Photos will be dynamically loaded here -->
</div>
</div>
</div>
<div class="columns is-desktop">
<div class="column">
<div class="image-container">
<a href="centerpiece/"><img class="image is-16by9" src="../assets/pics/centerpiece/centerpiece-cover.webp" alt="">
<div class="overlay">
<p class="has-text-white has-text-weight-bold is-size-2-desktop">Centerpieces</p>
</main>
<div id="image-modal" class="modal">
<div class="modal-background"></div>
<div class="modal-card">
<section class="modal-card-body">
<button class="modal-close-btn" aria-label="Close modal">&times;</button>
<figure class="modal-figure">
<img class="modal-image" id="modal-image-src" src="" alt="">
</figure>
<div class="modal-caption-block">
<p class="modal-caption-title" id="modal-caption"></p>
<div class="modal-caption-tags" id="modal-caption-tags"></div>
</div>
</a>
</section>
</div>
</div>
<div class="column">
<div class="image-container">
<a href="sculpture/"><img class="image is-16by9" src="../assets/pics/sculptures/sculpture-cover.webp" alt="">
<div class="overlay">
<p class="has-text-white has-text-weight-bold is-size-2-desktop">Sculptures & Themes</p>
</div>
</a>
</div>
</div>
</div>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
@ -146,9 +173,12 @@
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<script src="../script.js"></script>
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
<script async data-nf='{"formurl":"https://forms.beachpartyballoons.com/forms/contact-us-vjz40v","emoji":"💬","position":"left","bgcolor":"#0dc9ba","width":"500"}' src='https://forms.beachpartyballoons.com/widgets/embed-min.js'></script>
<script>
// Force gallery API to the hosted backend to avoid localhost/mixed-content issues.
window.GALLERY_API_URL = 'https://photobackend.beachpartyballoons.com';
</script>
<script src="../script.js" defer></script>
<script src="../update.js" defer></script>
<script src="/build/gallery.js" defer></script>
</body>
</html>

154
gallery_old/index.html Normal file
View File

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png">
<link rel="manifest" href="../assets/favicon/site.webmanifest">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Beach Party Balloons - Your go-to shop for stunning balloon decorations, walk-in arrangements, and deliveries in CT.">
<title>Beach Party Balloons</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="../">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item " href="../">
Home
</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
Shop
</a>
<a class="navbar-item" href="../about/">
About Us
</a>
<a class="navbar-item" href="../faq/">
FAQ
</a>
<a class="navbar-item" href="../terms/">
Terms
</a>
<!-- <div class="navbar-item "> -->
<a class="navbar-item is-tab is-active" href="../gallery/">
Gallery
</a>
<a class="navbar-item" href="../color/">
Colors
</a>
<a class="navbar-item" href="../contact/">
Contact
</a>
</div>
</div>
<div class="navbar-end">
</div>
</div>
</nav>
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
<div class="columns is-desktop">
<div class="column">
<div class="image-container">
<a href="classic/"><img class="image is-16by9" src="../assets/pics/classic/classic-cover.webp" alt="">
<div class="overlay">
<p class="has-text-white has-text-weight-bold is-size-2-desktop">Classic Balloon Décor</p>
</div>
</a>
</div>
</div>
<div class="column">
<div class="image-container">
<a href="organic/"><img class="image is-16by9" src="../assets/pics/organic/organic-cover.webp" alt="">
<div class="overlay">
<p class="has-text-white has-text-weight-bold is-size-2-desktop">Organic Balloon Décor</p>
</div>
</a>
</div>
</div>
</div>
<div class="columns is-desktop">
<div class="column">
<div class="image-container">
<a href="centerpiece/"><img class="image is-16by9" src="../assets/pics/centerpiece/centerpiece-cover.webp" alt="">
<div class="overlay">
<p class="has-text-white has-text-weight-bold is-size-2-desktop">Centerpieces</p>
</div>
</a>
</div>
</div>
<div class="column">
<div class="image-container">
<a href="sculpture/"><img class="image is-16by9" src="../assets/pics/sculptures/sculpture-cover.webp" alt="">
<div class="overlay">
<p class="has-text-white has-text-weight-bold is-size-2-desktop">Sculptures & Themes</p>
</div>
</a>
</div>
</div>
</div>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
</a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
</a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
</a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
<i class="fa-brands fa-bluesky is-size-2"></i>
</a>
</div>
<h7>Copyright &copy; <span id="year"></span> Beach Party Balloons</h7>
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<script src="../script.js"></script>
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
<script async data-nf='{"formurl":"https://forms.beachpartyballoons.com/forms/contact-us-vjz40v","emoji":"💬","position":"left","bgcolor":"#0dc9ba","width":"500"}' src='https://forms.beachpartyballoons.com/widgets/embed-min.js'></script>
</body>
</html>

View File

@ -8,7 +8,8 @@
"start:prod": "NODE_ENV=production node server.js",
"start:backend": "npm start --prefix photo-gallery-app/backend",
"start:all": "concurrently --names \"web,api\" \"npm start\" \"npm run start:backend\"",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"build": "npx esbuild gallery/gallery.js admin/admin.js --bundle --minify --format=iife --target=es2018 --outdir=public/build"
},
"keywords": [],
"author": "",
@ -20,6 +21,7 @@
"express": "^5.1.0"
},
"devDependencies": {
"concurrently": "^9.2.1"
"concurrently": "^9.2.1",
"esbuild": "^0.21.5"
}
}

View File

@ -0,0 +1,84 @@
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: 'birthday', label: 'Birthday', aliases: ['bday', 'birthday-party'] },
{ slug: 'baby-shower', label: 'Baby Shower', aliases: ['baby', 'shower'] },
{ slug: 'gifts', label: 'Gifts', aliases: ['presents'] },
{ slug: 'graduation', label: 'Graduation', aliases: ['grad', 'commencement'] },
];
const OTHER_TAGS = [
{ slug: 'classic', label: 'Classic', aliases: [] },
{ slug: 'organic', label: 'Organic', aliases: [] },
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
{ slug: 'reunion', label: 'Reunion', aliases: [] },
{ slug: 'corporate', label: 'Corporate', aliases: ['business', 'office'] },
{ slug: 'holiday', label: 'Holiday', aliases: ['christmas', 'halloween', 'easter'] },
{ 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'] }
];
const MAX_TAGS = 8;
const slugifyTag = (tag) => {
return String(tag || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.trim();
};
const aliasMap = {};
TAG_DEFINITIONS.forEach(def => {
aliasMap[def.slug] = def.slug;
(def.aliases || []).forEach(alias => {
aliasMap[alias] = def.slug;
});
});
const labelLookup = TAG_DEFINITIONS.reduce((acc, tag) => {
acc[tag.slug] = tag.label;
return acc;
}, {});
const normalizeTags = (incomingTags = []) => {
const normalized = [];
const seen = new Set();
incomingTags.forEach(raw => {
const slug = slugifyTag(raw);
if (!slug) return;
const canonical = aliasMap[slug] || slug;
if (!seen.has(canonical)) {
seen.add(canonical);
normalized.push(canonical);
}
});
return { normalized, rejected: [] };
};
module.exports = {
MAIN_TAGS,
OTHER_TAGS,
TAG_DEFINITIONS,
TAG_PRESETS,
aliasMap,
labelLookup,
MAX_TAGS,
normalizeTags,
slugifyTag,
};

View File

@ -1,4 +1,5 @@
const mongoose = require('mongoose');
const { MAX_TAGS } = require('../lib/tagConfig');
const Schema = mongoose.Schema;
@ -21,7 +22,13 @@ const photoSchema = new Schema({
},
tags: {
type: [String],
required: true
required: true,
validate: [
{
validator: (arr) => Array.isArray(arr) && arr.length > 0 && arr.length <= MAX_TAGS,
message: `Tags must include between 1 and ${MAX_TAGS} items.`
}
]
},
hash: {
type: String,

View File

@ -11,6 +11,7 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"heic-convert": "^2.1.0",
"mongoose": "^8.20.0",
"multer": "^2.0.2",
"sharp": "^0.33.3"

View File

@ -13,6 +13,7 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"heic-convert": "^2.1.0",
"mongoose": "^8.20.0",
"multer": "^2.0.2",
"sharp": "^0.33.3"

View File

@ -8,9 +8,21 @@ const fsPromises = require('fs').promises;
const { Blob } = require('buffer');
const FormData = global.FormData;
const sharp = require('sharp');
const heicConvert = require('heic-convert');
const {
MAIN_TAGS,
OTHER_TAGS,
TAG_DEFINITIONS,
TAG_PRESETS,
MAX_TAGS,
normalizeTags,
aliasMap,
labelLookup,
} = require('../lib/tagConfig');
const WATERMARK_URL = process.env.WATERMARK_URL || 'http://watermarker:8000/watermark';
const DISABLE_WM = String(process.env.DISABLE_INVISIBLE_WATERMARK || '').toLowerCase() === 'true';
// We now use a visible diagonal watermark only. Invisible watermarking is disabled by default.
const DISABLE_WM = true;
const VARIANTS = {
main: { size: 2000, quality: 82, suffix: '' },
@ -18,34 +30,27 @@ const VARIANTS = {
thumb: { size: 640, quality: 76, suffix: '-sm' },
};
const HEIF_BRANDS = new Set([
'heic', 'heix', 'hevc', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1', 'avif', 'avis'
]);
const isHeic = (file) => {
const mime = (file.mimetype || '').toLowerCase();
if (mime.includes('heic') || mime.includes('heif')) return true;
const ext = path.extname(file.originalname || '').toLowerCase();
return ext === '.heic' || ext === '.heif' || ext === '.avif';
};
const isHeifBuffer = (buffer) => {
if (!buffer || buffer.length < 12) return false;
// ISO BMFF brand is at bytes 8-11, e.g. "heic", "avif"
const brand = buffer.slice(8, 12).toString('ascii').toLowerCase();
return HEIF_BRANDS.has(brand);
};
async function applyInvisibleWatermark(buffer, payload, filename) {
if (DISABLE_WM) {
// Invisible watermarking intentionally disabled.
return buffer;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const formData = new FormData();
formData.append('payload', payload);
formData.append('image', new Blob([buffer], { type: 'image/webp' }), filename || 'image.webp');
const response = await fetch(WATERMARK_URL, {
method: 'POST',
body: formData,
signal: controller.signal
});
clearTimeout(timeout);
if (!response.ok) {
throw new Error(`Watermark service responded with ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('Invisible watermarking failed, falling back to visible-only:', error.message);
return buffer;
}
}
// Multer setup for file uploads in memory
@ -59,6 +64,41 @@ router.route('/').get((req, res) => {
.catch(err => res.status(400).json('Error: ' + err));
});
router.route('/tags').get(async (_req, res) => {
try {
const existing = await Photo.distinct('tags');
res.json({
tags: TAG_DEFINITIONS,
main: MAIN_TAGS,
other: OTHER_TAGS,
aliases: aliasMap,
presets: TAG_PRESETS,
maxTags: MAX_TAGS,
labels: labelLookup,
existing: existing || [],
});
} catch (err) {
console.error('Error fetching tag metadata:', err);
res.json({
tags: TAG_DEFINITIONS,
main: MAIN_TAGS,
other: OTHER_TAGS,
aliases: aliasMap,
presets: TAG_PRESETS,
maxTags: MAX_TAGS,
labels: labelLookup,
existing: [],
});
}
});
const parseIncomingTags = (tagsInput) => {
const rawList = Array.isArray(tagsInput)
? tagsInput
: String(tagsInput || '').split(',').map(tag => tag.trim()).filter(Boolean);
return normalizeTags(rawList);
};
// POST new photo(s) with WebP conversion + duplicate hash checks
router.route('/upload').post(upload.array('photos'), async (req, res) => {
const files = (req.files && req.files.length) ? req.files : (req.file ? [req.file] : []);
@ -71,12 +111,43 @@ router.route('/upload').post(upload.array('photos'), async (req, res) => {
if (!captionText) {
return res.status(400).json({ success: false, error: 'Caption is required.' });
}
const tagList = typeof tags === 'string'
? tags.split(',').map(tag => tag.trim()).filter(Boolean)
: [];
const { normalized: tagList } = parseIncomingTags(tags);
if (!tagList.length) {
return res.status(400).json({
success: false,
error: 'Please add at least one tag.',
});
}
if (tagList.length > MAX_TAGS) {
return res.status(400).json({
success: false,
error: `Please use at most ${MAX_TAGS} tags.`
});
}
const processFile = async (file) => {
const hash = crypto.createHash('sha256').update(file.buffer).digest('hex');
let inputBuffer = file.buffer;
let convertedFromHeif = false;
const convertHeifIfNeeded = async (force) => {
if (convertedFromHeif) return;
if (!force && !(isHeic(file) || isHeifBuffer(inputBuffer))) return;
try {
inputBuffer = await heicConvert({
buffer: inputBuffer,
format: 'JPEG',
quality: 1,
});
convertedFromHeif = true;
} catch (err) {
console.error('HEIC/HEIF conversion failed:', err);
throw new Error('Unable to process HEIC/HEIF image. Please try a different file.');
}
};
await convertHeifIfNeeded(false);
const hash = crypto.createHash('sha256').update(inputBuffer).digest('hex');
let existing = null;
try {
@ -100,45 +171,88 @@ router.route('/upload').post(upload.array('photos'), async (req, res) => {
parseInt(hash.substring(4, 6), 16),
];
const mainOverlay = Buffer.from(`
<svg width="800" height="200" xmlns="http://www.w3.org/2000/svg">
<style>
text { font-family: Arial, sans-serif; }
</style>
<text x="780" y="150" text-anchor="end" fill="rgba(255,255,255,0.30)" stroke="rgba(0,0,0,0.25)" stroke-width="2" font-size="64">Beach Party Balloons</text>
</svg>
`);
const cornerOverlay = Buffer.from(`
<svg width="400" height="120" xmlns="http://www.w3.org/2000/svg">
<style>
text { font-family: Arial, sans-serif; }
</style>
<text x="12" y="70" fill="rgba(0,0,0,0.22)" font-size="36">beachpartyballoons.com</text>
<rect x="2" y="2" width="1" height="1" fill="rgba(${hiddenColor[0]}, ${hiddenColor[1]}, ${hiddenColor[2]}, 0.01)" />
const diagonalOverlay = Buffer.from(`
<svg width="2400" height="2400" viewBox="0 0 2400 2400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="diagGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.22)" />
<stop offset="50%" stop-color="rgba(255,255,255,0.33)" />
<stop offset="100%" stop-color="rgba(255,255,255,0.22)" />
</linearGradient>
</defs>
<g transform="translate(1200 1200) rotate(-32)">
<text x="0" y="-80" text-anchor="middle" dominant-baseline="middle"
fill="url(#diagGrad)" stroke="rgba(0,0,0,0.16)" stroke-width="8"
font-family="Arial Black, Arial, sans-serif" font-size="260" letter-spacing="6" textLength="1800" lengthAdjust="spacingAndGlyphs">
BEACH PARTY
<tspan x="0" dy="280">BALLOONS</tspan>
</text>
</g>
</svg>
`);
let buffer;
try {
buffer = await sharp(file.buffer)
// Prepare base image first so we know its post-resize dimensions, then scale overlay slightly smaller to avoid size conflicts
const base = sharp(inputBuffer)
.rotate()
.resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true })
.toColorspace('srgb')
.toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 })
.toColorspace('srgb');
const { data: baseBuffer, info } = await base.toBuffer({ resolveWithObject: true });
const targetWidth = Math.max(Math.floor((info.width || VARIANTS.main.size) * 0.98), 1);
const targetHeight = Math.max(Math.floor((info.height || VARIANTS.main.size) * 0.98), 1);
// Scale the diagonal overlay to slightly smaller than the image to ensure it composites cleanly
const overlayBuffer = await sharp(diagonalOverlay, { density: 300 })
.resize({ width: targetWidth, height: targetHeight, fit: 'cover' })
.png()
.toBuffer();
buffer = await sharp(baseBuffer)
.composite([
{ input: mainOverlay, gravity: 'southeast' },
{ input: cornerOverlay, gravity: 'northwest' },
{ input: overlayBuffer, gravity: 'center' },
])
.toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 })
.toBuffer();
} catch (err) {
console.error('Error processing image with sharp:', err);
const needsHeifFallback = err.message && err.message.toLowerCase().includes('no decoding plugin');
if (!convertedFromHeif && needsHeifFallback) {
await convertHeifIfNeeded(true);
try {
const baseRetry = sharp(inputBuffer)
.rotate()
.resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true })
.toColorspace('srgb');
const { data: baseBufferRetry, info: infoRetry } = await baseRetry.toBuffer({ resolveWithObject: true });
const overlayRetry = await sharp(diagonalOverlay, { density: 300 })
.resize({
width: Math.max(Math.floor((infoRetry.width || VARIANTS.main.size) * 0.98), 1),
height: Math.max(Math.floor((infoRetry.height || VARIANTS.main.size) * 0.98), 1),
fit: 'cover'
})
.png()
.toBuffer();
buffer = await sharp(baseBufferRetry)
.composite([
{ input: overlayRetry, gravity: 'center' },
])
.toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 })
.toBuffer();
} catch (secondErr) {
console.error('Retry after HEIF conversion failed:', secondErr);
throw new Error('Server error during image processing.');
}
} else {
throw new Error('Server error during image processing.');
}
}
try {
const payload = `BPB:${hash}`;
const stampedBuffer = await applyInvisibleWatermark(buffer, payload, filename);
const stampedBuffer = buffer;
await fsPromises.writeFile(filepath, stampedBuffer);
// Create responsive variants from the stamped image to keep overlays consistent
const variants = {};
@ -245,8 +359,23 @@ router.route('/:id').delete((req, res) => {
router.route('/update/:id').post((req, res) => {
Photo.findById(req.params.id)
.then(photo => {
photo.caption = req.body.caption;
photo.tags = req.body.tags.split(',').map(tag => tag.trim());
const incomingCaption = req.body.caption;
const incomingTags = req.body.tags;
const captionText = typeof incomingCaption === 'string' ? incomingCaption.trim() : '';
const { normalized: tagList } = parseIncomingTags(incomingTags);
if (!captionText) {
return res.status(400).json('Caption is required.');
}
if (!tagList.length) {
return res.status(400).json(`Please add at least one tag (${MAX_TAGS} max).`);
}
if (tagList.length > MAX_TAGS) {
return res.status(400).json(`Please keep tags under ${MAX_TAGS}.`);
}
photo.caption = captionText;
photo.tags = tagList;
photo.save()
.then(() => res.json('Photo updated!'))

View File

@ -7,7 +7,9 @@ const port = process.env.PORT || 5000;
const whitelist = [
'https://preview.beachpartyballoons.com',
'https://photobackend.beachpartyballoons.com', // Added new backend hostname as a potential origin
'https://beachpartyballoons.com',
'https://www.beachpartyballoons.com',
'https://photobackend.beachpartyballoons.com', // Dedicated backend hostname
'http://localhost:3050',
'http://127.0.0.1:3050',
'http://localhost:8080' // Common local dev port

View File

@ -60,8 +60,20 @@ apiRouter.post('/update-status', (req, res) => {
app.use('/api', apiRouter);
// --- Static Files ---
const staticCacheOptions = {
maxAge: process.env.NODE_ENV === 'production' ? '30d' : 0,
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-store');
} else if (/\.(js|css|svg|ico|png|jpg|jpeg|webp|avif|woff2?)$/i.test(filePath)) {
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable');
}
}
};
// Serve bundled assets under /build with long cache
app.use('/build', express.static(path.join(__dirname, 'public/build'), staticCacheOptions));
// Serve static files from the root directory (handles all other GET requests)
app.use(express.static(path.join(__dirname)));
app.use(express.static(path.join(__dirname), staticCacheOptions));
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);