Reorganize gallery, optimize builds, add backups
This commit is contained in:
parent
b2a3e5d605
commit
c340cd2eaf
20
.gitignore
vendored
20
.gitignore
vendored
@ -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 │
|
||||
gallery/sculpture/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/
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
#clearSelection:hover {
|
||||
color: #f14668;
|
||||
}
|
||||
|
||||
.low-tag-card {
|
||||
box-shadow: 0 0 0 2px #ffdd57 inset;
|
||||
}
|
||||
|
||||
202
admin/admin.js
202
admin/admin.js
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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
44
backup.sh
Executable 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."
|
||||
@ -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
425
gallery/gallery.css
Normal 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 × */
|
||||
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
377
gallery/gallery.js
Normal 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();
|
||||
});
|
||||
@ -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>
|
||||
|
||||
<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" />
|
||||
|
||||
|
||||
<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 - 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>
|
||||
<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>
|
||||
</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="gallery-meta">
|
||||
<p class="result-count" id="result-count">Loading gallery...</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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">×</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>
|
||||
</section>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
154
gallery_old/index.html
Normal file
154
gallery_old/index.html
Normal 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 © <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>
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
84
photo-gallery-app/backend/lib/tagConfig.js
Normal file
84
photo-gallery-app/backend/lib/tagConfig.js
Normal 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,
|
||||
};
|
||||
@ -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,
|
||||
|
||||
1
photo-gallery-app/backend/package-lock.json
generated
1
photo-gallery-app/backend/package-lock.json
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
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;
|
||||
}
|
||||
// Invisible watermarking intentionally disabled.
|
||||
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);
|
||||
throw new Error('Server error during image processing.');
|
||||
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!'))
|
||||
|
||||
@ -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
|
||||
|
||||
14
server.js
14
server.js
@ -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}`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user