Add 'main-site/' from commit '5cefb4d1618bc54ae0e86830421a8c911900302c'

git-subtree-dir: main-site
git-subtree-mainline: 4d1daa39101c0a85ca6d916f1c31139faf39632a
git-subtree-split: 5cefb4d1618bc54ae0e86830421a8c911900302c
This commit is contained in:
chris 2026-04-13 19:22:17 -04:00
commit 746868d720
2070 changed files with 326765 additions and 0 deletions

18
main-site/.dockerignore Normal file
View File

@ -0,0 +1,18 @@
# Docker Build Ignore List
## Dependencies & Local State
node_modules
npm-debug.log*
mongodb_data/
*.swp
## Git & Docker
.git
.gitignore
Dockerfile
.dockerignore
## Development
.vscode/
README.md
.env

1
main-site/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
.webp filter=lfs diff=lfs merge=lfs -text

45
main-site/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# Dependencies
/node_modules
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs and editors
.idea
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.sublime-workspace
# Misc
.DS_Store
Thumbs.db
/assets/pics/gallery/centerpiece/
/assets/pics/gallery/sculpture/
/assets/pics/gallery/classic/
/assets/pics/gallery/organic/
gallery/centerpiece/index.html
gallery/organic/index.html
gallery/classic/index.html
gallery/sculpture/index.html
# Build artifacts and backups
public/build/
backups/
# Local database files
mongodb_data/
photo-gallery-app/backend/uploads/

5
main-site/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"eliutdev.bulma-css-class-completion"
]
}

4
main-site/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"liveServer.settings.port": 5517,
"continue.telemetryEnabled": false
}

23
main-site/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# Use an official Node.js runtime as a parent image
FROM node:18-alpine
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install any needed packages
RUN npm install
# Bundle app source
COPY . .
# Build optimized frontend assets
RUN npm run build
# Make port 3050 available to the world outside this container
EXPOSE 3050
# Define the command to run the app
CMD [ "node", "server.js" ]

9
main-site/LICENSE Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 chris
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

2
main-site/README.md Normal file
View File

@ -0,0 +1,2 @@
# bpb-website

184
main-site/about/index.html Normal file
View File

@ -0,0 +1,184 @@
<!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">
<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" />
<style>
h1 {
margin-bottom: 2.1rem;
}
.section-title {
font-size: 3rem;
margin-bottom: 2rem;
color: #2c3e50;
}
.content-container {
max-width: 850px;
margin: 2rem auto;
padding: 1rem;
}
.article {
margin-bottom: 3rem;
}
</style>
</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 is-tab is-active" href="#">
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" 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="container is-justify-content-center padding">
<img src="../assets/pics/classic/ceiling.jpg" alt="balloon ceiling fill" >
</div>
<div class="content-container">
<h1 class="section-title has-text-centered">Beach Party Balloons: A celebration of joy and creativity</h1>
<div class="article">
<p class="has-text-centered">Since 2016, Beach Party Balloons has been steadfastly dedicated to providing exceptional family-friendly entertainment in Milford, Connecticut.
Our journey began with the establishment of our sister company, Painted You, which laid the foundation for crafting unforgettable experiences through creative party services.</p>
</div>
<div class="article">
<p class="has-text-centered">In 2016, Melissa spearheaded the opening of Beach Party Balloons at Walnut Beach, bringing her unwavering passion for joy and magic to the community. Fast forward to today, we have relocated to a new venue at 554 Boston Post Road, continuing our legacy with enhanced innovation and growth. In 2025, Chris and Alyssa, both long time employees, purchased the business from Melissa. </p>
</div>
<div class="article">
<p class="has-text-centered">
Our expertise lies in crafting memorable experiences through a diverse array of services: expert balloon decorations that add vibrant life to your events, custom designs tailored to reflect each client's unique style, interactive balloon art that engages and captivates. We are committed to reflecting the essence of our clients by creating cohesive atmospheres that align with their needs. </p>
</div>
<div class="article has-text-centered">
<p class="has-text-centered">
At Beach Party Balloons, we prioritize excellence in service and relationship-building with our local community.
Our creative solutions exceed expectations, ensuring your events are truly unforgettable. Let us help you turn every detail into a magical memory.</p>
</div>
</div>
</body>
<!-- <div class="article">
<p class="has-text-centered">At Beach Party Balloons, we specialize in a variety of party services designed to make every event memorable. Our expert team offers balloon decorations, custom designs, and interactive balloon art that brings your special moments to life.
</div>
<div class="article">
<p class="has-text-centered">
Committed to excellence, we ensure each event reflects the unique personality of our clients by creating a cohesive atmosphere with our wide range of styles and services. Our goal is to make every experience as magical as possible for you.
</p>
</div>
<div class="article">
<p class="has-text-centered">
We take pride in delivering outstanding service, maintaining strong relationships with local communities, and providing creative solutions that exceed our clients' expectations. At Beach Party Balloons, we believe every detail matters—so let us help you create unforgettable memories.
</p>
</div> -->
</div>
<!-- <div style="margin: auto;">
<script type="text/javascript" src="https://form.jotform.com/jsform/250083932725053"></script>
</div> -->
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
</a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
</a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
</a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
<i class="fa-brands fa-bluesky is-size-2"></i>
</a>
</div>
<h7>Copyright &copy; <span id="year"></span> Beach Party Balloons</h7>
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<script defer 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>

