diff --git a/.dockerignore b/.dockerignore
index d75e453..41d2377 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,8 +1,18 @@
-# Ignore dependencies, git, and local development files
+# Docker Build Ignore List
+
+## Dependencies & Local State
node_modules
+npm-debug.log*
+mongodb_data/
+*.swp
+
+## Git & Docker
.git
.gitignore
-.env
Dockerfile
.dockerignore
+
+## Development
+.vscode/
README.md
+.env
diff --git a/colors.js b/colors.js
new file mode 100644
index 0000000..c7f8401
--- /dev/null
+++ b/colors.js
@@ -0,0 +1,67 @@
+const PALETTE = [
+ { family: "Whites & Neutrals", colors: [
+ { name:"White",hex:"#ffffff"},{name:"Retro White",hex:"#e8e3d9"},{name:"Sand",hex:"#e1d8c6"},
+ { name:"Cameo",hex:"#e9ccc8"},{name:"Grey",hex:"#ced3d4"},{name:"Stone",hex:"#989689"},
+ { name:"Fog",hex:"#6b9098"},{name:"Smoke",hex:"#75777b"},{name:"Black",hex:"#0b0d0f"}
+ ]},
+ { family: "Pinks & Reds", colors: [
+ {name:"Blush",hex:"#fbd6c0"},{name:"Light Pink",hex:"#fcccda"},{name:"Melon",hex:"#fac4bc"},
+ {name:"Rose Pink",hex:"#d984a3"},{name:"Fuchsia",hex:"#eb4799"},{name:"Aloha",hex:"#e45c56"},
+ {name:"Red",hex:"#ef2a2f"},{name:"Pastel Magenta",hex:"#B72E6C"},{name:"Coral",hex:"#bd4b3b"},
+ {name:"Wild Berry",hex:"#79384c"},{name:"Maroon",hex:"#80011f"}
+ ]},
+ { family: "Oranges & Browns & Yellows", colors: [
+ {name:"Pastel Yellow",hex:"#fcfd96"},{name:"Yellow",hex:"#f5e812"},{name:"Goldenrod",hex:"#f7b615"},
+ {name:"Orange",hex:"#ef6b24"},{name:"Coffee",hex:"#957461"},{name:"Burnt Orange",hex:"#9d4223"},
+ {name:"Blended Brown",hex:"#c9aea0"}
+ ]},
+ { family: "Greens", colors: [
+ {name:"Eucalyptus",hex:"#a3bba3"},{name:"Pastel Green",hex:"#acdba7"},{name:"Lime Green",hex:"#8fc73e"},
+ {name:"Seafoam",hex:"#479a87"},{name:"Grass Green",hex:"#28b35e"},{name:"Empowermint",hex:"#779786"},
+ {name:"Forest Green",hex:"#218b21"},{name:"Willow",hex:"#4a715c"}
+ ]},
+ { family: "Blues", colors: [
+ {name:"Sky Blue",hex:"#87ceec"},{name:"Sea Glass",hex:"#80a4bc"},{name:"Caribbean Blue",hex:"#0bbbb6"},
+ {name:"Medium Blue",hex:"#1b89e8"},{name:"Blue Slate",hex:"#327295"},{name:"Tropical Teal",hex:"#0d868f"},
+ {name:"Royal Blue",hex:"#005eb7"},{name:"Dark Blue",hex:"#26408e"},{name:"Navy",hex:"#262266"}
+ ]},
+ { family: "Purples", colors: [
+ {name:"Pastel Dusk",hex:"#d7c4c8"},{name:"Lilac",hex:"#c69edb"},{name:"Canyon Rose",hex:"#ca93b3"},
+ {name:"Rosewood",hex:"#ad7271"},{name:"Lavender",hex:"#866c92"},{name:"Orchid",hex:"#a42487"},
+ {name:"Violet",hex:"#812a8c"}
+ ]},
+
+ // === Pearl & Metallic: image-backed swatches ===
+ { family: "Pearl and Matallic Colors", colors: [
+ { name:"Pearl White", hex:"#F8F8F8", metallic:true, pearlType:"white", image:"images/pearl-white.webp" },
+ { name:"Classic Silver", hex:"#F4C2C2", metallic:true, pearlType:"silver", image:"images/classic-silver.webp" },
+ { name:"Pearl Pink", hex:"#F4C2C2", metallic:true, pearlType:"pink", image:"images/pearl-pink.webp" },
+ { name:"Pearl Peach", hex:"#F4C2C2", metallic:true, pearlType:"pink", image:"images/pearl-peach.webp" },
+ { name:"Classic Rose Gold", hex:"#F4C2C2", metallic:true, pearlType:"pink", image:"images/metalic-rosegold.webp" },
+ { name:"Pearl Lilac", hex:"#C8A2C8", metallic:true, pearlType:"lilac", image:"images/pearl-lilac.webp" },
+ { name:"Pearl Light Blue", hex:"#87CEEB", metallic:true, pearlType:"blue", image:"images/pearl-lightblue.webp" },
+ { name:"Pearl Periwinkle", hex:"#F4C2C2", metallic:true, pearlType:"blue", image:"images/pearl-periwinkle.webp" },
+ { name:"Pearl Fuchsia", hex:"#FD49AB", metallic:true, pearlType:"fuchsia", image:"images/pearl-fuchsia.webp" },
+ { name:"Pearl Violet", hex:"#8F00FF", metallic:true, pearlType:"violet", image:"images/pearl-violet.webp" },
+ { name:"Pearl Sapphire", hex:"#0F52BA", metallic:true, pearlType:"sapphire", image:"images/pearl-sapphire.webp" },
+ { name:"Pearl Midnight Blue",hex:"#191970", metallic:true, pearlType:"midnight-blue", image:"images/pearl-midnightblue.webp" },
+ { name:"Classic Gold", hex:"#E32636", metallic:true, pearlType:"gold", image:"images/classic-gold.webp" }
+ ]},
+
+ // === Chrome: image-backed swatches ===
+ { family: "Chrome Colors", colors: [
+ { name:"Chrome Rose Gold", hex:"#FFBF00", metallic:true, chromeType:"rosegold", image:"images/chrome-rosegold.webp" },
+ { name:"Chrome Pink", hex:"#FFBF00", metallic:true, chromeType:"rosegold", image:"images/chrome-pink.webp" },
+ { name:"Chrome Purple", hex:"#DFFF00", metallic:true, chromeType:"purple", image:"images/chrome-purple.webp" },
+ { name:"Chrome Champagne", hex:"#FF1DCE", metallic:true, chromeType:"champagne", image:"images/chrome-champagne.webp" },
+ { name:"Chrome Truffle", hex:"#FF1DCE", metallic:true, chromeType:"champagne", image:"images/chrome-truffle.webp" },
+ { name:"Chrome Silver", hex:"#a8a9a4", metallic:true, chromeType:"silver", image:"images/chrome-silver.webp" },
+ { name:"Chrome Space Grey",hex:"#a8a9a4", metallic:true, chromeType:"spacegrey", image:"images/chrome-spacegrey.webp" },
+ { name:"Chrome Gold", hex:"#a18b67", metallic:true, chromeType:"gold", image:"images/chrome-gold.webp" },
+ { name:"Chrome Green", hex:"#457066", metallic:true, chromeType:"green", image:"images/chrome-green.webp" },
+ { name:"Chrome Blue", hex:"#2d576f", metallic:true, chromeType:"blue", image:"images/chrome-blue.webp" }
+ ]}
+ ];
+
+ window.CLASSIC_COLORS = ['#D92E3A', '#FFFFFF', '#0055A4', '#40E0D0'];
+ window.PALETTE = window.PALETTE || (typeof PALETTE !== "undefined" ? PALETTE : []);
diff --git a/gallery.css b/gallery.css
deleted file mode 100644
index 025b3a6..0000000
--- a/gallery.css
+++ /dev/null
@@ -1,425 +0,0 @@
-.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%;
- }
-}
\ No newline at end of file
diff --git a/gallery.html b/gallery.html
deleted file mode 100644
index 08da605..0000000
--- a/gallery.html
+++ /dev/null
@@ -1,175 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Beach Party Balloons - Gallery
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Gallery
-
-
-
-
-
Examples: arch, garland, birthday
-
-
-
-
-
-
-
-
-
No photos found matching your criteria.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/gallery.js b/gallery.js
deleted file mode 100644
index 8ff06ec..0000000
--- a/gallery.js
+++ /dev/null
@@ -1,256 +0,0 @@
-document.addEventListener('DOMContentLoaded', () => {
- const gallery = document.getElementById('photo-gallery');
- const searchInput = document.getElementById('searchInput');
- const filterBtns = 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 = [];
-
- const normalizeTags = (tags) => {
- if (Array.isArray(tags)) return tags;
- if (typeof tags === 'string') {
- return tags.split(',').map(tag => tag.trim()).filter(Boolean);
- }
- return [];
- };
-
- async function fetchPhotos() {
- try {
- const response = await fetch(`${window.location.protocol}//${window.location.hostname}:5000/photos`);
- if (!response.ok) {
- throw new Error(`Fetch failed with status ${response.status}`);
- }
- const data = await response.json();
- photos = Array.isArray(data) && data.length ? data : fallbackPhotos;
- } catch (error) {
- console.error('Error fetching photos:', error);
- photos = fallbackPhotos;
- }
- 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 = `${countText} photos shown • ${totalText} total`;
- return;
- }
- resultCountEl.innerHTML = count === total
- ? `${countText} photos on display`
- : `${countText} shown • ${totalText} total`;
- }
-
- 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')) return p;
- return `http://localhost:5000/${p}`;
- };
- 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 photoCard = document.createElement('div');
- photoCard.className = 'gallery-item';
- const tagBadges = photoTags.map(tag => `${tag}`).join('');
- photoCard.innerHTML = `
-
-

-
-
-
-
${photo.caption}
-
${photoTags.join(', ')}
-
${tagBadges}
-
-
- `;
- 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');
- });
- });
- }
-
- 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 => tag.toLowerCase().includes(searchTerm));
- return captionMatch || tagMatch;
- });
- renderFlatGallery(filteredPhotos);
- } else {
- renderFlatGallery(photos);
- // Reactivate 'All' button if search is cleared
- document.querySelector('.filter-btn[data-tag="all"]').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 => `${t}`).join('');
- }
- 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);
-
- 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');
- });
- });
-
-
- 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();
-});
diff --git a/photo-gallery-app/backend/Dockerfile b/photo-gallery-app/backend/Dockerfile
index d361c55..bca04c7 100644
--- a/photo-gallery-app/backend/Dockerfile
+++ b/photo-gallery-app/backend/Dockerfile
@@ -8,7 +8,10 @@ WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y \
imagemagick \
ghostscript \
+ libheif1 \
libheif-dev \
+ libde265-0 \
+ libvips-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy package.json and package-lock.json to the working directory
diff --git a/photo-gallery-app/backend/routes/photos.js b/photo-gallery-app/backend/routes/photos.js
index ddcbece..7ed0037 100644
--- a/photo-gallery-app/backend/routes/photos.js
+++ b/photo-gallery-app/backend/routes/photos.js
@@ -334,6 +334,9 @@ router.route('/:id').get((req, res) => {
// DELETE a photo by ID
router.route('/:id').delete((req, res) => {
+ console.log('DELETE request received for photo ID:', req.params.id);
+ console.log('Request headers:', req.headers);
+ console.log('Request IP:', req.ip);
Photo.findByIdAndDelete(req.params.id)
.then(photo => {
if (photo) {
diff --git a/photo-gallery-app/backend/scripts/cleanup_uploads.js b/photo-gallery-app/backend/scripts/cleanup_uploads.js
new file mode 100644
index 0000000..baedf2e
--- /dev/null
+++ b/photo-gallery-app/backend/scripts/cleanup_uploads.js
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+/**
+ * Cleanup helper:
+ * - Removes Photo docs whose main/variant paths are not WebP.
+ * - Deletes non-WebP files in uploads.
+ * - Optionally deletes orphaned WebP files (not referenced by any Photo) when DELETE_ORPHANS=1.
+ *
+ * Dry-run by default. Set APPLY=1 to make changes.
+ */
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+const Photo = require('../models/photo');
+
+const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/photogallery';
+const APPLY = process.env.APPLY === '1';
+const DELETE_ORPHANS = process.env.DELETE_ORPHANS === '1';
+const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
+
+const isWebp = (p) => /\.webp$/i.test(p || '');
+
+async function main() {
+ await mongoose.connect(MONGO_URI);
+ console.log(`Connected to Mongo: ${MONGO_URI}`);
+
+ // Find docs with non-webp main or variants
+ const nonWebpDocs = await Photo.find({
+ $or: [
+ { path: { $not: /\.webp$/i } },
+ { 'variants.medium': { $exists: true, $not: /\.webp$/i } },
+ { 'variants.thumb': { $exists: true, $not: /\.webp$/i } },
+ ]
+ }).select('_id path variants filename');
+
+ // Build referenced file set from remaining docs
+ const allDocs = await Photo.find().select('path variants');
+ const referenced = new Set();
+ for (const doc of allDocs) {
+ if (doc.path) referenced.add(doc.path);
+ if (doc.variants?.medium) referenced.add(doc.variants.medium);
+ if (doc.variants?.thumb) referenced.add(doc.variants.thumb);
+ }
+
+ // Scan uploads directory
+ const filesOnDisk = [];
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir)) {
+ const full = path.join(dir, entry);
+ const stat = fs.statSync(full);
+ if (stat.isDirectory()) walk(full);
+ else filesOnDisk.push(full);
+ }
+ };
+ walk(UPLOAD_DIR);
+
+ const nonWebpFiles = filesOnDisk.filter(f => !isWebp(f));
+ const orphans = filesOnDisk
+ .filter(f => isWebp(f))
+ .filter(f => !referenced.has(path.relative(path.join(__dirname, '..'), f).replace(/\\/g, '/')));
+
+ console.log(`Found ${nonWebpDocs.length} photo docs with non-WebP paths/variants.`);
+ console.log(`Found ${nonWebpFiles.length} non-WebP files on disk.`);
+ console.log(`Found ${orphans.length} orphaned WebP files${DELETE_ORPHANS ? ' (will delete if APPLY=1)' : ''}.`);
+
+ if (!APPLY) {
+ console.log('Dry run (set APPLY=1 to apply changes).');
+ await mongoose.disconnect();
+ return;
+ }
+
+ if (nonWebpDocs.length) {
+ const ids = nonWebpDocs.map(d => d._id);
+ await Photo.deleteMany({ _id: { $in: ids } });
+ console.log(`Deleted ${ids.length} photo docs with non-WebP paths/variants.`);
+ }
+
+ for (const file of nonWebpFiles) {
+ try {
+ fs.unlinkSync(file);
+ } catch (err) {
+ console.error('Failed to delete', file, err.message);
+ }
+ }
+ console.log(`Deleted ${nonWebpFiles.length} non-WebP files.`);
+
+ if (DELETE_ORPHANS && orphans.length) {
+ for (const file of orphans) {
+ try {
+ fs.unlinkSync(file);
+ } catch (err) {
+ console.error('Failed to delete orphan', file, err.message);
+ }
+ }
+ console.log(`Deleted ${orphans.length} orphaned WebP files.`);
+ }
+
+ await mongoose.disconnect();
+ console.log('Cleanup complete.');
+}
+
+main().catch(err => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/photo-gallery-app/backend/scripts/dedupe_uploads.js b/photo-gallery-app/backend/scripts/dedupe_uploads.js
new file mode 100644
index 0000000..e724035
--- /dev/null
+++ b/photo-gallery-app/backend/scripts/dedupe_uploads.js
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+/**
+ * Deduplicate files in uploads/ by content hash.
+ * Keeps the first file per hash, removes later duplicates, and deletes matching Photo docs.
+ * Dry run by default; set APPLY=1 to actually delete.
+ */
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { pipeline } = require('stream/promises');
+const mongoose = require('mongoose');
+const Photo = require('../models/photo');
+
+const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/photogallery';
+const APPLY = process.env.APPLY === '1';
+const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
+
+async function hashFile(filePath) {
+ const hash = crypto.createHash('sha256');
+ const stream = fs.createReadStream(filePath);
+ await pipeline(stream, hash);
+ return hash.digest('hex');
+}
+
+function walkFiles(dir) {
+ const results = [];
+ for (const entry of fs.readdirSync(dir)) {
+ const full = path.join(dir, entry);
+ const stat = fs.statSync(full);
+ if (stat.isDirectory()) {
+ results.push(...walkFiles(full));
+ } else {
+ results.push(full);
+ }
+ }
+ return results;
+}
+
+async function main() {
+ const files = walkFiles(UPLOAD_DIR).filter(f => f.toLowerCase().endsWith('.webp'));
+ console.log(`Scanning ${files.length} webp files...`);
+
+ const hashMap = new Map();
+ const dupes = [];
+
+ for (const file of files) {
+ const h = await hashFile(file);
+ if (!hashMap.has(h)) {
+ hashMap.set(h, file);
+ } else {
+ dupes.push({ hash: h, keep: hashMap.get(h), remove: file });
+ }
+ }
+
+ if (!dupes.length) {
+ console.log('No duplicate content found.');
+ return;
+ }
+
+ console.log(`Found ${dupes.length} duplicate files (by content).`);
+ dupes.slice(0, 10).forEach(d =>
+ console.log(`Hash ${d.hash.slice(0, 12)} keep=${path.basename(d.keep)} remove=${path.basename(d.remove)}`)
+ );
+
+ if (!APPLY) {
+ console.log('Dry run. Set APPLY=1 to delete duplicates and matching Photo docs.');
+ return;
+ }
+
+ await mongoose.connect(MONGO_URI);
+ console.log(`Connected to Mongo: ${MONGO_URI}`);
+
+ let removedFiles = 0;
+ let removedDocs = 0;
+ for (const d of dupes) {
+ try {
+ fs.unlinkSync(d.remove);
+ removedFiles++;
+ } catch (err) {
+ console.error('Failed to delete file', d.remove, err.message);
+ }
+ const relPath = path.relative(path.join(__dirname, '..'), d.remove).replace(/\\/g, '/');
+ const filename = path.basename(d.remove);
+ const res = await Photo.deleteMany({
+ $or: [
+ { path: relPath },
+ { filename }
+ ]
+ });
+ removedDocs += res.deletedCount || 0;
+ }
+
+ console.log(`Deleted ${removedFiles} duplicate files.`);
+ console.log(`Deleted ${removedDocs} Photo docs matching removed files.`);
+ await mongoose.disconnect();
+ console.log('Done.');
+}
+
+main().catch(err => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/photo-gallery-app/backend/scripts/find_duplicates.js b/photo-gallery-app/backend/scripts/find_duplicates.js
new file mode 100644
index 0000000..56acf27
--- /dev/null
+++ b/photo-gallery-app/backend/scripts/find_duplicates.js
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+/**
+ * Report duplicate photos by filename/path and oversized base-name clusters.
+ * Run: node scripts/find_duplicates.js
+ */
+const mongoose = require('mongoose');
+const Photo = require('../models/photo');
+
+const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/photogallery';
+
+async function main() {
+ await mongoose.connect(MONGO_URI);
+
+ const byFilename = await Photo.aggregate([
+ { $group: { _id: '$filename', ids: { $addToSet: '$_id' }, count: { $sum: 1 } } },
+ { $match: { count: { $gt: 1 } } },
+ { $project: { _id: 0, filename: '$_id', count: 1, ids: 1 } }
+ ]);
+
+ const byPath = await Photo.aggregate([
+ { $group: { _id: '$path', ids: { $addToSet: '$_id' }, count: { $sum: 1 } } },
+ { $match: { count: { $gt: 1 } } },
+ { $project: { _id: 0, path: '$_id', count: 1, ids: 1 } }
+ ]);
+
+ const cluster = await Photo.aggregate([
+ {
+ $project: {
+ base: { $regexFind: { input: '$filename', regex: /^(.*?)(-md|-sm)?\.webp$/ } },
+ filename: 1
+ }
+ },
+ {
+ $group: {
+ _id: '$base.captures.0',
+ files: { $addToSet: '$filename' },
+ ids: { $addToSet: '$_id' },
+ count: { $sum: 1 }
+ }
+ },
+ { $match: { count: { $gt: 3 } } },
+ { $project: { _id: 0, base: '$_id', count: 1, files: 1, ids: 1 } }
+ ]);
+
+ console.log('Duplicates by filename:', JSON.stringify(byFilename, null, 2));
+ console.log('Duplicates by path:', JSON.stringify(byPath, null, 2));
+ console.log('Clusters (>3 variants):', JSON.stringify(cluster, null, 2));
+ await mongoose.disconnect();
+}
+
+main().catch(err => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/photo-gallery-app/backend/scripts/relink_variants.js b/photo-gallery-app/backend/scripts/relink_variants.js
new file mode 100644
index 0000000..e182155
--- /dev/null
+++ b/photo-gallery-app/backend/scripts/relink_variants.js
@@ -0,0 +1,117 @@
+#!/usr/bin/env node
+/**
+ * Relink variants for existing Photo docs based on files in uploads/.
+ * For each Photo doc, derive baseName from its filename and fill in:
+ * - path -> uploads/-main (or first available in group)
+ * - variants.medium / variants.thumb -> matching files if present
+ *
+ * Dry run by default; set APPLY=1 to save.
+ */
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+const Photo = require('../models/photo');
+
+const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/photogallery';
+const APPLY = process.env.APPLY === '1';
+const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
+const VARIANT_KEYS = { '': 'main', '-md': 'medium', '-sm': 'thumb' };
+
+function parseFile(file) {
+ const ext = path.extname(file);
+ if (ext.toLowerCase() !== '.webp') return null;
+ const base = path.basename(file, ext);
+ const match = base.match(/^(.*?)(-md|-sm)?$/);
+ if (!match) return null;
+ return { baseName: match[1], variantKey: VARIANT_KEYS[match[2] || ''], filename: file };
+}
+
+function scanUploads() {
+ const groups = {};
+ const files = fs.readdirSync(UPLOAD_DIR).filter(f => f.toLowerCase().endsWith('.webp'));
+ for (const file of files) {
+ const parsed = parseFile(file);
+ if (!parsed) continue;
+ const { baseName, variantKey, filename } = parsed;
+ if (!groups[baseName]) groups[baseName] = {};
+ groups[baseName][variantKey] = filename;
+ }
+ return groups;
+}
+
+async function main() {
+ await mongoose.connect(MONGO_URI);
+ console.log(`Connected to Mongo: ${MONGO_URI}`);
+ const groups = scanUploads();
+ let updated = 0;
+ const missingGroups = [];
+
+ const docs = await Photo.find({});
+ // Index docs by filename/path and baseName to improve matching
+ const byFilename = new Map();
+ const byPath = new Map();
+ const byBase = new Map(); // baseName -> array of docs
+ for (const doc of docs) {
+ const fname = doc.filename || path.basename(doc.path || '');
+ byFilename.set(fname, doc);
+ if (doc.path) {
+ const rel = doc.path.replace(/\\/g, '/');
+ byPath.set(rel.startsWith('uploads/') ? rel.slice(''.length) : rel, doc);
+ byPath.set(rel, doc);
+ }
+ const parsed = parseFile(fname);
+ if (parsed) {
+ const arr = byBase.get(parsed.baseName) || [];
+ arr.push(doc);
+ byBase.set(parsed.baseName, arr);
+ }
+ }
+
+ for (const [baseName, variants] of Object.entries(groups)) {
+ const mainFile = variants.main || Object.values(variants)[0];
+ const relMain = path.join('uploads', mainFile).replace(/\\/g, '/');
+ let doc =
+ byFilename.get(mainFile) ||
+ byPath.get(relMain) ||
+ (() => {
+ const arr = byBase.get(baseName) || [];
+ return arr.length === 1 ? arr[0] : null;
+ })();
+
+ if (!doc) {
+ missingGroups.push(baseName);
+ continue;
+ }
+
+ const newPath = relMain;
+ const newVariants = {
+ medium: variants.medium ? path.join('uploads', variants.medium).replace(/\\/g, '/') : undefined,
+ thumb: variants.thumb ? path.join('uploads', variants.thumb).replace(/\\/g, '/') : undefined,
+ };
+
+ const pathChanged = doc.path !== newPath;
+ const medChanged = (doc.variants?.medium || undefined) !== newVariants.medium;
+ const thumbChanged = (doc.variants?.thumb || undefined) !== newVariants.thumb;
+
+ if (pathChanged || medChanged || thumbChanged) {
+ if (APPLY) {
+ doc.path = newPath;
+ doc.variants = newVariants;
+ await doc.save();
+ }
+ updated++;
+ }
+ }
+
+ console.log(`Relinked variants for ${updated} photos.${APPLY ? '' : ' (dry run, no writes)'}`);
+ if (missingGroups.length) {
+ const sample = missingGroups.slice(0, 10).join(', ');
+ console.log(`Skipped ${missingGroups.length} upload groups with no matching Photo doc. Examples: ${sample}`);
+ }
+ await mongoose.disconnect();
+}
+
+main().catch(err => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/photo-gallery-app/backend/scripts/reseed_uploads.js b/photo-gallery-app/backend/scripts/reseed_uploads.js
new file mode 100644
index 0000000..02d1605
--- /dev/null
+++ b/photo-gallery-app/backend/scripts/reseed_uploads.js
@@ -0,0 +1,65 @@
+#!/usr/bin/env node
+/**
+ * Re-seed Photo documents from existing uploads when Mongo data is lost.
+ * - One doc per webp file on disk.
+ * - Captions default to filename without suffix.
+ * - Tags default to ['uncategorized'] unless a prefix is present.
+ * - Variants NOT linked; each file is its own doc.
+ *
+ * Usage: node scripts/reseed_uploads.js
+ * Make sure MONGO_URI is set (or uses default).
+ */
+
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+const Photo = require('../models/photo');
+
+const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/photogallery';
+const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
+const VARIANT_SUFFIXES = {
+ '': 'main',
+ '-md': 'medium',
+ '-sm': 'thumb'
+};
+
+function parseFilename(file) {
+ const ext = path.extname(file);
+ const base = path.basename(file, ext);
+ const matched = base.match(/(.+?)(-md|-sm)?$/);
+ if (!matched) return { baseName: base, variant: 'main', ext };
+ const baseName = matched[1];
+ const variant = VARIANT_SUFFIXES[matched[2] || ''] || 'main';
+ return { baseName, variant, ext };
+}
+
+async function main() {
+ await mongoose.connect(MONGO_URI);
+ console.log('Connected to Mongo:', MONGO_URI);
+
+ const files = fs.readdirSync(UPLOAD_DIR).filter(f => f.toLowerCase().endsWith('.webp'));
+ let created = 0;
+ for (const file of files) {
+ const existing = await Photo.findOne({ filename: file });
+ if (existing) continue;
+ const baseName = path.basename(file, path.extname(file));
+ const caption = baseName.replace(/[-_]+/g, ' ');
+ const tag = baseName.split('-')[1] || 'uncategorized';
+ const doc = new Photo({
+ filename: file,
+ path: path.join('uploads', file),
+ caption,
+ tags: [tag],
+ });
+ await doc.save();
+ created++;
+ }
+
+ console.log(`Seed complete. Created ${created} photo documents.`);
+ await mongoose.disconnect();
+}
+
+main().catch(err => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/photo-gallery-app/watermarker/Dockerfile b/photo-gallery-app/watermarker/Dockerfile
index 25b3ab6..413a392 100644
--- a/photo-gallery-app/watermarker/Dockerfile
+++ b/photo-gallery-app/watermarker/Dockerfile
@@ -19,4 +19,6 @@ COPY app.py .
EXPOSE 8000
-CMD ["python", "app.py"]
+# Command to run the app in production with Gunicorn
+CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:8000", "app:app"]
+
diff --git a/photo-gallery-app/watermarker/requirements.txt b/photo-gallery-app/watermarker/requirements.txt
index 6a327c9..9c6fd55 100644
--- a/photo-gallery-app/watermarker/requirements.txt
+++ b/photo-gallery-app/watermarker/requirements.txt
@@ -3,3 +3,4 @@ imwatermark==0.0.2
Pillow==10.4.0
numpy==1.26.4
opencv-python-headless==4.10.0.84
+gunicorn==22.0.0