Reorganize gallery, optimize builds, add backups
This commit is contained in:
parent
b2a3e5d605
commit
c340cd2eaf
18
.gitignore
vendored
18
.gitignore
vendored
@ -27,11 +27,15 @@ lerna-debug.log*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
/assets/pics/gallery/centerpiece/ │
|
/assets/pics/gallery/centerpiece/
|
||||||
/assets/pics/gallery/sculpture/ │
|
/assets/pics/gallery/sculpture/
|
||||||
/assets/pics/gallery/classic/ │
|
/assets/pics/gallery/classic/
|
||||||
/assets/pics/gallery/organic/ │
|
/assets/pics/gallery/organic/
|
||||||
gallery/centerpiece/index.html │
|
gallery/centerpiece/index.html
|
||||||
gallery/organic/index.html │
|
gallery/organic/index.html
|
||||||
gallery/classic/index.html │
|
gallery/classic/index.html
|
||||||
gallery/sculpture/index.html
|
gallery/sculpture/index.html
|
||||||
|
|
||||||
|
# Build artifacts and backups
|
||||||
|
public/build/
|
||||||
|
backups/
|
||||||
|
|||||||
@ -13,6 +13,9 @@ RUN npm install
|
|||||||
# Bundle app source
|
# Bundle app source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build optimized frontend assets
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Make port 3050 available to the world outside this container
|
# Make port 3050 available to the world outside this container
|
||||||
EXPOSE 3050
|
EXPOSE 3050
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
#clearSelection:hover {
|
#clearSelection:hover {
|
||||||
color: #f14668;
|
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
|
return `${protocol}//${backendHostname}`; // No explicit port needed as it's on 443
|
||||||
})();
|
})();
|
||||||
const LAST_TAGS_KEY = 'bpb-last-tags';
|
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 = '';
|
let adminPassword = '';
|
||||||
const storedPassword = localStorage.getItem('bpb-admin-password');
|
const storedPassword = localStorage.getItem('bpb-admin-password');
|
||||||
const getAdminPassword = () => adminPassword || 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 = () => {
|
const showAdmin = () => {
|
||||||
adminContent.style.display = 'block';
|
adminContent.style.display = 'block';
|
||||||
@ -91,6 +130,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
adminPassword = passwordVal;
|
adminPassword = passwordVal;
|
||||||
localStorage.setItem('bpb-admin-password', adminPassword);
|
localStorage.setItem('bpb-admin-password', adminPassword);
|
||||||
showAdmin();
|
showAdmin();
|
||||||
|
fetchTagMeta();
|
||||||
fetchPhotos();
|
fetchPhotos();
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
preloadLastTags();
|
preloadLastTags();
|
||||||
@ -103,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
adminPassword = storedPassword;
|
adminPassword = storedPassword;
|
||||||
passwordInput.value = storedPassword;
|
passwordInput.value = storedPassword;
|
||||||
showAdmin();
|
showAdmin();
|
||||||
|
fetchTagMeta();
|
||||||
fetchPhotos();
|
fetchPhotos();
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
preloadLastTags();
|
preloadLastTags();
|
||||||
@ -110,6 +151,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
showLogin();
|
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 ---
|
// --- Tab Switching ---
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
@ -149,15 +214,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
photos.forEach(photo => {
|
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 = `
|
const photoCard = `
|
||||||
<div class="column is-half-tablet is-one-third-desktop is-one-quarter-widescreen">
|
<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">
|
<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">
|
<label class="checkbox is-size-7">
|
||||||
<input type="checkbox" class="select-photo-checkbox" data-photo-id="${photo._id}" ${selectedPhotoIds.has(photo._id) ? 'checked' : ''}>
|
<input type="checkbox" class="select-photo-checkbox" data-photo-id="${photo._id}" ${selectedPhotoIds.has(photo._id) ? 'checked' : ''}>
|
||||||
Select
|
Select
|
||||||
</label>
|
</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>
|
||||||
<div class="card-image">
|
<div class="card-image">
|
||||||
<figure class="image is-3by2">
|
<figure class="image is-3by2">
|
||||||
@ -166,7 +235,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<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">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>
|
</div>
|
||||||
<footer class="card-footer">
|
<footer class="card-footer">
|
||||||
<a href="#" class="card-footer-item edit-button">Edit</a>
|
<a href="#" class="card-footer-item edit-button">Edit</a>
|
||||||
@ -184,7 +253,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (photo) {
|
if (photo) {
|
||||||
editPhotoId.value = photo._id;
|
editPhotoId.value = photo._id;
|
||||||
editCaption.value = photo.caption;
|
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');
|
editModal.classList.add('is-active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -222,9 +292,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
async function handleSaveChanges() {
|
async function handleSaveChanges() {
|
||||||
const photoId = editPhotoId.value;
|
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 = {
|
const updatedPhoto = {
|
||||||
caption: editCaption.value,
|
caption: editCaption.value.trim(),
|
||||||
tags: editTags.value
|
tags: canonicalTags.join(', ')
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -239,6 +318,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
closeEditModal();
|
closeEditModal();
|
||||||
fetchPhotos(); // Refresh the gallery
|
fetchPhotos(); // Refresh the gallery
|
||||||
|
fetchTagMeta();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to save changes.');
|
alert('Failed to save changes.');
|
||||||
}
|
}
|
||||||
@ -295,16 +375,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
openBulkDeleteModal();
|
openBulkDeleteModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTagsString(str) {
|
|
||||||
return str.split(',').map(t => t.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulkApplyEdits() {
|
async function bulkApplyEdits() {
|
||||||
if (!selectedPhotoIds.size) return;
|
if (!selectedPhotoIds.size) return;
|
||||||
const newCaption = bulkCaption.value.trim();
|
const newCaption = bulkCaption.value.trim();
|
||||||
const tagStr = bulkTags.value.trim();
|
const tagStr = bulkTags.value.trim();
|
||||||
const hasCaption = newCaption.length > 0;
|
const hasCaption = newCaption.length > 0;
|
||||||
const hasTags = tagStr.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) {
|
if (!hasCaption && !hasTags) {
|
||||||
alert('Enter a caption and/or tags to apply.');
|
alert('Enter a caption and/or tags to apply.');
|
||||||
return;
|
return;
|
||||||
@ -318,8 +404,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const existingTags = Array.isArray(photo.tags) ? photo.tags : [];
|
const existingTags = Array.isArray(photo.tags) ? photo.tags : [];
|
||||||
let finalTags = existingTags;
|
let finalTags = existingTags;
|
||||||
if (hasTags) {
|
if (hasTags) {
|
||||||
const incoming = parseTagsString(tagStr);
|
const merged = append ? Array.from(new Set([...existingTags, ...incomingCanonical])) : incomingCanonical;
|
||||||
finalTags = append ? Array.from(new Set([...existingTags, ...incoming])) : incoming;
|
if (!merged.length || merged.length > maxTagsAllowed) {
|
||||||
|
throw new Error('Tag limit exceeded or invalid.');
|
||||||
|
}
|
||||||
|
finalTags = merged;
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
caption: hasCaption ? newCaption : photo.caption,
|
caption: hasCaption ? newCaption : photo.caption,
|
||||||
@ -332,6 +421,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
fetchPhotos();
|
fetchPhotos();
|
||||||
|
fetchTagMeta();
|
||||||
clearSelection();
|
clearSelection();
|
||||||
bulkCaption.value = '';
|
bulkCaption.value = '';
|
||||||
bulkTags.value = '';
|
bulkTags.value = '';
|
||||||
@ -407,7 +497,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach(file => formData.append('photos', file));
|
files.forEach(file => formData.append('photos', file));
|
||||||
formData.append('caption', captionInput.value);
|
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();
|
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.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');
|
uploadStatus.classList.add('has-text-success');
|
||||||
localStorage.setItem(LAST_TAGS_KEY, tagsInput.value.trim());
|
localStorage.setItem(LAST_TAGS_KEY, canonicalTags.join(', '));
|
||||||
fetchPhotos();
|
fetchPhotos();
|
||||||
|
fetchTagMeta();
|
||||||
uploadForm.reset();
|
uploadForm.reset();
|
||||||
preloadLastTags();
|
preloadLastTags();
|
||||||
} catch (jsonError) {
|
} catch (jsonError) {
|
||||||
@ -481,62 +584,67 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
function updateTagSuggestions() {
|
function updateTagSuggestions() {
|
||||||
if (!tagSuggestions) return;
|
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 = '';
|
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');
|
const option = document.createElement('option');
|
||||||
option.value = tag;
|
option.value = tag.label;
|
||||||
|
option.dataset.slug = tag.slug;
|
||||||
tagSuggestions.appendChild(option);
|
tagSuggestions.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateQuickTags() {
|
function updateQuickTags() {
|
||||||
if (!quickTagButtons) return;
|
if (!quickTagButtons) return;
|
||||||
const tagCounts = {};
|
const presetButtons = (tagMeta.presets || []).map(preset => `<button type="button" class="button is-light is-rounded" data-preset="${preset.name}">${preset.name} preset</button>`);
|
||||||
photos.forEach(photo => {
|
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 rawTags = Array.isArray(photo.tags) ? photo.tags : String(photo.tags || '').split(',');
|
const otherButtons = (tagMeta.other || []).map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
||||||
rawTags.map(t => t.trim()).filter(Boolean).forEach(tag => {
|
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTagToInput(tag) {
|
function addTagToInput(tag) {
|
||||||
|
const canonical = canonicalizeTag(tag);
|
||||||
|
if (!canonical) return;
|
||||||
const existing = normalizeTagsInput(tagsInput.value);
|
const existing = normalizeTagsInput(tagsInput.value);
|
||||||
if (!existing.includes(tag)) {
|
if (!existing.includes(canonical)) {
|
||||||
existing.push(tag);
|
existing.push(canonical);
|
||||||
tagsInput.value = existing.join(', ');
|
|
||||||
}
|
}
|
||||||
|
tagsInput.value = canonicalToDisplayString(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
function preloadLastTags() {
|
function preloadLastTags() {
|
||||||
const last = localStorage.getItem(LAST_TAGS_KEY);
|
const last = localStorage.getItem(LAST_TAGS_KEY);
|
||||||
if (last && tagsInput && !tagsInput.value) {
|
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) {
|
if (quickTagButtons) {
|
||||||
quickTagButtons.addEventListener('click', (e) => {
|
quickTagButtons.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('button[data-tag]');
|
const presetBtn = e.target.closest('button[data-preset]');
|
||||||
if (!btn) return;
|
const tagBtn = e.target.closest('button[data-tag]');
|
||||||
addTagToInput(btn.dataset.tag);
|
if (presetBtn) {
|
||||||
|
applyPresetTags(presetBtn.dataset.preset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tagBtn) {
|
||||||
|
addTagToInput(tagBtn.dataset.tag);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -89,7 +89,7 @@
|
|||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input has-background-light has-text-black" type="text" id="tagsInput" placeholder="classic, birthday" list="tagSuggestions" required>
|
<input class="input has-background-light has-text-black" type="text" id="tagsInput" placeholder="classic, birthday" list="tagSuggestions" required>
|
||||||
<datalist id="tagSuggestions"></datalist>
|
<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>
|
||||||
<div class="buttons are-small mt-2" id="quickTagButtons" aria-label="Quick tag suggestions">
|
<div class="buttons are-small mt-2" id="quickTagButtons" aria-label="Quick tag suggestions">
|
||||||
</div>
|
</div>
|
||||||
@ -195,9 +195,9 @@
|
|||||||
<!-- Edit Photo Modal -->
|
<!-- Edit Photo Modal -->
|
||||||
<div id="editModal" class="modal">
|
<div id="editModal" class="modal">
|
||||||
<div class="modal-background"></div>
|
<div class="modal-background"></div>
|
||||||
<div class="modal-card">
|
<div class="modal-card has-background-light">
|
||||||
<header class="modal-card-head">
|
<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>
|
<button class="delete" aria-label="close"></button>
|
||||||
</header>
|
</header>
|
||||||
<section class="modal-card-body">
|
<section class="modal-card-body">
|
||||||
@ -205,13 +205,13 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Caption</label>
|
<label class="label">Caption</label>
|
||||||
<div class="control">
|
<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>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Tags</label>
|
<label class="label">Tags</label>
|
||||||
<div class="control">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -241,6 +241,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="admin.js"></script>
|
<script src="/build/admin.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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:
|
services:
|
||||||
bpb-website:
|
bpb-website:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3050:3050"
|
- "3052:3050"
|
||||||
environment:
|
environment:
|
||||||
ADMIN_PASSWORD: your_secure_password # IMPORTANT: Replace with a strong password
|
ADMIN_PASSWORD: your_secure_password # IMPORTANT: Replace with a strong password
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
@ -41,7 +39,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "27017:27017"
|
- "27017:27017"
|
||||||
volumes:
|
volumes:
|
||||||
- mongodb_data:/data/db
|
- ./mongodb_data:/data/db
|
||||||
|
|
||||||
volumes:
|
#volumes:
|
||||||
mongodb_data:
|
# 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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
|
<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="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="32x32" href="../assets/favicon/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.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="manifest" href="../assets/favicon/site.webmanifest">
|
<meta charset="UTF-8">
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="description" content="Beach Party Balloons - Photo Gallery">
|
||||||
<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 - Gallery</title>
|
||||||
<title>Beach Party Balloons</title>
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||||
<link
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
rel="stylesheet"
|
<link href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
|
||||||
href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
<link rel="stylesheet" href="../style.css">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="gallery.css">
|
||||||
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
|
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
|
||||||
<div class="navbar-brand is-size-1">
|
<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">
|
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
|
||||||
|
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
|
<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 id="navbarBasicExample" class="navbar-menu has-text-right">
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
<a class="navbar-item " href="../">
|
<a class="navbar-item" href="/">
|
||||||
Home
|
Home
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
|
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
|
||||||
Shop
|
Shop
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="../about/">
|
<a class="navbar-item" href="/about/">
|
||||||
About Us
|
About Us
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="../faq/">
|
<a class="navbar-item" href="/faq/">
|
||||||
FAQ
|
FAQ
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="../terms/">
|
<a class="navbar-item" href="/terms/">
|
||||||
Terms
|
Terms
|
||||||
</a>
|
</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
|
Gallery
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="../color/">
|
<a class="navbar-item" href="/color/">
|
||||||
Colors
|
Colors
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="../contact/">
|
<a class="navbar-item" href="/contact/">
|
||||||
Contact
|
Contact
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@ -74,59 +67,93 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
|
<div class="update">
|
||||||
|
<div id="message"></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>
|
|
||||||
</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>
|
</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">
|
<footer class="footer has-background-primary-light">
|
||||||
<div class="content has-text-centered">
|
<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>
|
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="../script.js"></script>
|
<script>
|
||||||
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
|
// Force gallery API to the hosted backend to avoid localhost/mixed-content issues.
|
||||||
<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>
|
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>
|
</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:prod": "NODE_ENV=production node server.js",
|
||||||
"start:backend": "npm start --prefix photo-gallery-app/backend",
|
"start:backend": "npm start --prefix photo-gallery-app/backend",
|
||||||
"start:all": "concurrently --names \"web,api\" \"npm start\" \"npm run start: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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@ -20,6 +21,7 @@
|
|||||||
"express": "^5.1.0"
|
"express": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 mongoose = require('mongoose');
|
||||||
|
const { MAX_TAGS } = require('../lib/tagConfig');
|
||||||
|
|
||||||
const Schema = mongoose.Schema;
|
const Schema = mongoose.Schema;
|
||||||
|
|
||||||
@ -21,7 +22,13 @@ const photoSchema = new Schema({
|
|||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
type: [String],
|
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: {
|
hash: {
|
||||||
type: String,
|
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": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
"mongoose": "^8.20.0",
|
"mongoose": "^8.20.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"sharp": "^0.33.3"
|
"sharp": "^0.33.3"
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
"mongoose": "^8.20.0",
|
"mongoose": "^8.20.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"sharp": "^0.33.3"
|
"sharp": "^0.33.3"
|
||||||
|
|||||||
@ -8,9 +8,21 @@ const fsPromises = require('fs').promises;
|
|||||||
const { Blob } = require('buffer');
|
const { Blob } = require('buffer');
|
||||||
const FormData = global.FormData;
|
const FormData = global.FormData;
|
||||||
const sharp = require('sharp');
|
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 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 = {
|
const VARIANTS = {
|
||||||
main: { size: 2000, quality: 82, suffix: '' },
|
main: { size: 2000, quality: 82, suffix: '' },
|
||||||
@ -18,34 +30,27 @@ const VARIANTS = {
|
|||||||
thumb: { size: 640, quality: 76, suffix: '-sm' },
|
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) {
|
async function applyInvisibleWatermark(buffer, payload, filename) {
|
||||||
if (DISABLE_WM) {
|
// Invisible watermarking intentionally disabled.
|
||||||
return buffer;
|
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
|
// Multer setup for file uploads in memory
|
||||||
@ -59,6 +64,41 @@ router.route('/').get((req, res) => {
|
|||||||
.catch(err => res.status(400).json('Error: ' + err));
|
.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
|
// POST new photo(s) with WebP conversion + duplicate hash checks
|
||||||
router.route('/upload').post(upload.array('photos'), async (req, res) => {
|
router.route('/upload').post(upload.array('photos'), async (req, res) => {
|
||||||
const files = (req.files && req.files.length) ? req.files : (req.file ? [req.file] : []);
|
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) {
|
if (!captionText) {
|
||||||
return res.status(400).json({ success: false, error: 'Caption is required.' });
|
return res.status(400).json({ success: false, error: 'Caption is required.' });
|
||||||
}
|
}
|
||||||
const tagList = typeof tags === 'string'
|
const { normalized: tagList } = parseIncomingTags(tags);
|
||||||
? tags.split(',').map(tag => tag.trim()).filter(Boolean)
|
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 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;
|
let existing = null;
|
||||||
try {
|
try {
|
||||||
@ -100,45 +171,88 @@ router.route('/upload').post(upload.array('photos'), async (req, res) => {
|
|||||||
parseInt(hash.substring(4, 6), 16),
|
parseInt(hash.substring(4, 6), 16),
|
||||||
];
|
];
|
||||||
|
|
||||||
const mainOverlay = Buffer.from(`
|
const diagonalOverlay = Buffer.from(`
|
||||||
<svg width="800" height="200" xmlns="http://www.w3.org/2000/svg">
|
<svg width="2400" height="2400" viewBox="0 0 2400 2400" xmlns="http://www.w3.org/2000/svg">
|
||||||
<style>
|
<defs>
|
||||||
text { font-family: Arial, sans-serif; }
|
<linearGradient id="diagGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
</style>
|
<stop offset="0%" stop-color="rgba(255,255,255,0.22)" />
|
||||||
<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>
|
<stop offset="50%" stop-color="rgba(255,255,255,0.33)" />
|
||||||
</svg>
|
<stop offset="100%" stop-color="rgba(255,255,255,0.22)" />
|
||||||
`);
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
const cornerOverlay = Buffer.from(`
|
<g transform="translate(1200 1200) rotate(-32)">
|
||||||
<svg width="400" height="120" xmlns="http://www.w3.org/2000/svg">
|
<text x="0" y="-80" text-anchor="middle" dominant-baseline="middle"
|
||||||
<style>
|
fill="url(#diagGrad)" stroke="rgba(0,0,0,0.16)" stroke-width="8"
|
||||||
text { font-family: Arial, sans-serif; }
|
font-family="Arial Black, Arial, sans-serif" font-size="260" letter-spacing="6" textLength="1800" lengthAdjust="spacingAndGlyphs">
|
||||||
</style>
|
BEACH PARTY
|
||||||
<text x="12" y="70" fill="rgba(0,0,0,0.22)" font-size="36">beachpartyballoons.com</text>
|
<tspan x="0" dy="280">BALLOONS</tspan>
|
||||||
<rect x="2" y="2" width="1" height="1" fill="rgba(${hiddenColor[0]}, ${hiddenColor[1]}, ${hiddenColor[2]}, 0.01)" />
|
</text>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
let buffer;
|
let buffer;
|
||||||
try {
|
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()
|
.rotate()
|
||||||
.resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true })
|
.resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true })
|
||||||
.toColorspace('srgb')
|
.toColorspace('srgb');
|
||||||
.toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 })
|
|
||||||
|
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([
|
.composite([
|
||||||
{ input: mainOverlay, gravity: 'southeast' },
|
{ input: overlayBuffer, gravity: 'center' },
|
||||||
{ input: cornerOverlay, gravity: 'northwest' },
|
|
||||||
])
|
])
|
||||||
|
.toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error processing image with sharp:', 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 {
|
try {
|
||||||
const payload = `BPB:${hash}`;
|
const stampedBuffer = buffer;
|
||||||
const stampedBuffer = await applyInvisibleWatermark(buffer, payload, filename);
|
|
||||||
await fsPromises.writeFile(filepath, stampedBuffer);
|
await fsPromises.writeFile(filepath, stampedBuffer);
|
||||||
// Create responsive variants from the stamped image to keep overlays consistent
|
// Create responsive variants from the stamped image to keep overlays consistent
|
||||||
const variants = {};
|
const variants = {};
|
||||||
@ -245,8 +359,23 @@ router.route('/:id').delete((req, res) => {
|
|||||||
router.route('/update/:id').post((req, res) => {
|
router.route('/update/:id').post((req, res) => {
|
||||||
Photo.findById(req.params.id)
|
Photo.findById(req.params.id)
|
||||||
.then(photo => {
|
.then(photo => {
|
||||||
photo.caption = req.body.caption;
|
const incomingCaption = req.body.caption;
|
||||||
photo.tags = req.body.tags.split(',').map(tag => tag.trim());
|
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()
|
photo.save()
|
||||||
.then(() => res.json('Photo updated!'))
|
.then(() => res.json('Photo updated!'))
|
||||||
|
|||||||
@ -7,7 +7,9 @@ const port = process.env.PORT || 5000;
|
|||||||
|
|
||||||
const whitelist = [
|
const whitelist = [
|
||||||
'https://preview.beachpartyballoons.com',
|
'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://localhost:3050',
|
||||||
'http://127.0.0.1:3050',
|
'http://127.0.0.1:3050',
|
||||||
'http://localhost:8080' // Common local dev port
|
'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);
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
// --- Static Files ---
|
// --- 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)
|
// 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, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server listening at http://localhost:${port}`);
|
console.log(`Server listening at http://localhost:${port}`);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user