20
main-site/admin/admin.css Normal file
View File

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

784
main-site/admin/admin.js Normal file
View File

@ -0,0 +1,784 @@
document.addEventListener('DOMContentLoaded', () => {
// General Admin Elements
const loginModal = document.getElementById('login-modal');
const loginForm = document.getElementById('loginForm');
const passwordInput = document.getElementById('passwordInput');
const loginButton = document.getElementById('loginButton');
const adminContent = document.getElementById('admin-content');
// Tabs
const tabs = document.querySelectorAll('.tabs li');
const tabContents = document.querySelectorAll('.tab-content');
// Photo Gallery Elements
const uploadForm = document.getElementById('uploadForm');
const uploadButton = document.getElementById('uploadButton');
const uploadStatus = document.getElementById('uploadStatus');
const uploadProgress = document.getElementById('uploadProgress');
const tagsInput = document.getElementById('tagsInput');
const tagSuggestions = document.getElementById('tagSuggestions');
const quickTagButtons = document.getElementById('quickTagButtons');
const captionInput = document.getElementById('captionInput');
const captionToTagsButton = document.getElementById('captionToTags');
const manageGallery = document.getElementById('manage-gallery');
const manageSearchInput = document.getElementById('manageSearchInput');
const editModal = document.getElementById('editModal');
const editPhotoId = document.getElementById('editPhotoId');
const editCaption = document.getElementById('editCaption');
const editTags = document.getElementById('editTags');
const saveChanges = document.getElementById('saveChanges');
const modalCloseButton = editModal.querySelector('.delete');
const modalCancelButton = editModal.querySelector('.modal-card-foot .button:not(.is-success)');
// Bulk Delete Modal
const bulkDeleteModal = document.getElementById('bulkDeleteModal');
const confirmBulkDeleteBtn = document.getElementById('confirmBulkDelete');
const cancelBulkDeleteBtn = document.getElementById('cancelBulkDelete');
const bulkDeleteModalCloseBtn = bulkDeleteModal.querySelector('.delete');
const bulkDeleteCountEl = document.getElementById('bulk-delete-count');
const bulkCaption = document.getElementById('bulkCaption');
const bulkTags = document.getElementById('bulkTags');
const bulkAppendTags = document.getElementById('bulkAppendTags');
const applyBulkEdits = document.getElementById('applyBulkEdits');
const bulkDelete = document.getElementById('bulkDelete');
const selectAllPhotosBtn = document.getElementById('selectAllPhotos');
const clearSelectionBtn = document.getElementById('clearSelection');
const selectedCountEl = document.getElementById('selectedCount');
const bulkPanel = document.getElementById('bulkPanel');
let selectedPhotoIds = new Set();
let photos = [];
// Store Status Elements
const messageInput = document.getElementById('scrollingMessageInput');
const isClosedCheckbox = document.getElementById('isClosedCheckbox');
const closedMessageInput = document.getElementById('closedMessageInput');
const updateButton = document.getElementById('updateButton');
const responseDiv = document.getElementById('response');
const backendUrl = (() => {
const { protocol, hostname } = window.location;
const productionHosts = new Set([
'beachpartyballoons.com',
'www.beachpartyballoons.com',
'preview.beachpartyballoons.com',
'photobackend.beachpartyballoons.com'
]);
const isProduction = productionHosts.has(hostname);
if (!isProduction) {
return 'http://localhost:5001';
}
const backendHostname = 'photobackend.beachpartyballoons.com';
return `${protocol}//${backendHostname}`; // No explicit port needed as it's on 443
})();
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: [],
tagCounts: {}
};
let adminPassword = '';
const storedPassword = localStorage.getItem('bpb-admin-password');
const getAdminPassword = () => adminPassword || localStorage.getItem('bpb-admin-password') || '';
const slugifyTag = (tag) => String(tag || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').trim();
const canonicalizeTag = (tag) => {
const slug = slugifyTag(tag);
const mapped = tagMeta.aliases?.[slug] || slug;
return mapped;
};
const resolveSearchTag = (value) => {
const slug = slugifyTag(value);
if (!slug) return '';
return tagMeta.aliases?.[slug] || slug;
};
const displayTag = (slug) => {
if (!slug) return '';
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};
const canonicalToDisplayString = (canonicalArr) => canonicalArr.map(displayTag).join(', ');
const normalizeTagsInput = (value) => {
const raw = String(value || '')
.split(',')
.map(t => t.trim())
.filter(Boolean);
const seen = new Set();
const canonical = [];
raw.forEach(tag => {
const mapped = canonicalizeTag(tag);
if (mapped && !seen.has(mapped) && canonical.length < (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
seen.add(mapped);
canonical.push(mapped);
}
});
return canonical;
};
const showAdmin = () => {
adminContent.style.display = 'block';
loginModal.classList.remove('is-active');
};
const showLogin = (message) => {
if (message) {
passwordInput.value = '';
passwordInput.placeholder = message;
}
loginModal.classList.add('is-active');
};
const handleUnauthorized = () => {
localStorage.removeItem('bpb-admin-password');
adminPassword = '';
showLogin('Enter password to continue');
};
// --- Password Protection ---
function login(event) {
event.preventDefault();
const passwordVal = passwordInput.value.trim();
if (!passwordVal) return;
adminPassword = passwordVal;
localStorage.setItem('bpb-admin-password', adminPassword);
showAdmin();
fetchTagMeta();
fetchPhotos();
fetchStatus();
preloadLastTags();
}
loginForm.addEventListener('submit', login);
loginButton.addEventListener('click', login);
if (storedPassword) {
adminPassword = storedPassword;
passwordInput.value = storedPassword;
showAdmin();
fetchTagMeta();
fetchPhotos();
fetchStatus();
preloadLastTags();
} else {
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: [],
tagCounts: {},
...data
};
updateTagSuggestions();
updateQuickTags();
preloadLastTags();
} catch (error) {
console.error('Error fetching tag metadata:', error);
}
}
// --- Tab Switching ---
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(item => item.classList.remove('is-active'));
tab.classList.add('is-active');
const target = document.getElementById(tab.dataset.tab);
tabContents.forEach(content => content.style.display = 'none');
target.style.display = 'block';
});
});
// --- Photo Management ---
async function fetchPhotos() {
try {
const response = await fetch(`${backendUrl}/photos`);
if (response.status === 401) {
handleUnauthorized();
return;
}
photos = await response.json();
const validIds = new Set(photos.map(p => p._id));
selectedPhotoIds = new Set(Array.from(selectedPhotoIds).filter(id => validIds.has(id)));
updateTagSuggestions();
updateQuickTags();
renderManageGallery();
updateBulkUI();
} catch (error) {
console.error('Error fetching photos:', error);
}
}
function renderManageGallery() {
manageGallery.innerHTML = '';
const query = String(manageSearchInput?.value || '').trim().toLowerCase();
const normalizedQuery = resolveSearchTag(query);
const filtered = query
? photos.filter(photo => {
const caption = String(photo.caption || '').toLowerCase();
const tags = Array.isArray(photo.tags) ? photo.tags : [];
const tagText = tags.map(displayTag).join(' ').toLowerCase();
return caption.includes(query)
|| tags.some(tag => String(tag || '').toLowerCase().includes(query))
|| (normalizedQuery && tags.some(tag => String(tag || '').toLowerCase() === normalizedQuery))
|| tagText.includes(query);
})
: photos;
if (!filtered.length) {
const message = query
? 'No photos match your search.'
: 'No photos yet. Upload a photo to get started.';
manageGallery.innerHTML = `<div class="column"><p class="has-text-grey">${message}</p></div>`;
return;
}
filtered.forEach(photo => {
const tagCount = Array.isArray(photo.tags) ? photo.tags.length : 0;
const tagStatusClass = tagCount <= 2 ? 'is-warning' : 'is-light';
const lowTagClass = tagCount <= 2 ? 'low-tag-card' : '';
const readableTags = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag);
const photoCard = `
<div class="column is-half-tablet is-one-third-desktop is-one-quarter-widescreen">
<div class="card has-background-light ${lowTagClass}" data-photo-id="${photo._id}">
<div class="card-content py-2 px-3 is-flex is-align-items-center is-justify-content-space-between">
<label class="checkbox is-size-7">
<input type="checkbox" class="select-photo-checkbox" data-photo-id="${photo._id}" ${selectedPhotoIds.has(photo._id) ? 'checked' : ''}>
Select
</label>
<span class="tag ${tagStatusClass}">${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}</span>
</div>
<div class="card-image">
<figure class="image is-3by2">
<img src="${backendUrl}/${photo.path}" alt="${photo.caption}">
</figure>
</div>
<div class="card-content">
<p class="has-text-dark"><strong class="has-text-dark">Caption:</strong> ${photo.caption}</p>
<p class="has-text-dark"><strong class="has-text-dark">Tags:</strong> ${readableTags.join(', ')}</p>
</div>
<footer class="card-footer">
<a href="#" class="card-footer-item edit-button">Edit</a>
<a href="#" class="card-footer-item delete-button">Delete</a>
</footer>
</div>
</div>
`;
manageGallery.innerHTML += photoCard;
});
}
function openEditModal(photoId) {
const photo = photos.find(p => p._id === photoId);
if (photo) {
editPhotoId.value = photo._id;
editCaption.value = photo.caption;
const readable = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag).join(', ');
editTags.value = readable;
editModal.classList.add('is-active');
}
}
function closeEditModal() {
editModal.classList.remove('is-active');
}
function updateBulkUI() {
const count = selectedPhotoIds.size;
selectedCountEl.textContent = `${count} selected`;
const disabled = count === 0;
applyBulkEdits.disabled = disabled;
bulkDelete.disabled = disabled;
if (bulkPanel) {
bulkPanel.style.display = count ? 'block' : 'none';
}
}
function toggleSelectAll() {
if (selectedPhotoIds.size === photos.length) {
selectedPhotoIds.clear();
} else {
photos.forEach(p => selectedPhotoIds.add(p._id));
}
renderManageGallery();
updateBulkUI();
}
function clearSelection() {
selectedPhotoIds.clear();
renderManageGallery();
updateBulkUI();
}
async function handleSaveChanges() {
const photoId = editPhotoId.value;
const canonicalTags = normalizeTagsInput(editTags.value);
if (!canonicalTags.length) {
alert('Please include at least one valid tag.');
return;
}
if (canonicalTags.length > (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
alert(`Keep tags under ${tagMeta.maxTags || DEFAULT_MAX_TAGS}.`);
return;
}
const updatedPhoto = {
caption: editCaption.value.trim(),
tags: canonicalTags.join(', ')
};
try {
const response = await fetch(`${backendUrl}/photos/update/${photoId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedPhoto)
});
if (response.ok) {
closeEditModal();
fetchPhotos(); // Refresh the gallery
fetchTagMeta();
} else {
alert('Failed to save changes.');
}
} catch (error) {
console.error('Error saving changes:', error);
alert('An error occurred while saving. Please try again.');
}
}
async function deletePhoto(id) {
if (confirm('Are you sure you want to delete this photo?')) {
try {
await fetch(`${backendUrl}/photos/${id}`, { method: 'DELETE' });
fetchPhotos();
} catch (error) {
console.error('Error deleting photo:', error);
}
}
}
function openBulkDeleteModal() {
const count = selectedPhotoIds.size;
if (count === 0) return;
bulkDeleteCountEl.textContent = `You are about to delete ${count} photo(s).`;
bulkDeleteModal.classList.add('is-active');
}
function closeBulkDeleteModal() {
bulkDeleteModal.classList.remove('is-active');
}
async function handleConfirmBulkDelete() {
const ids = Array.from(selectedPhotoIds);
if (ids.length === 0) {
closeBulkDeleteModal();
return;
}
confirmBulkDeleteBtn.classList.add('is-loading');
try {
await Promise.all(ids.map(id => fetch(`${backendUrl}/photos/${id}`, { method: 'DELETE' })));
clearSelection();
fetchPhotos();
closeBulkDeleteModal();
} catch (error) {
console.error('Error deleting photos:', error);
alert('Some deletions may have failed. Please refresh and check.');
} finally {
confirmBulkDeleteBtn.classList.remove('is-loading');
}
}
function bulkDeletePhotos() {
openBulkDeleteModal();
}
async function bulkApplyEdits() {
if (!selectedPhotoIds.size) return;
const newCaption = bulkCaption.value.trim();
const tagStr = bulkTags.value.trim();
const hasCaption = newCaption.length > 0;
const hasTags = tagStr.length > 0;
const maxTagsAllowed = tagMeta.maxTags || DEFAULT_MAX_TAGS;
const incomingCanonical = hasTags ? normalizeTagsInput(tagStr) : [];
if (hasTags && !incomingCanonical.length) {
alert('Bulk tags must include at least one valid option from the list.');
return;
}
if (incomingCanonical.length > maxTagsAllowed) {
alert(`Please keep bulk tags under ${maxTagsAllowed}.`);
return;
}
if (!hasCaption && !hasTags) {
alert('Enter a caption and/or tags to apply.');
return;
}
const ids = Array.from(selectedPhotoIds);
const append = bulkAppendTags.checked;
try {
await Promise.all(ids.map(async (id) => {
const photo = photos.find(p => p._id === id);
if (!photo) return;
const existingTags = Array.isArray(photo.tags) ? photo.tags : [];
let finalTags = existingTags;
if (hasTags) {
const merged = append ? Array.from(new Set([...existingTags, ...incomingCanonical])) : incomingCanonical;
if (!merged.length || merged.length > maxTagsAllowed) {
throw new Error('Tag limit exceeded or invalid.');
}
finalTags = merged;
}
const payload = {
caption: hasCaption ? newCaption : photo.caption,
tags: (hasTags ? finalTags : existingTags).join(', ')
};
await fetch(`${backendUrl}/photos/update/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}));
fetchPhotos();
fetchTagMeta();
clearSelection();
bulkCaption.value = '';
bulkTags.value = '';
bulkAppendTags.checked = false;
} catch (error) {
console.error('Error applying bulk edits:', error);
alert('Some edits may have failed. Please refresh and verify.');
}
}
manageGallery.addEventListener('click', (e) => {
if (e.target.classList.contains('edit-button')) {
e.preventDefault();
const photoId = e.target.closest('.card').dataset.photoId;
openEditModal(photoId);
}
if (e.target.classList.contains('delete-button')) {
e.preventDefault();
const photoId = e.target.closest('.card').dataset.photoId;
deletePhoto(photoId);
}
if (e.target.classList.contains('select-photo-checkbox')) {
const id = e.target.dataset.photoId;
if (e.target.checked) {
selectedPhotoIds.add(id);
} else {
selectedPhotoIds.delete(id);
}
updateBulkUI();
}
});
manageGallery.addEventListener('change', (e) => {
if (e.target.classList.contains('select-photo-checkbox')) {
const id = e.target.dataset.photoId;
if (e.target.checked) {
selectedPhotoIds.add(id);
} else {
selectedPhotoIds.delete(id);
}
updateBulkUI();
}
});
if (manageSearchInput) {
manageSearchInput.addEventListener('input', () => renderManageGallery());
}
selectAllPhotosBtn.addEventListener('click', (e) => {
e.preventDefault();
toggleSelectAll();
});
clearSelectionBtn.addEventListener('click', (e) => {
e.preventDefault();
clearSelection();
});
uploadForm.addEventListener('submit', (e) => {
e.preventDefault();
const photoInput = document.getElementById('photoInput');
const captionInput = document.getElementById('captionInput');
uploadStatus.textContent = '';
uploadStatus.className = 'help mt-3';
uploadProgress.style.display = 'none';
uploadProgress.value = 0;
const files = photoInput.files ? Array.from(photoInput.files) : [];
if (!files.length) {
uploadStatus.textContent = 'Please choose an image before uploading.';
uploadStatus.classList.add('has-text-danger');
return;
}
const formData = new FormData();
files.forEach(file => formData.append('photos', file));
formData.append('caption', captionInput.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();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
uploadProgress.value = percentComplete;
}
});
xhr.addEventListener('load', () => {
uploadButton.classList.remove('is-loading');
uploadProgress.style.display = 'none';
if (xhr.status === 401) {
handleUnauthorized();
uploadStatus.textContent = 'Session expired. Please log in again.';
uploadStatus.classList.add('has-text-danger');
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
const errorText = xhr.responseText;
uploadStatus.textContent = `Upload failed: ${errorText || xhr.statusText}`;
uploadStatus.classList.add('has-text-danger');
return;
}
try {
const result = JSON.parse(xhr.responseText);
const uploadedCount = Array.isArray(result?.uploaded) ? result.uploaded.length : 0;
const skippedCount = Array.isArray(result?.skipped) ? result.skipped.length : 0;
if (result?.success === false) {
uploadStatus.textContent = result?.error || 'Upload failed.';
uploadStatus.classList.add('has-text-danger');
return;
}
uploadStatus.textContent = result?.message || `Uploaded ${uploadedCount || files.length} photo${(uploadedCount || files.length) === 1 ? '' : 's'} successfully!` + (skippedCount ? ` Skipped ${skippedCount} duplicate${skippedCount === 1 ? '' : 's'}.` : '');
uploadStatus.classList.add('has-text-success');
localStorage.setItem(LAST_TAGS_KEY, canonicalTags.join(', '));
fetchPhotos();
fetchTagMeta();
uploadForm.reset();
preloadLastTags();
} catch (jsonError) {
console.error('Error parsing upload response:', jsonError);
uploadStatus.textContent = 'Received an invalid response from the server.';
uploadStatus.classList.add('has-text-danger');
}
});
xhr.addEventListener('error', () => {
uploadButton.classList.remove('is-loading');
uploadProgress.style.display = 'none';
uploadStatus.textContent = 'An unexpected error occurred during upload.';
uploadStatus.classList.add('has-text-danger');
});
xhr.addEventListener('abort', () => {
uploadButton.classList.remove('is-loading');
uploadProgress.style.display = 'none';
uploadStatus.textContent = 'Upload cancelled.';
uploadStatus.classList.add('has-text-grey');
});
xhr.open('POST', `${backendUrl}/photos/upload`);
uploadButton.classList.add('is-loading');
uploadProgress.style.display = 'block';
xhr.send(formData);
});
const getTagCount = (slug) => Number(tagMeta.tagCounts?.[slug] || 0);
const sortTagsByCount = (a, b) => {
const countDiff = getTagCount(b.slug) - getTagCount(a.slug);
if (countDiff !== 0) return countDiff;
return (a.label || '').localeCompare(b.label || '');
};
const sortSlugsByCount = (a, b) => {
const countDiff = getTagCount(b) - getTagCount(a);
if (countDiff !== 0) return countDiff;
return displayTag(a).localeCompare(displayTag(b));
};
function updateTagSuggestions() {
if (!tagSuggestions) return;
tagSuggestions.innerHTML = '';
const mainSorted = [...(tagMeta.main || [])].sort(sortTagsByCount);
const otherSorted = [...(tagMeta.other || [])].sort(sortTagsByCount);
const existingSorted = [...(tagMeta.existing || [])].sort(sortSlugsByCount);
const suggestions = [
...mainSorted,
...otherSorted,
...existingSorted.map(slug => ({ slug, label: displayTag(slug) }))
];
const seen = new Set();
suggestions.forEach(tag => {
if (!tag || !tag.slug || seen.has(tag.slug)) return;
seen.add(tag.slug);
const option = document.createElement('option');
option.value = tag.label;
option.dataset.slug = tag.slug;
tagSuggestions.appendChild(option);
});
}
function updateQuickTags() {
if (!quickTagButtons) return;
const presetButtons = (tagMeta.presets || []).map(preset => `<button type="button" class="button is-light is-rounded" data-preset="${preset.name}">${preset.name} preset</button>`);
const mainButtons = [...(tagMeta.main || [])]
.sort(sortTagsByCount)
.map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
const otherButtons = [...(tagMeta.other || [])]
.sort(sortTagsByCount)
.map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
}
function addTagToInput(tag) {
const canonical = canonicalizeTag(tag);
if (!canonical) return;
const existing = normalizeTagsInput(tagsInput.value);
if (!existing.includes(canonical)) {
existing.push(canonical);
}
tagsInput.value = canonicalToDisplayString(existing);
}
function preloadLastTags() {
const last = localStorage.getItem(LAST_TAGS_KEY);
if (last && tagsInput && !tagsInput.value) {
const canonical = normalizeTagsInput(last);
tagsInput.value = canonicalToDisplayString(canonical);
}
}
function applyPresetTags(presetName) {
const preset = (tagMeta.presets || []).find(p => p.name === presetName);
if (!preset) return;
const canonical = normalizeTagsInput((preset.tags || []).join(','));
tagsInput.value = canonicalToDisplayString(canonical);
}
if (quickTagButtons) {
quickTagButtons.addEventListener('click', (e) => {
const presetBtn = e.target.closest('button[data-preset]');
const tagBtn = e.target.closest('button[data-tag]');
if (presetBtn) {
applyPresetTags(presetBtn.dataset.preset);
return;
}
if (tagBtn) {
addTagToInput(tagBtn.dataset.tag);
}
});
}
if (captionToTagsButton) {
captionToTagsButton.addEventListener('click', () => {
const caption = captionInput.value || '';
const words = (caption.match(/[A-Za-z0-9]+/g) || [])
.map(w => w.toLowerCase())
.filter(w => w.length > 2);
const unique = Array.from(new Set(words));
unique.forEach(addTagToInput);
uploadStatus.textContent = unique.length ? 'Tags pulled from caption.' : 'No words found to convert to tags.';
uploadStatus.className = 'help mt-3 ' + (unique.length ? 'has-text-success' : 'has-text-grey');
});
}
updateBulkUI();
saveChanges.addEventListener('click', handleSaveChanges);
modalCloseButton.addEventListener('click', closeEditModal);
modalCancelButton.addEventListener('click', closeEditModal);
applyBulkEdits.addEventListener('click', bulkApplyEdits);
bulkDelete.addEventListener('click', bulkDeletePhotos);
confirmBulkDeleteBtn.addEventListener('click', handleConfirmBulkDelete);
cancelBulkDeleteBtn.addEventListener('click', closeBulkDeleteModal);
bulkDeleteModalCloseBtn.addEventListener('click', closeBulkDeleteModal);
// --- Store Status Management ---
async function fetchStatus() {
try {
const response = await fetch('../update.json');
const data = await response.json();
const currentStatus = data[0];
messageInput.value = currentStatus.message;
isClosedCheckbox.checked = currentStatus.isClosed;
closedMessageInput.value = currentStatus.closedMessage;
} catch (error) {
console.error('Error fetching current status:', error);
responseDiv.textContent = 'Error fetching current status.';
responseDiv.classList.add('is-danger');
}
}
updateButton.addEventListener('click', async () => {
const data = [
{
message: messageInput.value,
isClosed: isClosedCheckbox.checked,
closedMessage: closedMessageInput.value
}
];
try {
const response = await fetch('/api/update-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ data })
});
const result = await response.json();
if (result.success) {
responseDiv.textContent = 'Status updated successfully!';
responseDiv.classList.remove('is-danger');
responseDiv.classList.add('is-success');
} else {
responseDiv.textContent = `Error: ${result.message}`;
responseDiv.classList.remove('is-success');
responseDiv.classList.add('is-danger');
}
} catch (error) {
console.error('Error updating status:', error);
responseDiv.textContent = 'An unexpected error occurred.';
responseDiv.classList.remove('is-success');
responseDiv.classList.add('is-danger');
}
});
});

