From 9ca29e13de15204030a13c673e5d4482e5ee6b6a Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 8 Dec 2025 13:26:36 -0500 Subject: [PATCH] chore: update gallery tooling and docker setup --- .dockerignore | 14 +- colors.js | 67 +++ gallery.css | 425 ------------------ gallery.html | 175 -------- gallery.js | 256 ----------- photo-gallery-app/backend/Dockerfile | 3 + photo-gallery-app/backend/routes/photos.js | 3 + .../backend/scripts/cleanup_uploads.js | 104 +++++ .../backend/scripts/dedupe_uploads.js | 102 +++++ .../backend/scripts/find_duplicates.js | 54 +++ .../backend/scripts/relink_variants.js | 117 +++++ .../backend/scripts/reseed_uploads.js | 65 +++ photo-gallery-app/watermarker/Dockerfile | 4 +- .../watermarker/requirements.txt | 1 + 14 files changed, 531 insertions(+), 859 deletions(-) create mode 100644 colors.js delete mode 100644 gallery.css delete mode 100644 gallery.html delete mode 100644 gallery.js create mode 100644 photo-gallery-app/backend/scripts/cleanup_uploads.js create mode 100644 photo-gallery-app/backend/scripts/dedupe_uploads.js create mode 100644 photo-gallery-app/backend/scripts/find_duplicates.js create mode 100644 photo-gallery-app/backend/scripts/relink_variants.js create mode 100644 photo-gallery-app/backend/scripts/reseed_uploads.js 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

- - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-
- - - - - - - - - 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 = ` - - - `; - 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