chore: update gallery tooling and docker setup
This commit is contained in:
parent
c340cd2eaf
commit
9ca29e13de
@ -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
|
||||
|
||||
67
colors.js
Normal file
67
colors.js
Normal file
@ -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 : []);
|
||||
425
gallery.css
425
gallery.css
@ -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%;
|
||||
}
|
||||
}
|
||||
175
gallery.html
175
gallery.html
@ -1,175 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
|
||||
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/favicon/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="assets/favicon/site.webmanifest">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Beach Party Balloons - Photo Gallery">
|
||||
<title>Beach Party Balloons - Gallery</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<link rel="stylesheet" href="gallery.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand is-size-1">
|
||||
<a class="navbar-item" href="/">
|
||||
<img style="background-color: white;" src="assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
|
||||
</a>
|
||||
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="navbarBasicExample" class="navbar-menu has-text-right">
|
||||
<div class="navbar-end">
|
||||
<a class="navbar-item" href="/">
|
||||
Home
|
||||
</a>
|
||||
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
|
||||
Shop
|
||||
</a>
|
||||
<a class="navbar-item" href="about/">
|
||||
About Us
|
||||
</a>
|
||||
<a class="navbar-item" href="faq/">
|
||||
FAQ
|
||||
</a>
|
||||
<a class="navbar-item" href="terms/">
|
||||
Terms
|
||||
</a>
|
||||
<a class="navbar-item is-tab is-active" href="gallery.html">
|
||||
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>
|
||||
<div class="update">
|
||||
<div id="message"></div>
|
||||
</div>
|
||||
|
||||
<main class="section gallery-wrap">
|
||||
<div class="container">
|
||||
<h1 class="title is-2 has-text-centered mb-4 has-text-dark">Gallery</h1>
|
||||
<div class="has-text-centered">
|
||||
<a class="skip-to-gallery has-background-light has-text-dark" href="#photo-gallery">
|
||||
<i class="fa-solid fa-images"></i>
|
||||
Jump to photos
|
||||
</a>
|
||||
</div>
|
||||
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
|
||||
<div class="box search-box">
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap gap-sm">
|
||||
<div>
|
||||
<p class="is-size-5 has-text-weight-semibold">Search</p>
|
||||
</div>
|
||||
<div class="field has-icons-left search-field">
|
||||
<p class="control has-icons-left is-expanded">
|
||||
<input class="input search-input has-background-light has-text-dark" type="text" id="searchInput" placeholder="Search by tags or description" aria-label="Search gallery by tags or description">
|
||||
<span class="icon is-left">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help is-size-7 has-text-grey mt-2">Examples: arch, garland, birthday</p>
|
||||
</div>
|
||||
|
||||
<div class="gallery-meta">
|
||||
<p class="result-count" id="result-count">Loading gallery...</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-scroll">
|
||||
<div class="filter-rows">
|
||||
<div class="filter-row">
|
||||
<div class="control"><button class="button filter-btn is-active" data-tag="all">All</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="arch">Arch</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="garland">Garland</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="columns">Columns</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="centerpiece">Centerpiece</button></div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="control"><button class="button filter-btn" data-tag="birthday">Birthday</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="wedding">Wedding</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="corporate">Corporate</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="pastel">Pastel</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="black-tie">Black Tie</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="baby-shower">Baby Shower</button></div>
|
||||
<div class="control"><button class="button filter-btn" data-tag="organic">Organic</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="photo-gallery" class="gallery-grid">
|
||||
<div id="no-results" class="has-text-centered" style="display: none; width: 100%; grid-column: 1 / -1;">
|
||||
<p class="is-size-5 has-text-grey">No photos found matching your criteria.</p>
|
||||
</div>
|
||||
<!-- Photos will be dynamically loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="image-modal" class="modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<section class="modal-card-body">
|
||||
<button class="modal-close-btn" aria-label="Close modal">×</button>
|
||||
<figure class="modal-figure">
|
||||
<img class="modal-image" id="modal-image-src" src="" alt="">
|
||||
</figure>
|
||||
<div class="modal-caption-block">
|
||||
<p class="modal-caption-title" id="modal-caption"></p>
|
||||
<div class="modal-caption-tags" id="modal-caption-tags"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer has-background-primary-light">
|
||||
<div class="content has-text-centered">
|
||||
<div>
|
||||
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
|
||||
</a>
|
||||
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
|
||||
</a>
|
||||
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
|
||||
</a>
|
||||
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
|
||||
<i class="fa-brands fa-bluesky is-size-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h7>Copyright © <span id="year"></span> Beach Party Balloons</h7>
|
||||
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="script.js"></script>
|
||||
<script src="update.js"></script>
|
||||
<script src="gallery.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
256
gallery.js
256
gallery.js
@ -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 = `<span>${countText}</span> photos shown • ${totalText} total`;
|
||||
return;
|
||||
}
|
||||
resultCountEl.innerHTML = count === total
|
||||
? `<span>${countText}</span> photos on display`
|
||||
: `<span>${countText}</span> shown • ${totalText} total`;
|
||||
}
|
||||
|
||||
function 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 => `<span class="tag-chip"><i class="fa-solid fa-wand-magic-sparkles"></i>${tag}</span>`).join('');
|
||||
photoCard.innerHTML = `
|
||||
<div class="gallery-photo">
|
||||
<img loading="lazy" ${srcset ? `srcset="${srcset}" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 45vw, 90vw"` : ''} src="${src}" alt="${photo.caption}" data-caption="${photo.caption}" data-tags="${photoTags.join(',')}" data-full-src="${src}" decoding="async">
|
||||
</div>
|
||||
<div class="gallery-overlay">
|
||||
<div class="overlay-bottom">
|
||||
<p class="overlay-title">${photo.caption}</p>
|
||||
<p class="overlay-subtitle">${photoTags.join(', ')}</p>
|
||||
<div class="overlay-tags" aria-label="Tags for ${photo.caption}">${tagBadges}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
gallery.appendChild(photoCard);
|
||||
setTimeout(() => requestAnimationFrame(() => photoCard.classList.add('is-visible')), idx * 70);
|
||||
|
||||
const imgEl = photoCard.querySelector('img');
|
||||
imgEl.addEventListener('click', (e) => {
|
||||
const card = e.currentTarget.closest('.gallery-item');
|
||||
if (isTouchDevice && card) {
|
||||
if (!card.classList.contains('touch-active')) {
|
||||
card.classList.add('touch-active');
|
||||
setTimeout(() => card.classList.remove('touch-active'), 2200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
openModal(e.target);
|
||||
if (card) card.classList.remove('touch-active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 => `<span class="tag-chip"><i class="fa-solid fa-wand-magic-sparkles"></i>${t}</span>`).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();
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
104
photo-gallery-app/backend/scripts/cleanup_uploads.js
Normal file
104
photo-gallery-app/backend/scripts/cleanup_uploads.js
Normal file
@ -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);
|
||||
});
|
||||
102
photo-gallery-app/backend/scripts/dedupe_uploads.js
Normal file
102
photo-gallery-app/backend/scripts/dedupe_uploads.js
Normal file
@ -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);
|
||||
});
|
||||
54
photo-gallery-app/backend/scripts/find_duplicates.js
Normal file
54
photo-gallery-app/backend/scripts/find_duplicates.js
Normal file
@ -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);
|
||||
});
|
||||
117
photo-gallery-app/backend/scripts/relink_variants.js
Normal file
117
photo-gallery-app/backend/scripts/relink_variants.js
Normal file
@ -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/<base>-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);
|
||||
});
|
||||
65
photo-gallery-app/backend/scripts/reseed_uploads.js
Normal file
65
photo-gallery-app/backend/scripts/reseed_uploads.js
Normal file
@ -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);
|
||||
});
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user