252
main-site/admin/index.html Normal file
View File

@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.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" />
<link rel="stylesheet" href="admin.css">
</head>
<body class="admin-page">
<div id="login-modal" class="modal is-active">
<!-- <div class="modal-background"></div> -->
<div class="modal-content">
<div class="box admin-login-card has-background-light">
<div class="has-text-centered mb-4">
<p class="tag is-info is-light">Beach Party Balloons</p>
<h1 class="title is-4 mt-2">Admin Access</h1>
<p class="subtitle is-6 has-text-grey">Sign in to manage gallery photos and store status.</p>
</div>
<form id="loginForm">
<div class="field">
<p class="control has-icons-left">
<input class="input" id="passwordInput" type="password" placeholder="Password" autocomplete="current-password" required>
<span class="icon is-small is-left">
<i class="fas fa-lock"></i>
</span>
</p>
</div>
<div class="field">
<p class="control">
<button class="button is-info is-fullwidth" id="loginButton">Log in</button>
</p>
</div>
</form>
</div>
</div>
</div>
<div id="admin-content" style="display: none;">
<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>
</div>
</nav>
<div class="admin-hero">
<div class="container">
<p class="tag is-light admin-kicker">Control Center</p>
<h1 class="title is-2 has-text-white">Admin Panel</h1>
<p class="subtitle is-5 has-text-white-bis">Upload new work, curate your gallery, and update the store status in one place.</p>
</div>
</div>
<div class="container padding">
<div class="tabs is-boxed">
<ul>
<li class="is-active" data-tab="photo-tab"><a>Photo Gallery</a></li>
<li data-tab="status-tab"><a>Store Status</a></li>
</ul>
</div>
<div id="photo-tab" class="tab-content">
<div class="columns is-variable is-5 photo-columns">
<div class="column upload-column">
<div class="box admin-card">
<p class="is-size-5 has-text-weight-semibold mb-3">Upload new photo</p>
<p class="is-size-7 has-text-grey mb-4">Add a caption and tags to keep the gallery searchable.</p>
<form id="uploadForm" novalidate>
<div class="field">
<label class="label">Photo</label>
<div class="control">
<input class="input has-background-light has-text-black" type="file" id="photoInput" accept="image/*,.heic,.heif" multiple required>
<p class="help is-size-7 has-text-grey">Select one or many images (including HEIC/HEIF); each will be converted to WebP automatically.</p>
</div>
</div>
<div class="field">
<label class="label has-text-dark">Caption</label>
<div class="control">
<input class="input has-background-light has-text-dark" type="text" id="captionInput" placeholder="A beautiful balloon arrangement." required>
<p class="help is-size-7 mt-1"><button id="captionToTags" type="button" class="button is-ghost is-small p-0">Use caption words as tags</button></p>
</div>
</div>
<div class="field">
<label class="label has-text-dark">Tags (comma-separated)</label>
<div class="control">
<input class="input has-background-light has-text-black" type="text" id="tagsInput" placeholder="classic, birthday" list="tagSuggestions" required>
<datalist id="tagSuggestions"></datalist>
<p class="help is-size-7 has-text-grey">Pick from the curated list or presets; up to 8 tags per photo.</p>
</div>
<div class="buttons are-small mt-2" id="quickTagButtons" aria-label="Quick tag suggestions">
</div>
</div>
<div class="control">
<button class="button is-primary is-fullwidth" id="uploadButton">Upload</button>
</div>
<progress id="uploadProgress" class="progress is-primary mt-3" value="0" max="100" style="display: none;"></progress>
<p id="uploadStatus" class="help mt-3"></p>
</form>
</div>
</div>
<div class="column manage-column">
<div class="box admin-card">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-3">
<div>
<p class="is-size-5 has-text-weight-semibold mb-1">Manage existing photos</p>
<p class="is-size-7 has-text-grey">Edit captions/tags or delete images you no longer want visible.</p>
</div>
<span class="tag is-info is-light"><i class="fas fa-images mr-2"></i>Gallery</span>
</div>
<div class="field mb-4">
<label class="label is-size-7 has-text-dark">Search by caption or tag</label>
<div class="control">
<input class="input is-small has-background-light has-text-dark" type="text" id="manageSearchInput" placeholder="e.g. classic, wedding, arch">
</div>
</div>
<div class="box has-background-light mb-4" id="bulkPanel" style="display: none;">
<div class="columns is-vcentered is-mobile">
<div class="column is-narrow">
<button class="button is-small has-text-dark has-background-primary" id="selectAllPhotos">Select all</button>
</div>
<div class="column">
<p class="is-size-7 has-text-dark" id="selectedCount">0 selected</p>
</div>
<div class="column is-narrow">
<button class="button is-text is-small has-text-dark" id="clearSelection">Clear</button>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-full-mobile is-half-tablet">
<label class="label is-size-7 has-text-dark">New caption (optional)</label>
<input class="input is-small has-background-white has-text-dark" type="text" id="bulkCaption" placeholder="Leave blank to keep captions">
</div>
<div class="column is-full-mobile is-half-tablet">
<label class="label is-size-7 has-text-dark">Tags (comma-separated, optional)</label>
<input class="input is-small has-background-white has-text-dark" type="text" id="bulkTags" placeholder="e.g. arch, pastel">
<label class="checkbox is-size-7 mt-1 has-text-dark">
<input type="checkbox" id="bulkAppendTags"> Append to existing tags (unchecked = replace)
</label>
</div>
<div class="column is-full-mobile">
<div class="buttons are-small">
<button class="button is-primary" id="applyBulkEdits" disabled>Apply to selected</button>
<button class="button is-danger is-light" id="bulkDelete" disabled>Delete selected</button>
</div>
</div>
</div>
</div>
<div id="manage-gallery" class="columns is-multiline is-variable is-4 admin-gallery-grid">
<!-- Existing photos will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<div id="status-tab" class="tab-content" style="display: none;">
<div class="box admin-card">
<div class="is-flex is-align-items-center is-justify-content-space-between mb-3">
<div>
<p class="is-size-5 has-text-weight-semibold mb-1">Store status & scrolling message</p>
<p class="is-size-7 has-text-grey">Update the homepage banner and closed message.</p>
</div>
<span class="tag is-warning is-light"><i class="fas fa-bell mr-2"></i>Status</span>
</div>
<div class="field">
<label class="label has-text-dark">Scrolling Message</label>
<div class="control">
<input class="input has-background-light has-text-dark" id="scrollingMessageInput" type="text" placeholder="Enter message for the scrolling bar">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="isClosedCheckbox">
Is the store closed?
</label>
</div>
<div class="field">
<label class="label has-text-dark">Closed Message</label>
<div class="control">
<input class="input has-background-light has-text-dark" id="closedMessageInput" type="text" placeholder="Enter message to display when closed">
</div>
</div>
<div class="control">
<button id="updateButton" class="button is-primary">Update</button>
</div>
<div id="response" class="notification is-light mt-4"></div>
</div>
</div>
</div>
</div>
<!-- Edit Photo Modal -->
<div id="editModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card has-background-light">
<header class="modal-card-head">
<p class="modal-card-title has-text-dark has-background-light">Edit Photo</p>
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<input type="hidden" id="editPhotoId">
<div class="field">
<label class="label">Caption</label>
<div class="control">
<input class="input has-background-light has-text-black" type="text" id="editCaption">
</div>
</div>
<div class="field">
<label class="label">Tags</label>
<div class="control">
<input class="input has-background-light has-text-black" type="text" id="editTags">
</div>
</div>
</section>
<footer class="modal-card-foot">
<button id="saveChanges" class="button is-success">Save changes</button>
<button class="button">Cancel</button>
</footer>
</div>
</div>
<!-- Bulk Delete Confirmation Modal -->
<div id="bulkDeleteModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Confirm Deletion</p>
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<p>Are you sure you want to delete the selected photos? This action cannot be undone.</p>
<p id="bulk-delete-count" class="has-text-weight-bold"></p>
</section>
<footer class="modal-card-foot">
<button id="confirmBulkDelete" class="button is-danger">Delete</button>
<button class="button" id="cancelBulkDelete">Cancel</button>
</footer>
</div>
</div>
<script src="admin.js" defer></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1 @@
Ceiling Fill

View File

@ -0,0 +1 @@
30ft Areopole Arch

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1 @@
Cocktail Arrangements

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

View File

@ -0,0 +1 @@
20' Classic Arch

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -0,0 +1 @@
Classic Columns

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,10 @@
#!/bin/bash
if [ $# -ne 1 ]; then
echo "Usage: $0 <image_file>"
exit 1
fi
IMAGE_FILE=$1
convert "$IMAGE_FILE" -rotate 90 "$IMAGE_FILE"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1 @@
Ceiling Fill

View File

@ -0,0 +1 @@
30ft Areopole Arch

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -0,0 +1 @@
20' Classic Arch

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -0,0 +1 @@
Column with Custom Vinyl and a Bouquet

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

View File

@ -0,0 +1 @@
Classic 25' Arch

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1 @@
Ceiling Fill

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1 @@
Classic Columns

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

View File

@ -0,0 +1,2 @@
Classic 20' Arch with SIgnature Arrangement

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

View File

@ -0,0 +1 @@
Classic Columns

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Some files were not shown because too many files have changed in this diff Show More