[object Object]

← back to SmokeShop

initial scaffold (gitify-all 2026-05-06)

5f71b0304d9801af592153e121f1fd8e78286083 · 2026-05-06 10:25:13 -0700 · Steve Abrams

Files touched

Diff

commit 5f71b0304d9801af592153e121f1fd8e78286083
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 10:25:13 2026 -0700

    initial scaffold (gitify-all 2026-05-06)
---
 .gitignore                       |   15 +
 CLAUDE.md                        |   40 +
 Smoke Shop.xlsx                  |  Bin 0 -> 28844567 bytes
 db/smokeshop.db-shm              |  Bin 0 -> 32768 bytes
 db/smokeshop.db-wal              |  Bin 0 -> 688072 bytes
 ecosystem.config.js              |   15 +
 lib/content-calendar.js          |  179 ++++
 lib/gemini.js                    |  169 ++++
 lib/image-generator.js           |  211 ++++
 lib/instagram-api.js             |  159 +++
 lib/scheduler.js                 |   86 ++
 package-lock.json                | 1982 ++++++++++++++++++++++++++++++++++++++
 package.json                     |   26 +
 paper-design.sh                  |    8 +
 public/css/styles.css            |  734 ++++++++++++++
 public/favicon.svg               |    4 +
 public/index.html                |  280 ++++++
 public/js/app.js                 |  729 ++++++++++++++
 server.js                        |  559 +++++++++++
 shutdown-monday.sh               |   18 +
 templates/template-a/config.json |   48 +
 templates/template-b/config.json |   48 +
 22 files changed, 5310 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7f1d76b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+node_modules/
+.env
+db/smokeshop.db
+generated/
+public/uploads/
+*.log
+.env.local
+.env.*.local
+.env.*
+tmp/
+.DS_Store
+dist/
+build/
+.next/
+*.bak
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a04b33a
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,40 @@
+# SmokeShop — Instagram Content Manager
+
+## Overview
+Instagram content management system for Smoke & Vape Depot (6100 Reseda Blvd, Reseda CA).
+Generates branded post graphics with Gemini AI, manages a 2-month content calendar (3x/week = 24 posts), and handles scheduled posting via Meta Graph API.
+
+## Service Info
+- **Port**: 8401
+- **PM2**: smokeshop
+- **URL**: http://45.61.58.125:8401
+- **Auth**: admin / DWSecure2024!
+- **DB**: SQLite at db/smokeshop.db
+
+## Stack
+Express.js, SQLite (better-sqlite3), Gemini AI (image + text), sharp, node-cron, canvas (fallback)
+
+## Content Strategy
+- **3 categories** rotating: community → product → promo
+- **Community**: Local Reseda businesses, neighborhood vibes
+- **Product**: Accessories (RAW, Clipper, Zippo), glassware (GRAV, MAV), CBD (Charlotte's Web, cbdMD)
+- **Promo**: Store hours, location, deals
+
+## Instagram Compliance
+- NEVER show tobacco/vape use (inhaled, smoked, vaped)
+- NEVER make health claims
+- NEVER target minors
+- All captions include "21+ only"
+- OK: Product packaging, store shelves, glassware as art, CBD, accessories
+
+## Key Endpoints
+- `POST /api/posts/generate-calendar` — Generate 24-post 8-week calendar
+- `POST /api/posts/:id/generate` — Generate Gemini AI image for a post
+- `POST /api/posts/:id/generate-caption` — Generate Gemini AI caption
+- `POST /api/posts/:id/publish` — Publish to Instagram
+- `POST /api/posts/generate-all-images` — Bulk generate all images
+
+## Gemini Integration
+- Image gen: `gemini-2.0-flash-exp-image-generation` with `responseModalities: ['TEXT', 'IMAGE']`
+- Text gen: `gemini-2.0-flash` for captions and hashtags
+- Falls back to canvas-based generation if Gemini fails
diff --git a/Smoke Shop.xlsx b/Smoke Shop.xlsx
new file mode 100644
index 0000000..799b2a5
Binary files /dev/null and b/Smoke Shop.xlsx differ
diff --git a/db/smokeshop.db-shm b/db/smokeshop.db-shm
new file mode 100644
index 0000000..9d9825f
Binary files /dev/null and b/db/smokeshop.db-shm differ
diff --git a/db/smokeshop.db-wal b/db/smokeshop.db-wal
new file mode 100644
index 0000000..cd46f23
Binary files /dev/null and b/db/smokeshop.db-wal differ
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..d0bc36e
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,15 @@
+module.exports = {
+  apps: [{
+    name: 'smokeshop',
+    script: 'server.js',
+    cwd: '/root/Projects/SmokeShop',
+    env: {
+      NODE_ENV: 'production',
+      PORT: 8401
+    },
+    watch: false,
+    max_memory_restart: '256M',
+    error_file: '/root/.pm2/logs/smokeshop-error.log',
+    out_file: '/root/.pm2/logs/smokeshop-out.log'
+  }]
+};
diff --git a/lib/content-calendar.js b/lib/content-calendar.js
new file mode 100644
index 0000000..614261d
--- /dev/null
+++ b/lib/content-calendar.js
@@ -0,0 +1,179 @@
+/**
+ * Content Calendar Generator
+ * Generates 24 posts over 8 weeks (3x/week) with rotating categories
+ */
+
+const POSTING_DAYS = [1, 3, 5]; // Mon, Wed, Fri (0=Sun)
+const POSTS_PER_WEEK = 3;
+const TOTAL_WEEKS = 8;
+
+const COMMUNITY_POSTS = [
+  {
+    title: 'Visit Reseda Blvd Today',
+    caption: 'Your neighborhood smoke shop is right in the heart of Reseda Blvd! Swing by after grabbing wings at Wingstop next door. We\'re here for you, fam.\n\n#ResedaCA #SFValley #818 #ResedaBlvd #ShopLocal #SmokeShoReseda #SupportLocal #ValleyLife\n\n21+ only',
+  },
+  {
+    title: 'Your Neighborhood Smoke Shop',
+    caption: 'We\'ve been serving the Reseda community with the best selection and friendliest service on the boulevard. Come see why locals keep coming back.\n\n#ResedaCA #SFValley #818 #ResedaBlvd #ShopLocal #LocalBusiness #CommunityFirst #ValleyVibes\n\n21+ only',
+  },
+  {
+    title: 'Reseda\'s Best Selection',
+    caption: 'Looking for the best smoke shop on Reseda Blvd? We\'ve got the biggest selection of accessories, glassware, and CBD in the valley. Stop by and see for yourself!\n\n#ResedaCA #SFValley #818 #ResedaBlvd #ShopLocal #BestInTheValley #SmokeShop #Selection\n\n21+ only',
+  },
+  {
+    title: 'Support Local Reseda',
+    caption: 'From Wingstop to the local barber shops, Reseda Blvd is full of amazing local businesses. We\'re proud to be part of this community. Come say hello!\n\n@wingstop #ResedaCA #SFValley #818 #ResedaBlvd #ShopLocal #SupportSmallBusiness #CommunityLove\n\n21+ only',
+  },
+  {
+    title: 'Valley\'s Favorite Shop',
+    caption: 'The San Fernando Valley knows where to find the best accessories and glassware. We\'re open late every night for your convenience!\n\n#SFValley #818 #ResedaCA #ResedaBlvd #ShopLocal #ValleyFavorite #OpenLate #SmokeShop\n\n21+ only',
+  },
+  {
+    title: 'Meet Your Neighbors',
+    caption: 'Come hang out with your Reseda neighbors! We love meeting the people who make this community great. Everyone\'s welcome (21+ of course).\n\n#ResedaCA #SFValley #818 #ResedaBlvd #Community #Neighbors #ShopLocal #ResedaLife\n\n21+ only',
+  },
+  {
+    title: 'Reseda Blvd Vibes Only',
+    caption: 'There\'s something special about Reseda Blvd. Great food, great people, great shops. We\'re proud to call this street home.\n\n#ResedaCA #SFValley #818 #ResedaBlvd #ShopLocal #GoodVibes #ValleyLife #LocalLove\n\n21+ only',
+  },
+  {
+    title: 'Heart Of The Valley',
+    caption: 'Right in the heart of the San Fernando Valley, we\'re your one-stop shop for premium accessories, artisan glassware, and quality CBD products.\n\n#SFValley #818 #ResedaCA #ResedaBlvd #HeartOfTheValley #ShopLocal #PremiumQuality\n\n21+ only',
+  },
+];
+
+const PRODUCT_POSTS = [
+  {
+    title: 'New Glass Just Arrived',
+    caption: 'Fresh glass just hit the shelves! We carry GRAV, MAV Glass, Diamond Glass, and more. Each piece is a work of art. Come see the new collection in person.\n\n#Glassware #GRAV #MAVGlass #DiamondGlass #ArtisanGlass #HandBlown #SmokeShop #ResedaCA\n\n21+ only',
+  },
+  {
+    title: 'Premium Accessories Inside',
+    caption: 'RAW papers, Clipper lighters, Zippo, premium grinders — we stock all the top brands. Quality accessories for the discerning customer.\n\n#RAWPapers #Clipper #Zippo #Grinders #Accessories #PremiumQuality #SmokeShop #ResedaCA\n\n21+ only',
+  },
+  {
+    title: 'Check Our CBD Selection',
+    caption: 'Looking for quality CBD? We carry Charlotte\'s Web, cbdMD, and other top brands. Tinctures, gummies, topicals — we\'ve got it all.\n\n#CBD #CharlottesWeb #cbdMD #Wellness #CBDProducts #CBDLife #NaturalWellness #ResedaCA\n\n21+ only',
+  },
+  {
+    title: 'Artisan Glass Collection',
+    caption: 'Our glassware collection features handcrafted pieces from the best glass artists. Functional art you\'ll be proud to display.\n\n#ArtisanGlass #HandBlown #GlassArt #FunctionalArt #GlassCollection #SmokeShop #ResedaCA\n\n21+ only',
+  },
+  {
+    title: 'Top Brand Accessories',
+    caption: 'We only carry the brands you trust. From RAW to Elements, Clipper to Zippo — every product on our shelves is premium quality.\n\n#RAW #Elements #Clipper #Zippo #TopBrands #PremiumAccessories #SmokeShop #ResedaCA\n\n21+ only',
+  },
+  {
+    title: 'Fresh Stock Every Week',
+    caption: 'We restock every week with the newest and best products. If you haven\'t visited recently, you\'re missing out on some amazing new arrivals!\n\n#NewArrivals #FreshStock #WeeklyDrops #SmokeShop #ResedaCA #ShopNew #LatestAndGreatest\n\n21+ only',
+  },
+  {
+    title: 'CBD Wellness Products',
+    caption: 'Explore our full range of CBD wellness products — from tinctures and capsules to gummies and topicals. Quality brands, lab-tested products.\n\n#CBD #Wellness #CBDTinctures #CBDGummies #LabTested #QualityCBD #ResedaCA #NaturalHealth\n\n21+ only',
+  },
+  {
+    title: 'Lighter Collection Fire',
+    caption: 'From classic Zippos to colorful Clippers, our lighter collection is unmatched. Refillable, reliable, and stylish. Come find your perfect lighter.\n\n#Zippo #Clipper #Lighters #Collection #Refillable #Stylish #SmokeShop #ResedaCA\n\n21+ only',
+  },
+];
+
+const PROMO_POSTS = [
+  {
+    title: 'Open Late Every Night',
+    caption: 'We\'re open until 12 AM every single night! Need something late? We\'ve got you covered. Swing by anytime.\n\n#OpenLate #NightOwl #LateNight #Convenience #SmokeShop #ResedaCA #AlwaysOpen #818\n\n21+ only',
+  },
+  {
+    title: 'Stop By After Wings',
+    caption: 'Grabbing Wingstop next door? Pop in and check out our latest arrivals while you wait for your order! We\'re literally right there.\n\n@wingstop #ResedaBlvd #ShopLocal #Convenience #SmokeShop #ResedaCA #WingsAndMore #818\n\n21+ only',
+  },
+  {
+    title: 'Great Prices Always',
+    caption: 'Quality doesn\'t have to break the bank. We offer the best prices on premium accessories, glassware, and CBD in the valley.\n\n#BestPrices #Value #Quality #Affordable #SmokeShop #ResedaCA #SFValley #SaveMore\n\n21+ only',
+  },
+  {
+    title: 'Free Parking Available',
+    caption: 'Easy parking right out front! No meters, no hassle. Just pull up, come in, and browse our amazing selection.\n\n#FreeParking #EasyAccess #Convenient #SmokeShop #ResedaCA #NoParkingStress #JustPullUp\n\n21+ only',
+  },
+  {
+    title: 'Friendly Staff Welcome',
+    caption: 'Our knowledgeable staff is always happy to help you find exactly what you need. No pressure, just good vibes and great service.\n\n#FriendlyStaff #GreatService #NoJudgment #SmokeShop #ResedaCA #CustomerFirst #GoodVibes\n\n21+ only',
+  },
+  {
+    title: 'Biggest Selection Around',
+    caption: 'Don\'t waste time driving all over the valley. We\'ve got the biggest selection of accessories, glass, and CBD under one roof.\n\n#BiggestSelection #OneStopShop #SmokeShop #ResedaCA #SFValley #ConvenientShopping #818\n\n21+ only',
+  },
+  {
+    title: 'Daily Deals Inside',
+    caption: 'Ask about our daily specials! We\'ve always got something on deal. Follow us for the latest offers and exclusive in-store promotions.\n\n#DailyDeals #Specials #Savings #InStoreDeals #SmokeShop #ResedaCA #FollowUs #Promotions\n\n21+ only',
+  },
+  {
+    title: 'Visit Us Today',
+    caption: 'Located at 6100 Reseda Blvd, Reseda CA. Open 7 days a week until midnight. Come see why we\'re the neighborhood\'s favorite shop!\n\n#VisitUs #OpenDaily #SmokeShop #ResedaCA #6100ResedaBlvd #OpenUntilMidnight #ShopLocal\n\n21+ only',
+  },
+];
+
+const CATEGORIES = ['community', 'product', 'promo'];
+const CONTENT_POOLS = {
+  community: COMMUNITY_POSTS,
+  product: PRODUCT_POSTS,
+  promo: PROMO_POSTS,
+};
+
+/**
+ * Generate a 2-month content calendar (24 posts, 3x/week)
+ * @param {string} [startDate] - ISO date string for calendar start (defaults to next Monday)
+ * @returns {Array<Object>} Array of post objects ready for DB insertion
+ */
+function generateCalendar(startDate) {
+  let start;
+  if (startDate) {
+    start = new Date(startDate);
+  } else {
+    // Start next Monday
+    start = new Date();
+    const day = start.getDay();
+    const daysUntilMon = day === 0 ? 1 : (8 - day) % 7 || 7;
+    start.setDate(start.getDate() + daysUntilMon);
+  }
+  start.setHours(10, 0, 0, 0); // 10 AM posting time
+
+  const posts = [];
+  let postIndex = 0;
+  const categoryCounters = { community: 0, product: 0, promo: 0 };
+
+  for (let week = 0; week < TOTAL_WEEKS; week++) {
+    for (let dayIdx = 0; dayIdx < POSTING_DAYS.length; dayIdx++) {
+      const category = CATEGORIES[postIndex % 3];
+      const pool = CONTENT_POOLS[category];
+      const contentIdx = categoryCounters[category] % pool.length;
+      const content = pool[contentIdx];
+      categoryCounters[category]++;
+
+      // Calculate the date for this post
+      const postDate = new Date(start);
+      postDate.setDate(postDate.getDate() + (week * 7));
+      // Adjust to the correct day of week
+      const currentDay = postDate.getDay();
+      const targetDay = POSTING_DAYS[dayIdx];
+      postDate.setDate(postDate.getDate() + (targetDay - currentDay));
+
+      // Alternate templates for A/B testing
+      const template = postIndex % 2 === 0 ? 'a' : 'b';
+
+      posts.push({
+        title: content.title,
+        caption: content.caption,
+        template,
+        category,
+        status: 'draft',
+        scheduled_at: postDate.toISOString(),
+      });
+
+      postIndex++;
+    }
+  }
+
+  return posts;
+}
+
+module.exports = { generateCalendar, COMMUNITY_POSTS, PRODUCT_POSTS, PROMO_POSTS };
diff --git a/lib/gemini.js b/lib/gemini.js
new file mode 100644
index 0000000..6361d91
--- /dev/null
+++ b/lib/gemini.js
@@ -0,0 +1,169 @@
+/**
+ * Gemini API Helper — Text + Image generation
+ * Uses gemini-2.0-flash for text, gemini-2.0-flash-exp-image-generation for images
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const GEMINI_TEXT_KEY = 'AIzaSyAO0rLKwtJUKcf3zVmKstBS4udct4QejMo';
+const GEMINI_TEXT_MODEL = 'gemini-2.0-flash';
+const GEMINI_IMAGE_MODEL = 'gemini-2.5-flash-image';
+const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
+
+/**
+ * Generate text content via Gemini
+ */
+async function generateText(prompt, options = {}) {
+  const model = options.model || GEMINI_TEXT_MODEL;
+  const key = options.apiKey || GEMINI_TEXT_KEY;
+  const url = `${BASE_URL}/${model}:generateContent?key=${key}`;
+
+  const parts = [{ text: prompt }];
+  if (options.images) {
+    for (const img of options.images) {
+      parts.push({ inlineData: { mimeType: img.mimeType || 'image/png', data: img.data } });
+    }
+  }
+
+  const body = {
+    contents: [{ parts }],
+    generationConfig: {
+      temperature: options.temperature || 0.8,
+      maxOutputTokens: options.maxTokens || 1024,
+    },
+  };
+
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  });
+
+  if (!res.ok) {
+    const err = await res.text();
+    throw new Error(`Gemini text error (${res.status}): ${err}`);
+  }
+
+  const data = await res.json();
+  const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
+  if (!text) throw new Error('No text in Gemini response');
+  return text;
+}
+
+/**
+ * Generate an image via Gemini
+ * Returns { imageBuffer, text } — the image as a Buffer and any accompanying text
+ */
+async function generateImage(prompt, options = {}) {
+  const model = options.model || GEMINI_IMAGE_MODEL;
+  const key = options.apiKey || GEMINI_TEXT_KEY;
+  const url = `${BASE_URL}/${model}:generateContent?key=${key}`;
+
+  const body = {
+    contents: [{ parts: [{ text: prompt }] }],
+    generationConfig: {
+      responseModalities: ['TEXT', 'IMAGE'],
+      temperature: options.temperature || 0.7,
+    },
+  };
+
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  });
+
+  if (!res.ok) {
+    const err = await res.text();
+    throw new Error(`Gemini image error (${res.status}): ${err}`);
+  }
+
+  const data = await res.json();
+  const parts = data.candidates?.[0]?.content?.parts || [];
+
+  let imageBuffer = null;
+  let text = '';
+
+  for (const part of parts) {
+    if (part.inlineData) {
+      imageBuffer = Buffer.from(part.inlineData.data, 'base64');
+    }
+    if (part.text) {
+      text += part.text;
+    }
+  }
+
+  if (!imageBuffer) throw new Error('No image in Gemini response');
+  return { imageBuffer, text };
+}
+
+/**
+ * Generate an Instagram post image with text overlay prompt
+ */
+async function generateInstagramImage(options) {
+  const { title, category, template, storeName, storeAddress } = options;
+
+  const categoryPrompts = {
+    community: 'a warm, moody interior shot of a trendy smoke shop lounge area with ambient lighting, dark wood shelves, neon accent signs, community bulletin board, cozy neighborhood hangout vibe, indoor scene only',
+    product: 'a stylish product showcase display with premium accessories, artisan hand-blown glassware, rolling papers, and CBD products arranged on dark velvet, moody studio lighting, high-end retail aesthetic, close-up product photography',
+    promo: 'a sleek retail counter display inside a modern smoke shop with neon signage, glass display cases glowing with warm light, dark interior aesthetic, professional retail photography, indoor scene only',
+  };
+
+  const categoryScene = categoryPrompts[category] || categoryPrompts.product;
+
+  const prompt = `Create a square 1080x1080 Instagram post image for a smoke shop called "${storeName || 'Smoke & Vape Depot'}".
+
+Scene: ${categoryScene}
+
+The image MUST include:
+- Bold text overlay reading: "${title}" in large white letters with a subtle dark shadow
+- The store name "${storeName || 'SMOKE & VAPE DEPOT'}" at the bottom in a smaller accent color
+- "${storeAddress || '6100 Reseda Blvd, Reseda CA'}" in small text below the store name
+- A consistent decorative border on all 4 sides (${template === 'b' ? 'orange/amber' : 'red/crimson'} color)
+- "21+ ONLY" disclaimer in small text at the very bottom
+- Dark, moody aesthetic with ${template === 'b' ? 'warm orange' : 'deep red'} accent colors
+
+Style: Professional Instagram marketing post, high contrast, modern design, square format.
+DO NOT include any tobacco or vaping imagery. Focus on the aesthetic and branding.
+DO NOT use outdoor scenes, street photos, storefronts, or sidewalks. ALL scenes must be indoor or studio shots only.`;
+
+  return generateImage(prompt);
+}
+
+/**
+ * Generate Instagram caption with hashtags via Gemini
+ */
+async function generateCaption(options) {
+  const { title, category, storeName } = options;
+
+  const prompt = `Write an Instagram caption for Smoke & Vape Depot (6100 Reseda Blvd, Reseda CA 91335).
+
+Post title: "${title}"
+Category: ${category}
+
+STORE BACKGROUND:
+- Located on Reseda Blvd in the San Fernando Valley (818 area code), open late until 12 AM
+- Right next to Wingstop (6048 Reseda Blvd) — regulars grab wings then stop by
+- Products: RAW papers, Clipper lighters, Zippo, GRAV Labs & MAV Glass & Diamond Glass (glassware), Charlotte's Web & cbdMD (CBD), Santa Cruz Shredder grinders, King Palm wraps
+- Neighborhood: diverse Reseda community, growing foodie scene, local barbershops, taquerias, Reseda Park
+- Voice: friendly, authentic, neighborhood vibe — like a real shop owner, NOT corporate
+
+RULES:
+- Keep it engaging and authentic, 2-3 sentences max
+- Include a call to action (stop by, come check it out, swing through)
+- For community posts: reference Reseda neighborhood, local spots, SFValley pride
+- For product posts: mention specific brands we carry by name
+- For promo posts: mention hours (open until 12 AM), location perks, or deals
+- NEVER reference smoking, vaping, or tobacco use
+- NEVER make health claims
+- End with "21+ only"
+- Include 8-10 hashtags on a new line: #SmokeAndVapeDepot #ResedaCA #SFValley #818 #ShopLocal plus category-specific ones
+- Keep caption under 300 characters (excluding hashtags)
+
+Return ONLY the caption text, nothing else.`;
+
+  return generateText(prompt, { temperature: 0.9 });
+}
+
+module.exports = { generateText, generateImage, generateInstagramImage, generateCaption };
diff --git a/lib/image-generator.js b/lib/image-generator.js
new file mode 100644
index 0000000..1c334da
--- /dev/null
+++ b/lib/image-generator.js
@@ -0,0 +1,211 @@
+/**
+ * Image Generator — Gemini AI-powered Instagram post graphics
+ * Primary: Gemini image generation for AI-created branded posts
+ * Fallback: Canvas-based programmatic generation
+ */
+
+const { createCanvas, registerFont, loadImage } = require('canvas');
+const fs = require('fs');
+const path = require('path');
+const sharp = require('sharp');
+const gemini = require('./gemini');
+
+// Register system fonts (for canvas fallback)
+try {
+  registerFont('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', { family: 'DejaVu Sans', weight: 'bold' });
+  registerFont('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', { family: 'DejaVu Sans' });
+} catch (e) {
+  console.log('[ImageGen] Using default fonts');
+}
+
+const SIZE = 1080;
+const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
+const GENERATED_DIR = path.join(__dirname, '..', 'generated');
+
+if (!fs.existsSync(GENERATED_DIR)) fs.mkdirSync(GENERATED_DIR, { recursive: true });
+
+function loadTemplate(templateId) {
+  const configPath = path.join(TEMPLATES_DIR, `template-${templateId}`, 'config.json');
+  if (!fs.existsSync(configPath)) throw new Error(`Template ${templateId} not found`);
+  return JSON.parse(fs.readFileSync(configPath, 'utf8'));
+}
+
+/**
+ * Generate post using Gemini AI (primary method)
+ */
+async function generateWithGemini(options) {
+  const { title, category, template, postId } = options;
+
+  console.log(`[ImageGen] Gemini generating: "${title}" (${category}, template ${template})`);
+
+  const result = await gemini.generateInstagramImage({
+    title,
+    category,
+    template,
+    storeName: 'Smoke & Vape Depot',
+    storeAddress: '6100 Reseda Blvd, Reseda CA',
+  });
+
+  // Resize to exact 1080x1080 with sharp
+  const resized = await sharp(result.imageBuffer)
+    .resize(SIZE, SIZE, { fit: 'cover' })
+    .png()
+    .toBuffer();
+
+  const filename = postId ? `post-${postId}.png` : `post-${Date.now()}.png`;
+  const outputPath = path.join(GENERATED_DIR, filename);
+  fs.writeFileSync(outputPath, resized);
+
+  console.log(`[ImageGen] Gemini image saved: ${filename}`);
+  return { path: outputPath, filename, buffer: resized };
+}
+
+/**
+ * Generate post using canvas (fallback method)
+ */
+async function generateWithCanvas(options) {
+  const { title, backgroundImage, template = 'a', category = 'product', postId } = options;
+  const config = loadTemplate(template);
+
+  const canvas = createCanvas(SIZE, SIZE);
+  const ctx = canvas.getContext('2d');
+
+  // Background
+  if (backgroundImage && fs.existsSync(backgroundImage)) {
+    const img = await loadImage(backgroundImage);
+    const scale = Math.max(SIZE / img.width, SIZE / img.height);
+    const w = img.width * scale;
+    const h = img.height * scale;
+    ctx.drawImage(img, (SIZE - w) / 2, (SIZE - h) / 2, w, h);
+    ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
+    ctx.fillRect(0, 0, SIZE, SIZE);
+  } else {
+    const { colors, direction } = config.background;
+    const gradient = direction === 'diagonal'
+      ? ctx.createLinearGradient(0, 0, SIZE, SIZE)
+      : ctx.createLinearGradient(0, 0, 0, SIZE);
+    gradient.addColorStop(0, colors[0]);
+    gradient.addColorStop(1, colors[1]);
+    ctx.fillStyle = gradient;
+    ctx.fillRect(0, 0, SIZE, SIZE);
+  }
+
+  // Subtle pattern overlay
+  ctx.globalAlpha = 0.05;
+  ctx.strokeStyle = '#ffffff';
+  ctx.lineWidth = 1;
+  for (let i = 0; i < SIZE; i += 30) {
+    ctx.beginPath();
+    ctx.moveTo(0, i);
+    ctx.lineTo(SIZE, i);
+    ctx.stroke();
+  }
+  ctx.globalAlpha = 1;
+
+  const border = config.border;
+
+  // Outer border
+  ctx.strokeStyle = border.color;
+  ctx.lineWidth = border.width;
+  ctx.strokeRect(border.width / 2, border.width / 2, SIZE - border.width, SIZE - border.width);
+
+  if (border.style === 'double') {
+    ctx.strokeStyle = border.innerColor;
+    ctx.lineWidth = 3;
+    const inset = border.width + 8;
+    ctx.strokeRect(inset, inset, SIZE - inset * 2, SIZE - inset * 2);
+  }
+
+  // Category accent bar
+  const catColor = config.categoryColors[category] || config.accent;
+  ctx.fillStyle = catColor;
+  ctx.fillRect(border.width, border.width, SIZE - border.width * 2, 6);
+
+  // Category label
+  ctx.font = `bold 16px "${config.text.fontFamily}"`;
+  ctx.fillStyle = catColor;
+  const catLabel = category.toUpperCase();
+  const catMetrics = ctx.measureText(catLabel);
+  ctx.fillText(catLabel, SIZE - border.width - catMetrics.width - 20, border.width + 40);
+
+  // Main title
+  ctx.font = `bold ${config.text.fontSize}px "${config.text.fontFamily}"`;
+  ctx.fillStyle = config.text.color;
+  ctx.shadowColor = config.text.shadowColor;
+  ctx.shadowBlur = config.text.shadowBlur;
+  ctx.shadowOffsetX = 0;
+  ctx.shadowOffsetY = 4;
+
+  const maxTextWidth = SIZE - border.width * 2 - 120;
+  const words = title.split(' ');
+  const lines = [];
+  let currentLine = '';
+  for (const word of words) {
+    const testLine = currentLine + (currentLine ? ' ' : '') + word;
+    if (ctx.measureText(testLine).width > maxTextWidth && currentLine) {
+      lines.push(currentLine);
+      currentLine = word;
+    } else {
+      currentLine = testLine;
+    }
+  }
+  if (currentLine) lines.push(currentLine);
+
+  const lineHeight = config.text.fontSize * 1.2;
+  const totalHeight = lines.length * lineHeight;
+  const startY = (SIZE / 2) - (totalHeight / 2) + config.text.fontSize * 0.3;
+  lines.forEach((line, i) => {
+    const metrics = ctx.measureText(line);
+    ctx.fillText(line, (SIZE - metrics.width) / 2, startY + i * lineHeight);
+  });
+
+  ctx.shadowColor = 'transparent';
+  ctx.shadowBlur = 0;
+  ctx.shadowOffsetY = 0;
+
+  // Store name
+  ctx.font = `bold ${config.storeName.fontSize}px "${config.text.fontFamily}"`;
+  ctx.fillStyle = config.storeName.color;
+  const storeMetrics = ctx.measureText(config.storeName.text);
+  ctx.fillText(config.storeName.text, (SIZE - storeMetrics.width) / 2, SIZE - border.width - 70);
+
+  // Address
+  ctx.font = `${config.address.fontSize}px "${config.text.fontFamily}"`;
+  ctx.fillStyle = config.address.color;
+  const addrMetrics = ctx.measureText(config.address.text);
+  ctx.fillText(config.address.text, (SIZE - addrMetrics.width) / 2, SIZE - border.width - 42);
+
+  // Age disclaimer
+  ctx.font = `bold ${config.ageDisclaimer.fontSize}px "${config.text.fontFamily}"`;
+  ctx.fillStyle = config.ageDisclaimer.color;
+  const ageMetrics = ctx.measureText(config.ageDisclaimer.text);
+  ctx.fillText(config.ageDisclaimer.text, (SIZE - ageMetrics.width) / 2, SIZE - border.width - 18);
+
+  const buffer = canvas.toBuffer('image/png');
+  const filename = postId ? `post-${postId}.png` : `post-${Date.now()}.png`;
+  const outputPath = path.join(GENERATED_DIR, filename);
+  fs.writeFileSync(outputPath, buffer);
+
+  return { path: outputPath, filename, buffer };
+}
+
+/**
+ * Generate a branded Instagram post graphic
+ * Tries Gemini AI first, falls back to canvas if Gemini fails
+ */
+async function generatePost(options) {
+  const useGemini = options.useGemini !== false; // default true
+
+  if (useGemini) {
+    try {
+      return await generateWithGemini(options);
+    } catch (err) {
+      console.error('[ImageGen] Gemini failed, falling back to canvas:', err.message);
+      return generateWithCanvas(options);
+    }
+  }
+
+  return generateWithCanvas(options);
+}
+
+module.exports = { generatePost, generateWithGemini, generateWithCanvas, loadTemplate };
diff --git a/lib/instagram-api.js b/lib/instagram-api.js
new file mode 100644
index 0000000..ac6984f
--- /dev/null
+++ b/lib/instagram-api.js
@@ -0,0 +1,159 @@
+/**
+ * Instagram Graph API — Meta OAuth + posting
+ * Handles authentication and media publishing for Instagram Business accounts
+ */
+
+const GRAPH_API_VERSION = 'v18.0';
+const GRAPH_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`;
+
+/**
+ * Build the OAuth authorization URL for Meta login
+ */
+function getAuthUrl(appId, redirectUri) {
+  const scopes = [
+    'instagram_basic',
+    'instagram_content_publish',
+    'pages_show_list',
+    'pages_read_engagement',
+  ].join(',');
+
+  return `https://www.facebook.com/${GRAPH_API_VERSION}/dialog/oauth?` +
+    `client_id=${appId}&redirect_uri=${encodeURIComponent(redirectUri)}` +
+    `&scope=${scopes}&response_type=code`;
+}
+
+/**
+ * Exchange authorization code for access token
+ */
+async function exchangeCodeForToken(code, appId, appSecret, redirectUri) {
+  const url = `${GRAPH_BASE}/oauth/access_token?` +
+    `client_id=${appId}&client_secret=${appSecret}` +
+    `&redirect_uri=${encodeURIComponent(redirectUri)}&code=${code}`;
+
+  const res = await fetch(url);
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({}));
+    throw new Error(`Token exchange failed: ${JSON.stringify(err)}`);
+  }
+  return res.json();
+}
+
+/**
+ * Exchange short-lived token for long-lived token (60 days)
+ */
+async function getLongLivedToken(shortToken, appId, appSecret) {
+  const url = `${GRAPH_BASE}/oauth/access_token?` +
+    `grant_type=fb_exchange_token&client_id=${appId}` +
+    `&client_secret=${appSecret}&fb_exchange_token=${shortToken}`;
+
+  const res = await fetch(url);
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({}));
+    throw new Error(`Long-lived token exchange failed: ${JSON.stringify(err)}`);
+  }
+  return res.json();
+}
+
+/**
+ * Get Instagram Business Account ID from Facebook Page
+ */
+async function getIGBusinessAccount(accessToken) {
+  // Get pages
+  const pagesRes = await fetch(`${GRAPH_BASE}/me/accounts?access_token=${accessToken}`);
+  const pages = await pagesRes.json();
+
+  if (!pages.data || pages.data.length === 0) {
+    throw new Error('No Facebook Pages found. You need a Facebook Page linked to an Instagram Business account.');
+  }
+
+  // Get IG account from first page
+  const pageId = pages.data[0].id;
+  const igRes = await fetch(
+    `${GRAPH_BASE}/${pageId}?fields=instagram_business_account&access_token=${accessToken}`
+  );
+  const igData = await igRes.json();
+
+  if (!igData.instagram_business_account) {
+    throw new Error('No Instagram Business account linked to this Facebook Page.');
+  }
+
+  return {
+    igAccountId: igData.instagram_business_account.id,
+    pageId,
+    pageName: pages.data[0].name,
+  };
+}
+
+/**
+ * Publish an image to Instagram
+ * Step 1: Create media container
+ * Step 2: Publish the container
+ */
+async function publishPost(igAccountId, accessToken, imageUrl, caption) {
+  // Step 1: Create media container
+  const containerRes = await fetch(
+    `${GRAPH_BASE}/${igAccountId}/media`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        image_url: imageUrl,
+        caption,
+        access_token: accessToken,
+      }),
+    }
+  );
+  const container = await containerRes.json();
+
+  if (container.error) {
+    throw new Error(`Media container error: ${JSON.stringify(container.error)}`);
+  }
+
+  // Step 2: Publish
+  const publishRes = await fetch(
+    `${GRAPH_BASE}/${igAccountId}/media_publish`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        creation_id: container.id,
+        access_token: accessToken,
+      }),
+    }
+  );
+  const result = await publishRes.json();
+
+  if (result.error) {
+    throw new Error(`Publish error: ${JSON.stringify(result.error)}`);
+  }
+
+  return { mediaId: result.id, containerId: container.id };
+}
+
+/**
+ * Get insights for a media object
+ */
+async function getMediaInsights(mediaId, accessToken) {
+  const metrics = 'impressions,reach,engagement,saved';
+  const res = await fetch(
+    `${GRAPH_BASE}/${mediaId}/insights?metric=${metrics}&access_token=${accessToken}`
+  );
+  const data = await res.json();
+
+  if (data.error) return null;
+
+  const insights = {};
+  if (data.data) {
+    data.data.forEach(metric => {
+      insights[metric.name] = metric.values?.[0]?.value || 0;
+    });
+  }
+  return insights;
+}
+
+module.exports = {
+  getAuthUrl,
+  exchangeCodeForToken,
+  getLongLivedToken,
+  getIGBusinessAccount,
+  publishPost,
+  getMediaInsights,
+};
diff --git a/lib/scheduler.js b/lib/scheduler.js
new file mode 100644
index 0000000..5266b7e
--- /dev/null
+++ b/lib/scheduler.js
@@ -0,0 +1,86 @@
+/**
+ * Scheduler — node-cron based posting scheduler
+ * Checks every 15 minutes for due posts and publishes them
+ */
+
+const cron = require('node-cron');
+const path = require('path');
+const ig = require('./instagram-api');
+
+let db = null;
+let cronJob = null;
+
+function init(database) {
+  db = database;
+}
+
+function getSetting(key) {
+  const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
+  return row ? row.value : null;
+}
+
+async function processDuePosts() {
+  const now = new Date().toISOString();
+  const duePosts = db.prepare(
+    "SELECT * FROM posts WHERE status = 'scheduled' AND scheduled_at <= ? ORDER BY scheduled_at ASC"
+  ).all(now);
+
+  if (duePosts.length === 0) return;
+
+  const accessToken = getSetting('ig_access_token');
+  const igAccountId = getSetting('ig_business_account_id');
+  const appUrl = getSetting('app_url') || process.env.APP_URL || 'http://45.61.58.125:8401';
+
+  if (!accessToken || !igAccountId) {
+    console.log(`[Scheduler] ${duePosts.length} posts due but Instagram not connected`);
+    return;
+  }
+
+  for (const post of duePosts) {
+    try {
+      // The image must be publicly accessible for Meta Graph API
+      const imageUrl = `${appUrl}/generated/${path.basename(post.image_path || '')}`;
+
+      if (!post.image_path) {
+        console.log(`[Scheduler] Post ${post.id} has no image, skipping`);
+        db.prepare("UPDATE posts SET status = 'failed' WHERE id = ?").run(post.id);
+        continue;
+      }
+
+      const result = await ig.publishPost(igAccountId, accessToken, imageUrl, post.caption);
+
+      db.prepare(
+        "UPDATE posts SET status = 'posted', posted_at = ?, ig_media_id = ? WHERE id = ?"
+      ).run(new Date().toISOString(), result.mediaId, post.id);
+
+      console.log(`[Scheduler] Posted ${post.id}: "${post.title}" → IG media ${result.mediaId}`);
+    } catch (err) {
+      console.error(`[Scheduler] Failed to post ${post.id}:`, err.message);
+      db.prepare("UPDATE posts SET status = 'failed' WHERE id = ?").run(post.id);
+    }
+  }
+}
+
+function start() {
+  if (cronJob) return;
+  // Run every 15 minutes
+  cronJob = cron.schedule('*/15 * * * *', async () => {
+    console.log('[Scheduler] Checking for due posts...');
+    try {
+      await processDuePosts();
+    } catch (err) {
+      console.error('[Scheduler] Error:', err.message);
+    }
+  });
+  console.log('[Scheduler] Started — checking every 15 minutes');
+}
+
+function stop() {
+  if (cronJob) {
+    cronJob.stop();
+    cronJob = null;
+    console.log('[Scheduler] Stopped');
+  }
+}
+
+module.exports = { init, start, stop, processDuePosts };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..789028c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1982 @@
+{
+  "name": "smokeshop",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "smokeshop",
+      "version": "1.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "better-sqlite3": "^12.6.2",
+        "canvas": "^3.2.1",
+        "cors": "^2.8.6",
+        "dotenv": "^17.3.1",
+        "express": "^5.2.1",
+        "helmet": "^8.1.0",
+        "multer": "^2.1.1",
+        "node-cron": "^4.2.1",
+        "sharp": "^0.34.5"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+      "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/better-sqlite3": {
+      "version": "12.6.2",
+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
+      "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^7.1.1"
+      },
+      "engines": {
+        "node": "20.x || 22.x || 23.x || 24.x || 25.x"
+      }
+    },
+    "node_modules/bindings": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "license": "MIT",
+      "dependencies": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
+    "node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+      "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.0",
+        "iconv-lite": "^0.7.0",
+        "on-finished": "^2.4.1",
+        "qs": "^6.14.1",
+        "raw-body": "^3.0.1",
+        "type-is": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/canvas": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz",
+      "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "node-addon-api": "^7.0.0",
+        "prebuild-install": "^7.1.3"
+      },
+      "engines": {
+        "node": "^18.12.0 || >= 20.9.0"
+      }
+    },
+    "node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "license": "ISC"
+    },
+    "node_modules/concat-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+      "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+      "engines": [
+        "node >= 6.0"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.0.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+      "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "17.3.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
+      "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+      "license": "MIT",
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+      "license": "(MIT OR WTFPL)",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+      "license": "MIT"
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "license": "MIT"
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+      "license": "MIT"
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/helmet": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+      "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+      "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "license": "MIT"
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/multer": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+      "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.6.0",
+        "concat-stream": "^2.0.0",
+        "type-is": "^1.6.18"
+      },
+      "engines": {
+        "node": ">= 10.16.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/multer/node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/multer/node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/multer/node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/multer/node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/napi-build-utils": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-abi": {
+      "version": "3.87.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
+      "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/node-addon-api": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+      "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+      "license": "MIT"
+    },
+    "node_modules/node-cron": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
+      "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+      "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/prebuild-install": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+      "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+      "license": "MIT",
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^2.0.0",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      },
+      "bin": {
+        "prebuild-install": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/pump": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+      "license": "MIT",
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
+      "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+      "dependencies": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "bin": {
+        "rc": "cli.js"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/simple-get": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tar-fs": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      }
+    },
+    "node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+      "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^1.0.5",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a3a81e8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+  "name": "smokeshop",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "directories": {
+    "lib": "lib"
+  },
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "dependencies": {
+    "better-sqlite3": "^12.6.2",
+    "canvas": "^3.2.1",
+    "cors": "^2.8.6",
+    "dotenv": "^17.3.1",
+    "express": "^5.2.1",
+    "helmet": "^8.1.0",
+    "multer": "^2.1.1",
+    "node-cron": "^4.2.1",
+    "sharp": "^0.34.5"
+  }
+}
diff --git a/paper-design.sh b/paper-design.sh
new file mode 100755
index 0000000..47ce692
--- /dev/null
+++ b/paper-design.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+# Helper to call Paper MCP tools
+TOOL_NAME="$1"
+ARGS="$2"
+curl -s -X POST http://127.0.0.1:29979/mcp \
+  -H 'Content-Type: application/json' \
+  -H 'Accept: application/json, text/event-stream' \
+  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$TOOL_NAME\",\"arguments\":$ARGS}}" 2>&1 | sed 's/^event: message\ndata: //' | sed 's/^data: //'
diff --git a/public/css/styles.css b/public/css/styles.css
new file mode 100644
index 0000000..e0db090
--- /dev/null
+++ b/public/css/styles.css
@@ -0,0 +1,734 @@
+/* SmokeShop Admin Dashboard — Dark Theme */
+:root {
+  --bg-primary: #0f0f1a;
+  --bg-secondary: #1a1a2e;
+  --bg-card: #16213e;
+  --bg-input: #0f3460;
+  --accent: #e94560;
+  --accent-hover: #ff6b81;
+  --accent-orange: #ff6b35;
+  --text-primary: #ffffff;
+  --text-secondary: #aaaaaa;
+  --text-muted: #666666;
+  --border: #2a2a4a;
+  --success: #4ecdc4;
+  --warning: #ffd93d;
+  --danger: #e94560;
+  --community: #4ecdc4;
+  --product: #e94560;
+  --promo: #ffd93d;
+  --radius: 8px;
+  --shadow: 0 4px 20px rgba(0,0,0,0.3);
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  min-height: 100vh;
+}
+
+/* ─── Header ─────────────────────────────────────────── */
+.header {
+  background: var(--bg-secondary);
+  border-bottom: 2px solid var(--accent);
+  padding: 16px 24px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  position: sticky;
+  top: 0;
+  z-index: 100;
+}
+
+.header h1 {
+  font-size: 22px;
+  font-weight: 700;
+  color: var(--accent);
+  letter-spacing: 1px;
+}
+
+.header h1 span { color: var(--text-secondary); font-weight: 400; font-size: 14px; margin-left: 8px; }
+
+.header-actions { display: flex; gap: 8px; align-items: center; }
+
+.ig-status {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  color: var(--text-muted);
+  padding: 6px 12px;
+  border-radius: var(--radius);
+  background: var(--bg-card);
+}
+.ig-status .dot {
+  width: 8px; height: 8px; border-radius: 50%;
+  background: var(--danger);
+}
+.ig-status.connected .dot { background: var(--success); }
+
+/* ─── Layout ─────────────────────────────────────────── */
+.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
+
+.tabs {
+  display: flex;
+  gap: 4px;
+  margin-bottom: 20px;
+  border-bottom: 1px solid var(--border);
+  padding-bottom: 0;
+}
+
+.tab {
+  padding: 10px 20px;
+  background: transparent;
+  border: none;
+  color: var(--text-secondary);
+  font-size: 14px;
+  cursor: pointer;
+  border-bottom: 2px solid transparent;
+  transition: all 0.2s;
+}
+.tab:hover { color: var(--text-primary); }
+.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
+
+.tab-content { display: none; }
+.tab-content.active { display: block; }
+
+/* ─── Stats Bar ──────────────────────────────────────── */
+.stats-bar {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+  gap: 12px;
+  margin-bottom: 20px;
+}
+
+.stat-card {
+  background: var(--bg-card);
+  border-radius: var(--radius);
+  padding: 16px;
+  text-align: center;
+  border: 1px solid var(--border);
+}
+.stat-card .value { font-size: 28px; font-weight: 700; color: var(--accent); }
+.stat-card .label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; text-transform: uppercase; letter-spacing: 1px; }
+
+/* ─── Quick Photo ────────────────────────────────────── */
+.quick-photo {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 20px;
+  padding: 20px;
+  background: var(--bg-secondary);
+  border-radius: var(--radius);
+  border: 2px dashed var(--border);
+  justify-content: center;
+}
+.quick-photo .btn-lg { border-radius: 12px; }
+
+/* ─── Calendar Grid ──────────────────────────────────── */
+.calendar-controls {
+  display: flex; gap: 10px; margin-bottom: 16px; align-items: center; flex-wrap: wrap;
+}
+
+.calendar-grid {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 4px;
+  margin-bottom: 20px;
+}
+
+.calendar-day-header {
+  text-align: center;
+  padding: 8px;
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--text-muted);
+  text-transform: uppercase;
+  letter-spacing: 1px;
+}
+
+.calendar-day {
+  background: var(--bg-secondary);
+  border-radius: 6px;
+  padding: 8px;
+  min-height: 140px;
+  border: 1px solid var(--border);
+  transition: border-color 0.2s;
+}
+.calendar-day:hover { border-color: var(--accent); }
+.calendar-day .day-number { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
+
+.calendar-post {
+  background: var(--bg-card);
+  border-radius: 4px;
+  padding: 4px 6px;
+  margin-top: 4px;
+  font-size: 11px;
+  cursor: pointer;
+  border-left: 3px solid var(--accent);
+  transition: transform 0.1s;
+  overflow: hidden;
+}
+.calendar-post:hover { transform: translateX(2px); }
+.calendar-post.community { border-left-color: var(--community); }
+.calendar-post.product { border-left-color: var(--product); }
+.calendar-post.promo { border-left-color: var(--promo); }
+
+.calendar-post-img {
+  width: 100%;
+  aspect-ratio: 1;
+  object-fit: cover;
+  border-radius: 3px;
+  margin-bottom: 3px;
+  display: block;
+}
+
+.calendar-post .post-title { font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.calendar-post .post-status { font-size: 10px; color: var(--text-muted); }
+
+/* ─── Post List ──────────────────────────────────────── */
+.post-list { display: flex; flex-direction: column; gap: 8px; }
+
+.post-item {
+  background: var(--bg-card);
+  border-radius: var(--radius);
+  padding: 14px 18px;
+  display: grid;
+  grid-template-columns: 60px 1fr auto auto auto;
+  align-items: center;
+  gap: 14px;
+  border: 1px solid var(--border);
+  cursor: pointer;
+  transition: border-color 0.2s;
+}
+.post-item:hover { border-color: var(--accent); }
+
+.post-thumb {
+  width: 60px; height: 60px;
+  border-radius: 6px;
+  background: var(--bg-input);
+  overflow: hidden;
+  display: flex; align-items: center; justify-content: center;
+}
+.post-thumb img { width: 100%; height: 100%; object-fit: cover; }
+.post-thumb .no-img { font-size: 10px; color: var(--text-muted); text-align: center; }
+
+.post-info .post-title { font-weight: 600; font-size: 15px; }
+.post-info .post-meta { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
+
+.badge {
+  display: inline-block;
+  padding: 3px 10px;
+  border-radius: 12px;
+  font-size: 11px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+.badge.community { background: rgba(78,205,196,0.15); color: var(--community); }
+.badge.product { background: rgba(233,69,96,0.15); color: var(--product); }
+.badge.promo { background: rgba(255,217,61,0.15); color: var(--promo); }
+
+.status-badge {
+  display: inline-block;
+  padding: 3px 10px;
+  border-radius: 12px;
+  font-size: 11px;
+  font-weight: 600;
+}
+.status-badge.draft { background: rgba(102,102,102,0.2); color: #999; }
+.status-badge.scheduled { background: rgba(78,205,196,0.15); color: var(--success); }
+.status-badge.posted { background: rgba(78,205,196,0.3); color: var(--success); }
+.status-badge.failed { background: rgba(233,69,96,0.15); color: var(--danger); }
+
+/* ─── Buttons ────────────────────────────────────────── */
+.btn {
+  padding: 8px 16px;
+  border-radius: var(--radius);
+  border: none;
+  font-size: 13px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+.btn-primary { background: var(--accent); color: #fff; }
+.btn-primary:hover { background: var(--accent-hover); }
+.btn-secondary { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); }
+.btn-secondary:hover { border-color: var(--accent); }
+.btn-success { background: var(--success); color: #000; }
+.btn-success:hover { opacity: 0.9; }
+.btn-warning { background: var(--warning); color: #000; }
+.btn-danger { background: var(--danger); color: #fff; }
+.btn-danger:hover { opacity: 0.9; }
+.btn-sm { padding: 5px 10px; font-size: 12px; }
+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* ─── Modal ──────────────────────────────────────────── */
+.modal-overlay {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.7);
+  display: none;
+  align-items: center;
+  justify-content: center;
+  z-index: 200;
+}
+.modal-overlay.open { display: flex; }
+
+.modal {
+  background: var(--bg-secondary);
+  border-radius: 12px;
+  padding: 24px;
+  width: 90%;
+  max-width: 800px;
+  max-height: 90vh;
+  overflow-y: auto;
+  border: 1px solid var(--border);
+  box-shadow: var(--shadow);
+}
+
+.modal h2 {
+  font-size: 18px;
+  margin-bottom: 16px;
+  color: var(--accent);
+}
+
+.modal-actions {
+  display: flex;
+  gap: 8px;
+  justify-content: flex-end;
+  margin-top: 20px;
+  padding-top: 16px;
+  border-top: 1px solid var(--border);
+}
+
+/* ─── Form ───────────────────────────────────────────── */
+.form-group { margin-bottom: 14px; }
+.form-group label {
+  display: block;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--text-secondary);
+  margin-bottom: 4px;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.form-group input, .form-group select, .form-group textarea {
+  width: 100%;
+  padding: 10px 12px;
+  background: var(--bg-input);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  color: var(--text-primary);
+  font-size: 14px;
+  font-family: inherit;
+  transition: border-color 0.2s;
+}
+.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
+  outline: none;
+  border-color: var(--accent);
+}
+.form-group textarea { min-height: 100px; resize: vertical; }
+
+.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+
+/* ─── Preview ────────────────────────────────────────── */
+.preview-container {
+  display: flex;
+  gap: 20px;
+  flex-wrap: wrap;
+}
+.preview-image {
+  width: 300px;
+  height: 300px;
+  background: var(--bg-card);
+  border-radius: var(--radius);
+  border: 1px solid var(--border);
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+.preview-image img { width: 100%; height: 100%; object-fit: cover; }
+.preview-image .placeholder { color: var(--text-muted); font-size: 13px; text-align: center; padding: 20px; }
+.preview-details { flex: 1; min-width: 250px; }
+
+/* ─── Settings Panel ─────────────────────────────────── */
+.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }
+.settings-section {
+  background: var(--bg-card);
+  border-radius: var(--radius);
+  padding: 20px;
+  border: 1px solid var(--border);
+}
+.settings-section h3 { font-size: 14px; color: var(--accent); margin-bottom: 14px; text-transform: uppercase; letter-spacing: 1px; }
+
+/* ─── Toast ──────────────────────────────────────────── */
+.toast-container {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  z-index: 300;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.toast {
+  padding: 12px 20px;
+  border-radius: var(--radius);
+  font-size: 13px;
+  font-weight: 500;
+  box-shadow: var(--shadow);
+  animation: slideIn 0.3s ease;
+}
+.toast.success { background: var(--success); color: #000; }
+.toast.error { background: var(--danger); color: #fff; }
+.toast.info { background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border); }
+
+@keyframes slideIn {
+  from { transform: translateX(100%); opacity: 0; }
+  to { transform: translateX(0); opacity: 1; }
+}
+
+/* ─── Responsive — Tablet ────────────────────────────── */
+@media (max-width: 1024px) {
+  .calendar-grid { gap: 2px; }
+  .calendar-day { padding: 6px; min-height: 80px; }
+  .calendar-post .post-title { font-size: 10px; }
+}
+
+/* ─── Responsive — Mobile (iPhone optimized) ─────────── */
+@media (max-width: 768px) {
+  /* Header — compact, single row brand */
+  .header { flex-direction: column; gap: 8px; align-items: stretch; padding: 12px 16px; }
+  .header h1 { font-size: 18px; text-align: center; }
+  .header h1 span { display: block; margin-left: 0; margin-top: 2px; }
+  .header-actions { width: 100%; justify-content: center; gap: 8px; }
+
+  /* Hide spreadsheet banner on mobile */
+  .spreadsheet-banner { padding: 8px 16px; }
+  .spreadsheet-banner a { font-size: 12px; }
+
+  /* Container — tighter padding */
+  .container { padding: 12px 10px; }
+
+  /* Stats — horizontal scroll strip */
+  .stats-bar {
+    display: flex;
+    gap: 8px;
+    overflow-x: auto;
+    -webkit-overflow-scrolling: touch;
+    scroll-snap-type: x mandatory;
+    padding-bottom: 4px;
+  }
+  .stat-card {
+    min-width: 90px;
+    flex-shrink: 0;
+    scroll-snap-align: start;
+    padding: 10px 12px;
+  }
+  .stat-card .value { font-size: 24px; }
+  .stat-card .label { font-size: 10px; }
+
+  /* Tabs — full width, equal */
+  .tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; gap: 0; }
+  .tab { padding: 12px 14px; font-size: 14px; white-space: nowrap; flex: 1; text-align: center; }
+
+  /* Store details */
+  .store-details { margin-bottom: 12px; }
+  .store-detail-grid { grid-template-columns: 1fr; }
+  .store-details-content { padding: 12px; }
+
+  /* Quick Photo — stacked full-width buttons */
+  .quick-photo {
+    flex-direction: column;
+    gap: 10px;
+    padding: 16px;
+  }
+  .quick-photo .btn-lg {
+    width: 100%;
+    justify-content: center;
+    padding: 18px 24px !important;
+    font-size: 17px !important;
+    border-radius: 14px !important;
+  }
+
+  /* Calendar Controls — stacked */
+  .calendar-controls {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px;
+    margin-bottom: 12px;
+  }
+  .calendar-controls .btn {
+    font-size: 13px;
+    padding: 12px 10px;
+    text-align: center;
+    justify-content: center;
+    border-radius: 10px;
+  }
+  .calendar-controls .btn:last-child:nth-child(odd) {
+    grid-column: 1 / -1;
+  }
+
+  /* Calendar — 2-column card layout instead of 7-col grid */
+  .calendar-grid {
+    grid-template-columns: 1fr 1fr;
+    gap: 8px;
+  }
+  .calendar-day-header { display: none; }
+  .calendar-day {
+    padding: 10px;
+    min-height: auto;
+    border-radius: 10px;
+  }
+  .calendar-day .day-number {
+    font-size: 12px;
+    font-weight: 600;
+    color: var(--text-secondary);
+    margin-bottom: 6px;
+  }
+  .calendar-post {
+    padding: 6px 8px;
+    margin-top: 6px;
+    border-radius: 6px;
+    border-left-width: 4px;
+  }
+  .calendar-post-img {
+    aspect-ratio: 16/9;
+    border-radius: 6px;
+    margin-bottom: 6px;
+  }
+  .calendar-post .post-title { font-size: 12px; white-space: normal; }
+  .calendar-post .post-status { display: block; font-size: 11px; margin-top: 2px; }
+
+  /* Post List — card style with image + info */
+  .post-item {
+    grid-template-columns: 70px 1fr;
+    gap: 12px;
+    padding: 12px;
+    border-radius: 12px;
+  }
+  .post-thumb { width: 70px; height: 70px; border-radius: 10px; }
+  .post-info .post-title { font-size: 15px; line-height: 1.3; }
+  .post-info .post-meta { font-size: 12px; margin-top: 4px; }
+  .post-item .badge { display: inline-block; font-size: 10px; padding: 2px 8px; margin-top: 4px; }
+  .post-item .status-badge { display: inline-block; font-size: 10px; padding: 2px 8px; margin-top: 4px; }
+  .post-item .btn { display: none; }
+
+  /* Modal — full-screen sheet on mobile */
+  .modal-overlay { align-items: flex-end; }
+  .modal {
+    width: 100%;
+    max-width: none;
+    max-height: 92vh;
+    padding: 20px 16px;
+    border-radius: 20px 20px 0 0;
+    border-bottom: none;
+  }
+  .modal h2 { font-size: 18px; margin-bottom: 14px; }
+  .preview-container { flex-direction: column; gap: 14px; }
+  .preview-image { width: 100%; height: 240px; border-radius: 12px; }
+  .preview-details { min-width: auto; }
+  .form-row { grid-template-columns: 1fr; gap: 10px; }
+  .form-group input, .form-group select, .form-group textarea {
+    font-size: 16px; /* prevents iOS zoom on focus */
+    padding: 12px 14px;
+    border-radius: 10px;
+  }
+  .form-group textarea { min-height: 100px; }
+  .modal-actions {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px;
+    margin-top: 16px;
+  }
+  .modal-actions .btn {
+    padding: 14px;
+    border-radius: 10px;
+    font-size: 14px;
+    text-align: center;
+    justify-content: center;
+  }
+  .modal-actions .btn-danger { order: 4; }
+  .modal-actions div[style*="flex:1"] { display: none; }
+
+  /* Settings */
+  .settings-grid { grid-template-columns: 1fr; gap: 12px; }
+  .settings-section { padding: 16px; border-radius: 12px; }
+  .settings-section .btn { width: 100%; padding: 14px; font-size: 15px; justify-content: center; border-radius: 10px; }
+
+  /* IG Connect Modal */
+  .ig-connect-modal { width: 95%; padding: 24px 20px; border-radius: 20px; }
+}
+
+/* ─── Responsive — Small Phone (iPhone SE, etc) ──────── */
+@media (max-width: 420px) {
+  .header h1 { font-size: 16px; }
+  .calendar-grid { grid-template-columns: 1fr; }
+  .stats-bar .stat-card { min-width: 80px; }
+  .stat-card .value { font-size: 20px; }
+  .quick-photo .btn-lg { font-size: 15px !important; padding: 16px 20px !important; }
+}
+
+/* ─── Touch Targets (iOS) ────────────────────────────── */
+@media (pointer: coarse) {
+  .btn { min-height: 44px; min-width: 44px; }
+  .tab { min-height: 48px; display: flex; align-items: center; justify-content: center; }
+  .calendar-post { min-height: 44px; padding: 8px 10px; }
+  .post-item { min-height: 80px; }
+}
+
+/* ─── iOS Safe Areas ─────────────────────────────────── */
+@supports (padding-bottom: env(safe-area-inset-bottom)) {
+  .toast-container { bottom: calc(20px + env(safe-area-inset-bottom)); }
+  .modal { padding-bottom: calc(20px + env(safe-area-inset-bottom)); }
+}
+
+/* ─── Loading ────────────────────────────────────────── */
+.loading {
+  display: inline-block;
+  width: 16px; height: 16px;
+  border: 2px solid var(--text-muted);
+  border-top-color: var(--accent);
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.generating-overlay {
+  position: absolute;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(15,15,26,0.85);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  border-radius: var(--radius);
+}
+.generating-overlay .loading { width: 32px; height: 32px; border-width: 3px; }
+
+/* ─── Store Details (Collapsible) ────────────────────── */
+.store-details {
+  background: var(--bg-secondary);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  margin-bottom: 16px;
+  overflow: hidden;
+}
+.store-details summary {
+  padding: 12px 18px;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-secondary);
+  list-style: none;
+  display: flex;
+  align-items: center;
+  user-select: none;
+  transition: color 0.2s;
+}
+.store-details summary::-webkit-details-marker { display: none; }
+.store-details summary::after {
+  content: '+';
+  margin-left: auto;
+  font-size: 18px;
+  color: var(--text-muted);
+  transition: transform 0.2s;
+}
+.store-details[open] summary::after { content: '-'; }
+.store-details summary:hover { color: var(--accent); }
+.store-details[open] summary {
+  color: var(--accent);
+  border-bottom: 1px solid var(--border);
+}
+.store-details-content { padding: 16px 18px; }
+.store-detail-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+  gap: 16px;
+}
+.store-detail-section {
+  background: var(--bg-card);
+  border-radius: 6px;
+  padding: 14px;
+  border: 1px solid var(--border);
+}
+.store-detail-section h4 {
+  font-size: 12px;
+  text-transform: uppercase;
+  letter-spacing: 1px;
+  color: var(--accent);
+  margin-bottom: 8px;
+}
+.store-detail-section p {
+  font-size: 13px;
+  color: var(--text-secondary);
+  line-height: 1.6;
+}
+.product-tags { display: flex; flex-wrap: wrap; gap: 5px; }
+.ptag {
+  display: inline-block;
+  padding: 3px 8px;
+  border-radius: 10px;
+  font-size: 11px;
+  font-weight: 600;
+}
+.ptag.papers { background: rgba(233,69,96,0.12); color: #e94560; }
+.ptag.lighters { background: rgba(255,217,61,0.15); color: #e6c235; }
+.ptag.glass { background: rgba(78,205,196,0.12); color: #4ecdc4; }
+.ptag.cbd { background: rgba(100,200,100,0.12); color: #6cc66c; }
+.ptag.acc { background: rgba(170,170,170,0.12); color: #aaa; }
+
+/* store-detail-grid mobile handled in main mobile block above */
+
+/* ─── Spreadsheet Banner ────────────────────────────── */
+.spreadsheet-banner {
+  background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
+  padding: 10px 24px;
+  text-align: center;
+  border-bottom: 1px solid var(--border);
+}
+.spreadsheet-banner a {
+  color: var(--success);
+  text-decoration: none;
+  font-size: 14px;
+  font-weight: 600;
+  transition: color 0.2s;
+}
+.spreadsheet-banner a:hover { color: #fff; }
+
+/* ─── IG Connect Modal ──────────────────────────────── */
+.ig-connect-overlay {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.7);
+  display: none;
+  align-items: center;
+  justify-content: center;
+  z-index: 250;
+}
+.ig-connect-overlay.open { display: flex; }
+.ig-connect-modal {
+  background: var(--bg-secondary);
+  border-radius: 16px;
+  padding: 28px;
+  width: 90%;
+  max-width: 420px;
+  border: 1px solid var(--border);
+  box-shadow: var(--shadow);
+}
+.ig-connect-modal h3 {
+  font-size: 18px;
+  color: var(--accent);
+  margin-bottom: 6px;
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..b3e6dbe
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">S</text>
+</svg>
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..0b65797
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,280 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>SmokeShop — Instagram Content Manager</title>
+  <link rel="stylesheet" href="/css/styles.css">
+</head>
+<body>
+  <!-- Spreadsheet Banner -->
+  <div class="spreadsheet-banner">
+    <a href="https://docs.google.com/spreadsheets/d/1FrGZRWdisWpPIKK1zx6uHstRuidtUOua/edit?usp=sharing&ouid=118338764009699775321&rtpof=true&sd=true" target="_blank" rel="noopener">
+      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:8px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
+      View Full Content Calendar Spreadsheet (Google Sheets)
+    </a>
+  </div>
+
+  <!-- Header -->
+  <div class="header">
+    <h1>SMOKE &amp; VAPE DEPOT <span>Instagram Content Manager</span></h1>
+    <div class="header-actions">
+      <div class="ig-status">
+        <div class="dot"></div>
+        <span>Instagram:</span>
+        <span>Not Connected</span>
+      </div>
+      <button class="btn btn-sm btn-secondary" onclick="showIGOptions()">Connect Instagram</button>
+    </div>
+  </div>
+
+  <!-- Instagram Connect Modal -->
+  <div class="ig-connect-overlay" id="ig-connect-overlay">
+    <div class="ig-connect-modal">
+      <h3>Connect Instagram</h3>
+      <p style="color:var(--text-muted);font-size:13px;margin-bottom:16px">Choose how to connect your Instagram account:</p>
+      <div style="display:flex;flex-direction:column;gap:12px">
+        <button class="btn btn-primary" onclick="igLoginStandard()" style="padding:14px 20px;font-size:15px;border-radius:10px;text-align:left">
+          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:10px"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" y1="6.5" x2="17.5" y2="6.5"/></svg>
+          Login to Instagram (Standard)
+          <div style="font-size:11px;color:var(--text-muted);margin-top:4px;margin-left:30px">Log into your Instagram account in a new tab to post manually</div>
+        </button>
+        <button class="btn btn-warning" onclick="igConnectAPI()" style="padding:14px 20px;font-size:15px;border-radius:10px;text-align:left;color:#000">
+          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:10px"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
+          Connect via Meta API (Auto-Post)
+          <div style="font-size:11px;color:rgba(0,0,0,0.5);margin-top:4px;margin-left:30px">Authorize via Meta Business to enable scheduled auto-posting</div>
+        </button>
+      </div>
+      <button class="btn btn-secondary btn-sm" onclick="closeIGModal()" style="margin-top:16px;width:100%">Cancel</button>
+    </div>
+  </div>
+
+  <div class="container">
+    <!-- Store Details (Collapsible) -->
+    <details class="store-details">
+      <summary>
+        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:6px"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
+        Store Profile &amp; AI Context
+      </summary>
+      <div class="store-details-content">
+        <div class="store-detail-grid">
+          <div class="store-detail-section">
+            <h4>About the Store</h4>
+            <p><strong>Smoke &amp; Vape Depot</strong></p>
+            <p>6100 Reseda Blvd, Reseda, CA 91335</p>
+            <p>San Fernando Valley &bull; 818 Area Code</p>
+            <p>Open late every night until 12 AM</p>
+            <p style="color:var(--text-muted);font-size:12px;margin-top:8px">Your neighborhood smoke shop serving the local Reseda community.</p>
+          </div>
+          <div class="store-detail-section">
+            <h4>Products We Carry</h4>
+            <div class="product-tags">
+              <span class="ptag papers">RAW</span>
+              <span class="ptag papers">Elements</span>
+              <span class="ptag papers">Juicy Jay's</span>
+              <span class="ptag papers">Zig-Zag</span>
+              <span class="ptag papers">King Palm</span>
+              <span class="ptag lighters">Clipper</span>
+              <span class="ptag lighters">Zippo</span>
+              <span class="ptag lighters">BIC</span>
+              <span class="ptag glass">GRAV Labs</span>
+              <span class="ptag glass">MAV Glass</span>
+              <span class="ptag glass">Diamond Glass</span>
+              <span class="ptag cbd">Charlotte's Web</span>
+              <span class="ptag cbd">cbdMD</span>
+              <span class="ptag acc">Santa Cruz Shredder</span>
+              <span class="ptag acc">Cali Crusher</span>
+              <span class="ptag acc">Formula 420</span>
+            </div>
+          </div>
+          <div class="store-detail-section">
+            <h4>Neighborhood</h4>
+            <p>Next to Wingstop (6048 Reseda Blvd)</p>
+            <p>Reseda — diverse, working-class SFValley neighborhood</p>
+            <p>Nearby: barbershops, taquerias, Reseda Park, Sherman Way corridor</p>
+            <p style="color:var(--text-muted);font-size:12px;margin-top:8px">We support local businesses and community events.</p>
+          </div>
+          <div class="store-detail-section">
+            <h4>Brand Voice</h4>
+            <p>Friendly, authentic, neighborhood vibe</p>
+            <p>"Your local smoke shop" — approachable, knowledgeable</p>
+            <p>Emphasize craft/art side of glassware, community connection</p>
+            <h4 style="margin-top:12px;color:var(--danger)">Instagram Compliance</h4>
+            <p>NEVER reference smoking, vaping, or inhaling</p>
+            <p>NEVER make health claims about CBD</p>
+            <p>NEVER target minors — always "21+ only"</p>
+            <p style="color:var(--success)">OK: packaging, shelves, glassware as art, CBD, accessories, store interior</p>
+          </div>
+        </div>
+        <p style="color:var(--text-muted);font-size:11px;margin-top:12px;text-align:center">This context is sent to Gemini AI when generating posts from photos. Edit in Settings or CLAUDE.md.</p>
+      </div>
+    </details>
+
+    <!-- Stats -->
+    <div class="stats-bar">
+      <div class="stat-card"><div class="value" id="stat-total">0</div><div class="label">Total Posts</div></div>
+      <div class="stat-card"><div class="value" id="stat-draft">0</div><div class="label">Drafts</div></div>
+      <div class="stat-card"><div class="value" id="stat-scheduled">0</div><div class="label">Scheduled</div></div>
+      <div class="stat-card"><div class="value" id="stat-posted">0</div><div class="label">Posted</div></div>
+      <div class="stat-card"><div class="value" id="stat-images">0</div><div class="label">With Image</div></div>
+    </div>
+
+    <!-- Tabs -->
+    <div class="tabs">
+      <button class="tab active" data-tab="calendar" onclick="switchTab('calendar')">Calendar</button>
+      <button class="tab" data-tab="posts" onclick="switchTab('posts')">All Posts</button>
+      <button class="tab" data-tab="settings" onclick="switchTab('settings')">Settings</button>
+    </div>
+
+    <!-- Quick Photo Capture -->
+    <div class="quick-photo">
+      <label class="btn btn-lg btn-primary" style="cursor:pointer;font-size:16px;padding:16px 32px;border-radius:12px">
+        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:6px"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
+        Take Photo
+        <input type="file" accept="image/*" capture="environment" id="quick-camera" style="display:none" onchange="quickPhoto(this)">
+      </label>
+      <label class="btn btn-lg btn-warning" style="cursor:pointer;font-size:16px;padding:16px 32px;border-radius:12px;color:#000">
+        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:6px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
+        Upload Photo
+        <input type="file" accept="image/*" id="quick-upload" style="display:none" onchange="quickPhoto(this)">
+      </label>
+    </div>
+
+    <!-- Calendar Tab -->
+    <div class="tab-content active" id="tab-calendar">
+      <div class="calendar-controls">
+        <button class="btn btn-primary" onclick="generateCalendar()">Generate 8-Week Calendar</button>
+        <button class="btn btn-warning" onclick="generateAllImages()">Generate All Images (Gemini)</button>
+        <button class="btn btn-secondary" onclick="openNewPost()">+ New Post</button>
+      </div>
+      <div class="calendar-grid" id="calendar-grid"></div>
+    </div>
+
+    <!-- Posts Tab -->
+    <div class="tab-content" id="tab-posts">
+      <div class="calendar-controls">
+        <button class="btn btn-secondary" onclick="openNewPost()">+ New Post</button>
+        <button class="btn btn-warning" onclick="generateAllImages()">Generate All Images</button>
+      </div>
+      <div class="post-list" id="post-list"></div>
+    </div>
+
+    <!-- Settings Tab -->
+    <div class="tab-content" id="tab-settings">
+      <div class="settings-grid">
+        <div class="settings-section">
+          <h3>Store Info</h3>
+          <div class="form-group">
+            <label>Store Name</label>
+            <input type="text" id="setting-store_name" value="Smoke &amp; Vape Depot">
+          </div>
+          <div class="form-group">
+            <label>Store Address</label>
+            <input type="text" id="setting-store_address" value="6100 Reseda Blvd, Reseda CA">
+          </div>
+          <div class="form-group">
+            <label>App URL</label>
+            <input type="text" id="setting-app_url" value="http://45.61.58.125:8401">
+          </div>
+        </div>
+        <div class="settings-section">
+          <h3>Instagram / Meta API</h3>
+          <div class="form-group">
+            <label>Meta App ID</label>
+            <input type="text" id="setting-ig_app_id" placeholder="Enter Meta App ID">
+          </div>
+          <div class="form-group">
+            <label>Meta App Secret</label>
+            <input type="password" id="setting-ig_app_secret" placeholder="Enter Meta App Secret">
+          </div>
+          <div class="form-group">
+            <label>Instagram Status</label>
+            <p style="color:var(--text-muted);font-size:13px;padding:8px 0">
+              Configure your Meta App ID and Secret above, then click "Connect Instagram" in the header to authorize.
+            </p>
+          </div>
+          <button class="btn btn-primary" onclick="saveSettings()">Save Settings</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- Post Editor Modal -->
+  <div class="modal-overlay" id="modal-overlay">
+    <div class="modal">
+      <h2>Edit Post</h2>
+      <div class="preview-container">
+        <div class="preview-image" id="preview-img" style="position:relative">
+          <div class="placeholder">No image generated yet.</div>
+        </div>
+        <div class="preview-details">
+          <div class="form-group">
+            <label>Title (4 words max)</label>
+            <input type="text" id="edit-title" placeholder="New Glass Just Arrived" maxlength="60">
+          </div>
+          <div class="form-row">
+            <div class="form-group">
+              <label>Template</label>
+              <select id="edit-template">
+                <option value="a">A — Dark/Red</option>
+                <option value="b">B — Dark/Orange</option>
+              </select>
+            </div>
+            <div class="form-group">
+              <label>Category</label>
+              <select id="edit-category">
+                <option value="community">Community</option>
+                <option value="product">Product</option>
+                <option value="promo">Promo</option>
+              </select>
+            </div>
+          </div>
+          <div class="form-row">
+            <div class="form-group">
+              <label>Schedule</label>
+              <input type="datetime-local" id="edit-schedule">
+            </div>
+            <div class="form-group">
+              <label>Status</label>
+              <select id="edit-status">
+                <option value="draft">Draft</option>
+                <option value="scheduled">Scheduled</option>
+                <option value="posted">Posted</option>
+                <option value="failed">Failed</option>
+              </select>
+            </div>
+          </div>
+          <div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap">
+            <button class="btn btn-sm btn-primary" onclick="generateImage()">Generate Image</button>
+            <button class="btn btn-sm btn-secondary" onclick="generateCaption()">AI Caption</button>
+            <label class="btn btn-sm btn-warning" style="cursor:pointer">
+              Take Photo
+              <input type="file" accept="image/*" capture="environment" id="camera-input" style="display:none" onchange="uploadPhoto(this)">
+            </label>
+            <label class="btn btn-sm btn-secondary" style="cursor:pointer">
+              Upload
+              <input type="file" accept="image/*" id="file-input" style="display:none" onchange="uploadPhoto(this)">
+            </label>
+          </div>
+        </div>
+      </div>
+      <div class="form-group">
+        <label>Caption (with hashtags)</label>
+        <textarea id="edit-caption" placeholder="Write your Instagram caption here..."></textarea>
+      </div>
+      <div class="modal-actions">
+        <button class="btn btn-danger btn-sm" onclick="deletePost()">Delete</button>
+        <button class="btn btn-success btn-sm" id="publish-btn" onclick="publishPost()">Publish Now</button>
+        <div style="flex:1"></div>
+        <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
+        <button class="btn btn-primary" onclick="savePost()">Save</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- Toast Container -->
+  <div class="toast-container" id="toast-container"></div>
+
+  <script src="/js/app.js"></script>
+</body>
+</html>
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..5af48a8
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,729 @@
+/**
+ * SmokeShop Admin Dashboard — Frontend
+ * All user content is escaped via esc() before DOM insertion
+ */
+
+const API = '';
+const AUTH = 'Basic ' + btoa('admin:OsamaMario1999');
+
+const headers = () => ({
+  'Content-Type': 'application/json',
+  'Authorization': AUTH,
+});
+
+let posts = [];
+let currentPost = null;
+let draggedPostId = null;
+
+// ─── API Helpers ─────────────────────────────────────────
+async function api(path, opts = {}) {
+  const res = await fetch(API + path, {
+    ...opts,
+    headers: { ...headers(), ...(opts.headers || {}) },
+  });
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({ error: res.statusText }));
+    throw new Error(err.error || 'API error');
+  }
+  return res.json();
+}
+
+// ─── Toast ───────────────────────────────────────────────
+function toast(msg, type = 'info') {
+  const container = document.getElementById('toast-container');
+  const el = document.createElement('div');
+  el.className = 'toast ' + type;
+  el.textContent = msg;
+  container.appendChild(el);
+  setTimeout(() => el.remove(), 4000);
+}
+
+// ─── Safe HTML Escape ────────────────────────────────────
+function esc(str) {
+  if (!str) return '';
+  const div = document.createElement('div');
+  div.textContent = str;
+  return div.innerHTML;
+}
+
+// ─── Tab Switching ───────────────────────────────────────
+function switchTab(tabName) {
+  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
+  document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tabName));
+  if (tabName === 'calendar') renderCalendar();
+  if (tabName === 'posts') renderPostList();
+  if (tabName === 'settings') loadSettings();
+}
+
+// ─── Load Data ───────────────────────────────────────────
+async function loadPosts() {
+  try {
+    posts = await api('/api/posts');
+    renderStats();
+    renderCalendar();
+    renderPostList();
+  } catch (err) {
+    toast('Failed to load posts: ' + err.message, 'error');
+  }
+}
+
+// ─── Stats ───────────────────────────────────────────────
+function renderStats() {
+  const total = posts.length;
+  const draft = posts.filter(p => p.status === 'draft').length;
+  const scheduled = posts.filter(p => p.status === 'scheduled').length;
+  const posted = posts.filter(p => p.status === 'posted').length;
+  const withImage = posts.filter(p => p.image_path).length;
+
+  document.getElementById('stat-total').textContent = total;
+  document.getElementById('stat-draft').textContent = draft;
+  document.getElementById('stat-scheduled').textContent = scheduled;
+  document.getElementById('stat-posted').textContent = posted;
+  document.getElementById('stat-images').textContent = withImage;
+}
+
+// ─── Calendar View ───────────────────────────────────────
+function renderCalendar() {
+  const grid = document.getElementById('calendar-grid');
+  if (!grid) return;
+
+  // Clear grid safely
+  while (grid.firstChild) grid.removeChild(grid.firstChild);
+
+  const scheduledPosts = posts.filter(p => p.scheduled_at);
+  if (scheduledPosts.length === 0) {
+    const msg = document.createElement('div');
+    msg.style.cssText = 'grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)';
+    msg.textContent = 'No posts scheduled yet. Generate a calendar first.';
+    grid.appendChild(msg);
+    return;
+  }
+
+  // Find date range
+  const dates = scheduledPosts.map(p => new Date(p.scheduled_at));
+  const minDate = new Date(Math.min(...dates));
+  const maxDate = new Date(Math.max(...dates));
+
+  const start = new Date(minDate);
+  start.setDate(start.getDate() - start.getDay());
+  start.setHours(0, 0, 0, 0);
+
+  const end = new Date(maxDate);
+  end.setDate(end.getDate() + (6 - end.getDay()));
+  end.setHours(23, 59, 59);
+
+  // Day headers
+  const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+  dayNames.forEach(d => {
+    const header = document.createElement('div');
+    header.className = 'calendar-day-header';
+    header.textContent = d;
+    grid.appendChild(header);
+  });
+
+  // Days
+  const current = new Date(start);
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+
+  while (current <= end) {
+    const dateStr = current.toISOString().split('T')[0];
+    const dayPosts = scheduledPosts.filter(p => p.scheduled_at && p.scheduled_at.startsWith(dateStr));
+    const isToday = current.getTime() === today.getTime();
+
+    const dayEl = document.createElement('div');
+    dayEl.className = 'calendar-day';
+    if (isToday) dayEl.style.borderColor = 'var(--accent)';
+
+    const dayNum = document.createElement('div');
+    dayNum.className = 'day-number';
+    if (isToday) { dayNum.style.color = 'var(--accent)'; dayNum.style.fontWeight = 'bold'; }
+    dayNum.textContent = current.getDate();
+    dayEl.appendChild(dayNum);
+
+    dayPosts.forEach(p => {
+      const postEl = document.createElement('div');
+      postEl.className = 'calendar-post ' + (p.category || '');
+      postEl.addEventListener('click', () => openPost(p.id));
+
+      // Thumbnail image
+      if (p.image_path) {
+        const img = document.createElement('img');
+        img.className = 'calendar-post-img';
+        img.src = '/api/preview/' + p.id;
+        img.alt = p.title;
+        img.loading = 'lazy';
+        img.onerror = function() { this.style.display = 'none'; };
+        postEl.appendChild(img);
+      }
+
+      const titleEl = document.createElement('div');
+      titleEl.className = 'post-title';
+      titleEl.textContent = p.title;
+      postEl.appendChild(titleEl);
+
+      const statusEl = document.createElement('div');
+      statusEl.className = 'post-status';
+      statusEl.textContent = p.status;
+      postEl.appendChild(statusEl);
+
+      dayEl.appendChild(postEl);
+    });
+
+    grid.appendChild(dayEl);
+    current.setDate(current.getDate() + 1);
+  }
+}
+
+// ─── Post List View ─────────────────────────────────────
+function renderPostList() {
+  const list = document.getElementById('post-list');
+  if (!list) return;
+
+  while (list.firstChild) list.removeChild(list.firstChild);
+
+  if (posts.length === 0) {
+    const msg = document.createElement('div');
+    msg.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted)';
+    msg.textContent = 'No posts yet. Generate a calendar to get started.';
+    list.appendChild(msg);
+    return;
+  }
+
+  posts.forEach(p => {
+    const item = document.createElement('div');
+    item.className = 'post-item';
+    item.addEventListener('click', () => openPost(p.id));
+
+    // Thumbnail
+    const thumb = document.createElement('div');
+    thumb.className = 'post-thumb';
+    if (p.image_path) {
+      const img = document.createElement('img');
+      img.src = '/api/preview/' + p.id;
+      img.alt = p.title;
+      img.onerror = function() { this.parentElement.textContent = 'No image'; };
+      thumb.appendChild(img);
+    } else {
+      const noImg = document.createElement('div');
+      noImg.className = 'no-img';
+      noImg.textContent = 'No image';
+      thumb.appendChild(noImg);
+    }
+    item.appendChild(thumb);
+
+    // Info
+    const info = document.createElement('div');
+    info.className = 'post-info';
+    const title = document.createElement('div');
+    title.className = 'post-title';
+    title.textContent = p.title;
+    const meta = document.createElement('div');
+    meta.className = 'post-meta';
+    meta.textContent = p.scheduled_at ? formatDate(p.scheduled_at) : 'Not scheduled';
+    info.appendChild(title);
+    info.appendChild(meta);
+    item.appendChild(info);
+
+    // Category badge
+    const badge = document.createElement('span');
+    badge.className = 'badge ' + (p.category || '');
+    badge.textContent = p.category || 'none';
+    item.appendChild(badge);
+
+    // Status badge
+    const status = document.createElement('span');
+    status.className = 'status-badge ' + (p.status || '');
+    status.textContent = p.status || 'draft';
+    item.appendChild(status);
+
+    // Actions
+    const actions = document.createElement('div');
+    if (!p.image_path) {
+      const genBtn = document.createElement('button');
+      genBtn.className = 'btn btn-sm btn-primary';
+      genBtn.textContent = 'Generate';
+      genBtn.addEventListener('click', (e) => { e.stopPropagation(); generateImage(p.id); });
+      actions.appendChild(genBtn);
+    }
+    item.appendChild(actions);
+
+    list.appendChild(item);
+  });
+}
+
+// ─── Post Editor Modal ──────────────────────────────────
+function openPost(id) {
+  currentPost = posts.find(p => p.id === id);
+  if (!currentPost) return;
+
+  document.getElementById('edit-title').value = currentPost.title || '';
+  document.getElementById('edit-caption').value = currentPost.caption || '';
+  document.getElementById('edit-template').value = currentPost.template || 'a';
+  document.getElementById('edit-category').value = currentPost.category || 'product';
+  document.getElementById('edit-schedule').value = currentPost.scheduled_at ? currentPost.scheduled_at.slice(0, 16) : '';
+  document.getElementById('edit-status').value = currentPost.status || 'draft';
+
+  const previewImg = document.getElementById('preview-img');
+  while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
+
+  if (currentPost.image_path) {
+    const img = document.createElement('img');
+    img.src = '/api/preview/' + currentPost.id + '?t=' + Date.now();
+    img.alt = 'Preview';
+    previewImg.appendChild(img);
+  } else {
+    const ph = document.createElement('div');
+    ph.className = 'placeholder';
+    ph.textContent = 'No image generated yet. Click "Generate Image" to create one with Gemini AI.';
+    previewImg.appendChild(ph);
+  }
+
+  document.getElementById('modal-overlay').classList.add('open');
+}
+
+function openNewPost() {
+  currentPost = null;
+  document.getElementById('edit-title').value = '';
+  document.getElementById('edit-caption').value = '';
+  document.getElementById('edit-template').value = 'a';
+  document.getElementById('edit-category').value = 'product';
+  document.getElementById('edit-schedule').value = '';
+  document.getElementById('edit-status').value = 'draft';
+
+  const previewImg = document.getElementById('preview-img');
+  while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
+  const ph = document.createElement('div');
+  ph.className = 'placeholder';
+  ph.textContent = 'Create the post first, then generate an image.';
+  previewImg.appendChild(ph);
+
+  document.getElementById('modal-overlay').classList.add('open');
+}
+
+function closeModal() {
+  document.getElementById('modal-overlay').classList.remove('open');
+  currentPost = null;
+}
+
+async function savePost() {
+  const data = {
+    title: document.getElementById('edit-title').value,
+    caption: document.getElementById('edit-caption').value,
+    template: document.getElementById('edit-template').value,
+    category: document.getElementById('edit-category').value,
+    scheduled_at: document.getElementById('edit-schedule').value || null,
+    status: document.getElementById('edit-status').value,
+  };
+
+  if (!data.title) return toast('Title is required', 'error');
+
+  try {
+    if (currentPost) {
+      await api('/api/posts/' + currentPost.id, { method: 'PUT', body: JSON.stringify(data) });
+      toast('Post updated', 'success');
+    } else {
+      const created = await api('/api/posts', { method: 'POST', body: JSON.stringify(data) });
+      currentPost = created;
+      toast('Post created', 'success');
+    }
+    await loadPosts();
+    if (currentPost) openPost(currentPost.id);
+  } catch (err) {
+    toast('Save failed: ' + err.message, 'error');
+  }
+}
+
+async function deletePost() {
+  if (!currentPost) return;
+  if (!confirm('Delete this post?')) return;
+
+  try {
+    await api('/api/posts/' + currentPost.id, { method: 'DELETE' });
+    toast('Post deleted', 'success');
+    closeModal();
+    await loadPosts();
+  } catch (err) {
+    toast('Delete failed: ' + err.message, 'error');
+  }
+}
+
+// ─── Gemini Image Generation ─────────────────────────────
+async function generateImage(postId) {
+  const id = postId || (currentPost && currentPost.id);
+  if (!id) return toast('Save the post first', 'error');
+
+  const previewImg = document.getElementById('preview-img');
+  if (previewImg && !postId) {
+    while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
+    const overlay = document.createElement('div');
+    overlay.className = 'generating-overlay';
+    const spinner = document.createElement('div');
+    spinner.className = 'loading';
+    overlay.appendChild(spinner);
+    const label = document.createElement('div');
+    label.style.cssText = 'color:var(--text-secondary);font-size:13px';
+    label.textContent = 'Generating with Gemini AI...';
+    overlay.appendChild(label);
+    previewImg.appendChild(overlay);
+  }
+
+  try {
+    toast('Generating image with Gemini AI...', 'info');
+    await api('/api/posts/' + id + '/generate', { method: 'POST', body: JSON.stringify({}) });
+    toast('Image generated!', 'success');
+    await loadPosts();
+    if (currentPost && !postId) {
+      while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
+      const img = document.createElement('img');
+      img.src = '/api/preview/' + id + '?t=' + Date.now();
+      img.alt = 'Preview';
+      previewImg.appendChild(img);
+    }
+  } catch (err) {
+    toast('Generation failed: ' + err.message, 'error');
+    if (previewImg && !postId) {
+      while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
+      const ph = document.createElement('div');
+      ph.className = 'placeholder';
+      ph.textContent = 'Generation failed. Try again.';
+      previewImg.appendChild(ph);
+    }
+  }
+}
+
+async function generateCaption() {
+  if (!currentPost) return toast('Save the post first', 'error');
+
+  try {
+    toast('Generating caption with Gemini AI...', 'info');
+    const result = await api('/api/posts/' + currentPost.id + '/generate-caption', { method: 'POST' });
+    document.getElementById('edit-caption').value = result.caption;
+    toast('Caption generated!', 'success');
+  } catch (err) {
+    toast('Caption generation failed: ' + err.message, 'error');
+  }
+}
+
+async function publishPost() {
+  if (!currentPost) return;
+
+  // Check if API is connected
+  let apiConnected = false;
+  try {
+    const status = await api('/api/instagram/status');
+    apiConnected = status.connected;
+  } catch {}
+
+  if (apiConnected) {
+    // Auto-publish via Meta Graph API
+    if (!confirm('Publish this post to Instagram now via API?')) return;
+    try {
+      toast('Publishing to Instagram...', 'info');
+      await api('/api/posts/' + currentPost.id + '/publish', { method: 'POST' });
+      toast('Published to Instagram!', 'success');
+      await loadPosts();
+      closeModal();
+    } catch (err) {
+      toast('Publish failed: ' + err.message, 'error');
+    }
+  } else {
+    // Manual publish — copy caption + open IG
+    manualPublish();
+  }
+}
+
+function manualPublish() {
+  if (!currentPost) return;
+
+  // Copy caption to clipboard
+  const caption = document.getElementById('edit-caption').value || currentPost.caption || '';
+  if (caption) {
+    navigator.clipboard.writeText(caption).then(() => {
+      toast('Caption copied to clipboard!', 'success');
+    }).catch(() => {
+      // Fallback for older browsers
+      const ta = document.createElement('textarea');
+      ta.value = caption;
+      document.body.appendChild(ta);
+      ta.select();
+      document.execCommand('copy');
+      document.body.removeChild(ta);
+      toast('Caption copied to clipboard!', 'success');
+    });
+  }
+
+  // If there's an image, open it in new tab so user can download/share
+  if (currentPost.image_path) {
+    window.open('/api/preview/' + currentPost.id, '_blank');
+  }
+
+  // Open Instagram
+  setTimeout(() => {
+    window.open('https://www.instagram.com/', '_blank');
+    toast('Instagram opened — paste your caption and upload the image!', 'info');
+  }, 500);
+
+  // Mark as posted
+  api('/api/posts/' + currentPost.id, {
+    method: 'PUT',
+    body: JSON.stringify({ status: 'posted' }),
+  }).then(() => loadPosts()).catch(() => {});
+}
+
+// ─── Quick Photo (Main Screen) ────────────────────────────
+async function quickPhoto(input) {
+  if (!input.files || !input.files[0]) return;
+
+  toast('Creating post from photo...', 'info');
+
+  try {
+    // 1. Create a new post
+    const post = await api('/api/posts', {
+      method: 'POST',
+      body: JSON.stringify({ title: 'New Photo Post', category: 'product', template: 'a' }),
+    });
+
+    // 2. Upload the photo
+    const formData = new FormData();
+    formData.append('image', input.files[0]);
+    const uploadRes = await fetch('/api/posts/' + post.id + '/upload-photo', {
+      method: 'POST',
+      headers: { 'Authorization': AUTH },
+      body: formData,
+    });
+    if (!uploadRes.ok) throw new Error('Upload failed');
+
+    toast('Photo uploaded! Generating title & caption with AI...', 'info');
+
+    // 3. AI-generate title + caption from the photo
+    const aiRes = await api('/api/posts/' + post.id + '/ai-from-photo', { method: 'POST' });
+    if (aiRes.title) {
+      await api('/api/posts/' + post.id, {
+        method: 'PUT',
+        body: JSON.stringify({ title: aiRes.title, caption: aiRes.caption, category: aiRes.category || 'product' }),
+      });
+    }
+
+    toast('Post created from photo!', 'success');
+    await loadPosts();
+    openPost(post.id);
+  } catch (err) {
+    toast('Failed: ' + err.message, 'error');
+  }
+  input.value = '';
+}
+
+// ─── Photo Upload (Modal) ─────────────────────────────────
+async function uploadPhoto(input) {
+  if (!input.files || !input.files[0]) return;
+  const id = currentPost && currentPost.id;
+  if (!id) return toast('Save the post first', 'error');
+
+  const formData = new FormData();
+  formData.append('image', input.files[0]);
+
+  const previewImg = document.getElementById('preview-img');
+  while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
+  const overlay = document.createElement('div');
+  overlay.className = 'generating-overlay';
+  const spinner = document.createElement('div');
+  spinner.className = 'loading';
+  overlay.appendChild(spinner);
+  const label = document.createElement('div');
+  label.style.cssText = 'color:var(--text-secondary);font-size:13px';
+  label.textContent = 'Uploading photo...';
+  overlay.appendChild(label);
+  previewImg.appendChild(overlay);
+
+  try {
+    const res = await fetch('/api/posts/' + id + '/upload-photo', {
+      method: 'POST',
+      headers: { 'Authorization': AUTH },
+      body: formData,
+    });
+    if (!res.ok) throw new Error((await res.json()).error || 'Upload failed');
+    toast('Photo uploaded! Generating AI title & caption...', 'info');
+    await loadPosts();
+    while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
+    const img = document.createElement('img');
+    img.src = '/api/preview/' + id + '?t=' + Date.now();
+    img.alt = 'Preview';
+    previewImg.appendChild(img);
+
+    // AI analyze the photo for title + caption
+    try {
+      const aiRes = await api('/api/posts/' + id + '/ai-from-photo', { method: 'POST' });
+      if (aiRes.title) document.getElementById('edit-title').value = aiRes.title;
+      if (aiRes.caption) document.getElementById('edit-caption').value = aiRes.caption;
+      if (aiRes.category) document.getElementById('edit-category').value = aiRes.category;
+      toast('AI filled in title & caption!', 'success');
+    } catch (aiErr) {
+      toast('Photo saved, but AI analysis failed: ' + aiErr.message, 'error');
+    }
+  } catch (err) {
+    toast('Upload failed: ' + err.message, 'error');
+    while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
+    const ph = document.createElement('div');
+    ph.className = 'placeholder';
+    ph.textContent = 'Upload failed. Try again.';
+    previewImg.appendChild(ph);
+  }
+  input.value = '';
+}
+
+// ─── Calendar Generation ─────────────────────────────────
+async function generateCalendar() {
+  const clearExisting = confirm('Clear existing draft posts and generate a fresh 8-week calendar?');
+
+  try {
+    toast('Generating 24-post content calendar...', 'info');
+    const result = await api('/api/posts/generate-calendar', {
+      method: 'POST',
+      body: JSON.stringify({ clearExisting }),
+    });
+    toast('Generated ' + result.generated + ' posts!', 'success');
+    await loadPosts();
+  } catch (err) {
+    toast('Calendar generation failed: ' + err.message, 'error');
+  }
+}
+
+async function generateAllImages() {
+  if (!confirm('Generate Gemini AI images for all posts without images? This may take a while.')) return;
+
+  try {
+    toast('Generating all images with Gemini AI...', 'info');
+    const result = await api('/api/posts/generate-all-images', { method: 'POST' });
+    const success = result.results.filter(r => r.success).length;
+    const failed = result.results.filter(r => !r.success).length;
+    toast('Generated ' + success + ' images' + (failed ? ', ' + failed + ' failed' : ''), success ? 'success' : 'error');
+    await loadPosts();
+  } catch (err) {
+    toast('Bulk generation failed: ' + err.message, 'error');
+  }
+}
+
+// ─── Instagram Connect ───────────────────────────────────
+function showIGOptions() {
+  document.getElementById('ig-connect-overlay').classList.add('open');
+}
+function closeIGModal() {
+  document.getElementById('ig-connect-overlay').classList.remove('open');
+}
+function igLoginStandard() {
+  closeIGModal();
+  window.open('https://www.instagram.com/', '_blank');
+  // Mark as standard-login connected
+  sessionStorage.setItem('ig_standard_login', '1');
+  const igEl = document.querySelector('.ig-status');
+  const statusText = document.querySelector('.ig-status span:last-child');
+  if (igEl) igEl.classList.add('connected');
+  if (statusText) statusText.textContent = 'Logged In (Manual)';
+  toast('Instagram opened — log in, then come back and hit Publish to copy & post!', 'info');
+}
+async function igConnectAPI() {
+  closeIGModal();
+  try {
+    const status = await api('/api/instagram/status');
+    if (status.connected) {
+      toast('Instagram API is already connected!', 'success');
+      return;
+    }
+    if (!status.configured) {
+      toast('Configure Meta App ID and App Secret in Settings first', 'error');
+      switchTab('settings');
+      return;
+    }
+    window.location.href = '/auth/instagram';
+  } catch (err) {
+    toast('Failed to check Instagram status: ' + err.message, 'error');
+  }
+}
+
+async function checkIGStatus() {
+  try {
+    const status = await api('/api/instagram/status');
+    const igEl = document.querySelector('.ig-status');
+    const statusText = document.querySelector('.ig-status span:last-child');
+    if (status.connected) {
+      igEl.classList.add('connected');
+      if (statusText) statusText.textContent = 'Connected (API)';
+    } else if (sessionStorage.getItem('ig_standard_login')) {
+      if (igEl) igEl.classList.add('connected');
+      if (statusText) statusText.textContent = 'Logged In (Manual)';
+    } else if (status.configured) {
+      if (statusText) statusText.textContent = 'Not Connected';
+    } else {
+      if (statusText) statusText.textContent = 'Ready to Connect';
+    }
+  } catch (err) {}
+}
+
+// ─── Settings ────────────────────────────────────────────
+async function loadSettings() {
+  try {
+    const settings = await api('/api/settings');
+    Object.entries(settings).forEach(([key, value]) => {
+      const el = document.getElementById('setting-' + key);
+      if (el) el.value = value || '';
+    });
+  } catch (err) {
+    toast('Failed to load settings', 'error');
+  }
+}
+
+async function saveSettings() {
+  const keys = ['store_name', 'store_address', 'app_url', 'ig_app_id', 'ig_app_secret'];
+  const data = {};
+  keys.forEach(k => {
+    const el = document.getElementById('setting-' + k);
+    if (el) data[k] = el.value;
+  });
+
+  try {
+    await api('/api/settings', { method: 'PUT', body: JSON.stringify(data) });
+    toast('Settings saved', 'success');
+  } catch (err) {
+    toast('Save failed: ' + err.message, 'error');
+  }
+}
+
+// ─── Helpers ─────────────────────────────────────────────
+function formatDate(iso) {
+  if (!iso) return '';
+  const d = new Date(iso);
+  return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', weekday: 'short' }) +
+    ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+}
+
+// ─── Init ────────────────────────────────────────────────
+document.addEventListener('DOMContentLoaded', () => {
+  loadPosts();
+  checkIGStatus();
+
+  const params = new URLSearchParams(window.location.search);
+  if (params.get('ig') === 'connected') {
+    toast('Instagram connected successfully!', 'success');
+    document.querySelector('.ig-status').classList.add('connected');
+    const statusText = document.querySelector('.ig-status span:last-child');
+    if (statusText) statusText.textContent = 'Connected';
+    window.history.replaceState({}, '', '/');
+  }
+  if (params.get('ig') === 'error') {
+    toast('Instagram connection failed: ' + (params.get('msg') || 'Unknown error'), 'error');
+    window.history.replaceState({}, '', '/');
+  }
+
+  document.getElementById('modal-overlay').addEventListener('click', (e) => {
+    if (e.target === e.currentTarget) closeModal();
+  });
+
+  document.getElementById('ig-connect-overlay').addEventListener('click', (e) => {
+    if (e.target === e.currentTarget) closeIGModal();
+  });
+
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape') { closeModal(); closeIGModal(); }
+  });
+});
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..080706c
--- /dev/null
+++ b/server.js
@@ -0,0 +1,559 @@
+#!/usr/bin/env node
+/**
+ * SmokeShop — Instagram Content Manager for Smoke & Vape Depot
+ * Port: 8401 | PM2: smokeshop
+ */
+
+require('dotenv').config();
+const express = require('express');
+const helmet = require('helmet');
+const cors = require('cors');
+const path = require('path');
+const fs = require('fs');
+const multer = require('multer');
+const Database = require('better-sqlite3');
+const imageGen = require('./lib/image-generator');
+const calendar = require('./lib/content-calendar');
+const ig = require('./lib/instagram-api');
+const scheduler = require('./lib/scheduler');
+const gemini = require('./lib/gemini');
+
+const PORT = process.env.PORT || 8401;
+const AUTH_USER = process.env.AUTH_USER || 'admin';
+const AUTH_PASS = process.env.AUTH_PASS || 'DWSecure2024!';
+
+const app = express();
+// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
+app.use(helmet({ contentSecurityPolicy: false }));
+app.use(cors());
+app.use(express.json());
+
+// ─── Auth Middleware ──────────────────────────────────────────────────
+function basicAuth(req, res, next) {
+  if (req.path === '/health') return next();
+
+  const authHeader = req.headers.authorization;
+  if (!authHeader || !authHeader.startsWith('Basic ')) {
+    res.setHeader('WWW-Authenticate', 'Basic realm="SmokeShop"');
+    return res.status(401).json({ error: 'Authentication required' });
+  }
+
+  const decoded = Buffer.from(authHeader.split(' ')[1], 'base64').toString();
+  const [user, pass] = decoded.split(':');
+  if (user === AUTH_USER && pass === AUTH_PASS) return next();
+
+  res.setHeader('WWW-Authenticate', 'Basic realm="SmokeShop"');
+  return res.status(401).json({ error: 'Invalid credentials' });
+}
+
+app.use(basicAuth);
+app.use(express.static(path.join(__dirname, 'public'), { etag: false, maxAge: 0, setHeaders: (res) => { res.set('Cache-Control', 'no-store'); } }));
+app.use('/generated', express.static(path.join(__dirname, 'generated')));
+
+// ─── Database Setup ──────────────────────────────────────────────────
+const DB_PATH = path.join(__dirname, 'db', 'smokeshop.db');
+const dbDir = path.dirname(DB_PATH);
+if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
+
+const db = new Database(DB_PATH);
+db.pragma('journal_mode = WAL');
+
+db.exec(`
+  CREATE TABLE IF NOT EXISTS posts (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    title TEXT,
+    caption TEXT,
+    image_path TEXT,
+    template TEXT DEFAULT 'a',
+    category TEXT,
+    status TEXT DEFAULT 'draft',
+    scheduled_at TEXT,
+    posted_at TEXT,
+    ig_media_id TEXT,
+    engagement TEXT,
+    created_at TEXT DEFAULT (datetime('now'))
+  );
+
+  CREATE TABLE IF NOT EXISTS settings (
+    key TEXT PRIMARY KEY,
+    value TEXT
+  );
+`);
+
+// Initialize default settings
+const settingsCount = db.prepare('SELECT COUNT(*) as cnt FROM settings').get().cnt;
+if (settingsCount === 0) {
+  const defaults = {
+    store_name: 'Smoke & Vape Depot',
+    store_address: '6100 Reseda Blvd, Reseda CA',
+    app_url: process.env.APP_URL || 'http://45.61.58.125:8401',
+  };
+  const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
+  for (const [k, v] of Object.entries(defaults)) {
+    insertSetting.run(k, v);
+  }
+}
+
+// ─── File Upload ─────────────────────────────────────────────────────
+const uploadDir = path.join(__dirname, 'public', 'uploads');
+if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
+
+const upload = multer({
+  storage: multer.diskStorage({
+    destination: uploadDir,
+    filename: (req, file, cb) => {
+      const ext = path.extname(file.originalname);
+      cb(null, 'upload-' + Date.now() + ext);
+    },
+  }),
+  limits: { fileSize: 10 * 1024 * 1024 },
+  fileFilter: (req, file, cb) => {
+    const allowed = /jpeg|jpg|png|webp|gif/;
+    if (allowed.test(path.extname(file.originalname).toLowerCase())) {
+      cb(null, true);
+    } else {
+      cb(new Error('Only image files allowed'));
+    }
+  },
+});
+
+// ─── Health ──────────────────────────────────────────────────────────
+app.get('/health', (req, res) => {
+  res.json({ status: 'ok', agent: 'SmokeShop', port: PORT, uptime: process.uptime() });
+});
+
+// ─── Public Spreadsheet Download (no auth) ──────────────────────────
+app.get('/spreadsheet', (req, res) => {
+  const filePath = path.join(__dirname, 'Smoke Shop.xlsx');
+  if (!fs.existsSync(filePath)) return res.status(404).send('Spreadsheet not generated yet');
+  res.download(filePath, 'Smoke Shop.xlsx');
+});
+
+// ─── Posts CRUD ──────────────────────────────────────────────────────
+app.get('/api/posts', (req, res) => {
+  const { status, category } = req.query;
+  let sql = 'SELECT * FROM posts';
+  const conditions = [];
+  const params = [];
+
+  if (status) { conditions.push('status = ?'); params.push(status); }
+  if (category) { conditions.push('category = ?'); params.push(category); }
+
+  if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
+  sql += ' ORDER BY scheduled_at ASC, created_at DESC';
+
+  res.json(db.prepare(sql).all(...params));
+});
+
+app.get('/api/posts/:id', (req, res) => {
+  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+  if (!post) return res.status(404).json({ error: 'Post not found' });
+  res.json(post);
+});
+
+app.post('/api/posts', (req, res) => {
+  const { title, caption, template, category, scheduled_at } = req.body;
+  if (!title) return res.status(400).json({ error: 'Title is required' });
+
+  const result = db.prepare(
+    'INSERT INTO posts (title, caption, template, category, scheduled_at) VALUES (?, ?, ?, ?, ?)'
+  ).run(title, caption || '', template || 'a', category || 'product', scheduled_at || null);
+
+  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(result.lastInsertRowid);
+  res.status(201).json(post);
+});
+
+app.put('/api/posts/:id', (req, res) => {
+  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+  if (!post) return res.status(404).json({ error: 'Post not found' });
+
+  const { title, caption, template, category, scheduled_at, status } = req.body;
+  db.prepare(`
+    UPDATE posts SET
+      title = COALESCE(?, title),
+      caption = COALESCE(?, caption),
+      template = COALESCE(?, template),
+      category = COALESCE(?, category),
+      scheduled_at = COALESCE(?, scheduled_at),
+      status = COALESCE(?, status)
+    WHERE id = ?
+  `).run(title, caption, template, category, scheduled_at, status, req.params.id);
+
+  res.json(db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id));
+});
+
+app.delete('/api/posts/:id', (req, res) => {
+  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+  if (!post) return res.status(404).json({ error: 'Post not found' });
+
+  if (post.image_path && fs.existsSync(post.image_path)) {
+    fs.unlinkSync(post.image_path);
+  }
+
+  db.prepare('DELETE FROM posts WHERE id = ?').run(req.params.id);
+  res.json({ deleted: true, id: Number(req.params.id) });
+});
+
+// ─── Image Generation ────────────────────────────────────────────────
+app.post('/api/posts/:id/generate', async (req, res) => {
+  try {
+    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+    if (!post) return res.status(404).json({ error: 'Post not found' });
+
+    const { backgroundImage } = req.body;
+    const result = await imageGen.generatePost({
+      title: post.title,
+      backgroundImage: backgroundImage || null,
+      template: post.template,
+      category: post.category,
+      postId: post.id,
+    });
+
+    db.prepare('UPDATE posts SET image_path = ? WHERE id = ?').run(result.path, post.id);
+    res.json({ success: true, filename: result.filename, path: result.path });
+  } catch (err) {
+    console.error('[Generate]', err);
+    res.status(500).json({ error: err.message });
+  }
+});
+
+app.get('/api/preview/:id', (req, res) => {
+  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+  if (!post || !post.image_path || !fs.existsSync(post.image_path)) {
+    return res.status(404).json({ error: 'No generated image' });
+  }
+  res.sendFile(post.image_path);
+});
+
+// ─── Photo Upload to Post ───────────────────────────────────────────
+app.post('/api/posts/:id/upload-photo', upload.single('image'), async (req, res) => {
+  try {
+    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+    if (!post) return res.status(404).json({ error: 'Post not found' });
+    if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
+
+    const sharp = require('sharp');
+    const genDir = path.join(__dirname, 'generated');
+    if (!fs.existsSync(genDir)) fs.mkdirSync(genDir, { recursive: true });
+
+    const outPath = path.join(genDir, 'post-' + post.id + '.png');
+    await sharp(req.file.path)
+      .resize(1080, 1080, { fit: 'cover' })
+      .png()
+      .toFile(outPath);
+
+    // Clean up upload
+    fs.unlinkSync(req.file.path);
+
+    db.prepare('UPDATE posts SET image_path = ? WHERE id = ?').run(outPath, post.id);
+    res.json({ success: true, path: outPath });
+  } catch (err) {
+    console.error('[Upload Photo]', err);
+    res.status(500).json({ error: err.message });
+  }
+});
+
+// ─── AI From Photo (analyze image → title + caption) ────────────────
+const STORE_CONTEXT = `
+ABOUT THE STORE:
+Smoke & Vape Depot — 6100 Reseda Blvd, Reseda, CA 91335
+Located in the heart of Reseda on Reseda Boulevard in the San Fernando Valley (818 area code).
+Open late every night (until 12 AM). Neighborhood smoke shop serving the local community.
+
+PRODUCTS WE CARRY (Instagram-compliant items ONLY):
+- Rolling Papers & Wraps: RAW (our top seller), Elements, Juicy Jay's, Zig-Zag, King Palm
+- Lighters: Clipper (collectible designs), Zippo, BIC, torch lighters
+- Glassware & Art Pieces: GRAV Labs, MAV Glass, Diamond Glass — hand-blown bubblers, beakers, rigs
+- CBD & Wellness: Charlotte's Web, cbdMD — tinctures, gummies, topicals (fully compliant, no health claims)
+- Grinders: Santa Cruz Shredder, Cali Crusher, SharpStone
+- Accessories: Rolling trays, stash jars, odor-proof bags, cleaning solutions (Formula 420)
+- Incense & candles, tapestries, posters, stickers
+
+NEIGHBORHOOD & COMMUNITY:
+- Right next to Wingstop (6048 Reseda Blvd) — regulars grab wings then stop by
+- Reseda is a diverse, working-class SFValley neighborhood with a growing foodie scene
+- Nearby: local barbershops, taquerias, Reseda Park, the Sherman Way corridor
+- We support local businesses and community events
+
+BRAND VOICE:
+- Friendly, authentic, neighborhood vibe — NOT corporate
+- We're "your local smoke shop" — approachable, knowledgeable staff
+- Emphasize the craft/art side of glassware, the community connection
+- Use casual language, feel like a friend recommending something cool
+
+INSTAGRAM COMPLIANCE (MANDATORY):
+- NEVER reference smoking, vaping, tobacco use, or inhaling
+- NEVER make health claims about CBD or any product
+- NEVER target minors — always include "21+ only" at the end
+- OK to show: product packaging, store shelves, glassware as art, CBD products, accessories, store exterior/interior
+- OK to tag: community, local businesses, neighborhood pride
+`.trim();
+
+app.post('/api/posts/:id/ai-from-photo', async (req, res) => {
+  try {
+    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+    if (!post) return res.status(404).json({ error: 'Post not found' });
+    if (!post.image_path || !fs.existsSync(post.image_path)) {
+      return res.status(400).json({ error: 'No photo uploaded yet' });
+    }
+
+    const imageData = fs.readFileSync(post.image_path).toString('base64');
+
+    const prompt = `You are the social media manager for Smoke & Vape Depot. Use the store background below to create an Instagram post based on this photo.
+
+${STORE_CONTEXT}
+
+INSTRUCTIONS:
+1. Analyze the photo — identify what's shown (products, store interior, neighborhood, etc.)
+2. Create a post that connects what's in the photo to the store's actual offerings and community
+3. If the photo shows products we carry, mention the specific brands by name
+4. If it's a store/neighborhood shot, play up the Reseda community angle
+5. Make the caption feel authentic and local — like a real neighborhood shop owner wrote it
+
+Respond with JSON only:
+{
+  "title": "4 words max — punchy, attention-grabbing",
+  "caption": "2-3 sentences max. Engaging, authentic tone. Include a call to action (stop by, check it out, come see). End with 21+ only. Then a new line with 8-10 hashtags: #SmokeAndVapeDepot #ResedaCA #SFValley #818 #ShopLocal plus category-relevant ones",
+  "category": "community OR product OR promo"
+}
+
+Category guide:
+- "product" if the photo shows merchandise, glassware, accessories, CBD
+- "community" if it shows the neighborhood, local spots, people, events
+- "promo" if it shows the storefront, sales, hours, deals`;
+
+    const result = await gemini.generateText(prompt, {
+      images: [{ mimeType: 'image/png', data: imageData }],
+      temperature: 0.9,
+    });
+
+    let parsed;
+    try {
+      const jsonMatch = result.match(/\{[\s\S]*\}/);
+      parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : { title: 'New Photo Post', caption: result, category: 'product' };
+    } catch {
+      parsed = { title: 'New Photo Post', caption: result, category: 'product' };
+    }
+
+    // Also update the post in DB with the generated content
+    db.prepare('UPDATE posts SET title = ?, caption = ?, category = ? WHERE id = ?')
+      .run(parsed.title, parsed.caption, parsed.category, post.id);
+
+    res.json(parsed);
+  } catch (err) {
+    console.error('[AI From Photo]', err);
+    res.status(500).json({ error: err.message });
+  }
+});
+
+// ─── Calendar Generation ─────────────────────────────────────────────
+app.post('/api/posts/generate-calendar', (req, res) => {
+  const { startDate, clearExisting } = req.body;
+
+  if (clearExisting) {
+    db.prepare("DELETE FROM posts WHERE status = 'draft'").run();
+  }
+
+  const posts = calendar.generateCalendar(startDate);
+  const insert = db.prepare(
+    'INSERT INTO posts (title, caption, template, category, status, scheduled_at) VALUES (?, ?, ?, ?, ?, ?)'
+  );
+
+  const insertMany = db.transaction((posts) => {
+    for (const p of posts) {
+      insert.run(p.title, p.caption, p.template, p.category, p.status, p.scheduled_at);
+    }
+  });
+
+  insertMany(posts);
+
+  const allPosts = db.prepare('SELECT * FROM posts ORDER BY scheduled_at ASC').all();
+  res.json({ generated: posts.length, total: allPosts.length, posts: allPosts });
+});
+
+// ─── Bulk Generate Images ────────────────────────────────────────────
+app.post('/api/posts/generate-all-images', async (req, res) => {
+  const posts = db.prepare("SELECT * FROM posts WHERE image_path IS NULL OR image_path = ''").all();
+  const results = [];
+
+  for (const post of posts) {
+    try {
+      const result = await imageGen.generatePost({
+        title: post.title,
+        template: post.template,
+        category: post.category,
+        postId: post.id,
+      });
+      db.prepare('UPDATE posts SET image_path = ? WHERE id = ?').run(result.path, post.id);
+      results.push({ id: post.id, success: true, filename: result.filename });
+    } catch (err) {
+      results.push({ id: post.id, success: false, error: err.message });
+    }
+  }
+
+  res.json({ processed: results.length, results });
+});
+
+// ─── File Upload ─────────────────────────────────────────────────────
+app.post('/api/upload', upload.single('image'), (req, res) => {
+  if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
+  res.json({
+    success: true,
+    filename: req.file.filename,
+    path: req.file.path,
+    url: '/uploads/' + req.file.filename,
+  });
+});
+
+// ─── Instagram OAuth ─────────────────────────────────────────────────
+app.get('/api/instagram/status', (req, res) => {
+  const appId = getSetting('ig_app_id') || process.env.META_APP_ID;
+  const appSecret = getSetting('ig_app_secret') || process.env.META_APP_SECRET;
+  const token = getSetting('ig_access_token');
+  const accountId = getSetting('ig_business_account_id');
+
+  res.json({
+    configured: !!(appId && appSecret),
+    connected: !!(token && accountId),
+    hasAppId: !!appId,
+    hasAppSecret: !!appSecret,
+  });
+});
+
+app.get('/auth/instagram', (req, res) => {
+  const appId = getSetting('ig_app_id') || process.env.META_APP_ID;
+  if (!appId) {
+    return res.redirect('/?ig=error&msg=' + encodeURIComponent('Configure Meta App ID in Settings first'));
+  }
+
+  const redirectUri = (getSetting('app_url') || process.env.APP_URL) + '/auth/instagram/callback';
+  const authUrl = ig.getAuthUrl(appId, redirectUri);
+  res.redirect(authUrl);
+});
+
+app.get('/auth/instagram/callback', async (req, res) => {
+  try {
+    const { code } = req.query;
+    if (!code) return res.status(400).send('No authorization code received');
+
+    const appId = getSetting('ig_app_id') || process.env.META_APP_ID;
+    const appSecret = getSetting('ig_app_secret') || process.env.META_APP_SECRET;
+    const redirectUri = (getSetting('app_url') || process.env.APP_URL) + '/auth/instagram/callback';
+
+    const tokenData = await ig.exchangeCodeForToken(code, appId, appSecret, redirectUri);
+    const longToken = await ig.getLongLivedToken(tokenData.access_token, appId, appSecret);
+    const igAccount = await ig.getIGBusinessAccount(longToken.access_token);
+
+    upsertSetting('ig_access_token', longToken.access_token);
+    upsertSetting('ig_business_account_id', igAccount.igAccountId);
+
+    res.redirect('/?ig=connected');
+  } catch (err) {
+    console.error('[OAuth]', err);
+    res.redirect('/?ig=error&msg=' + encodeURIComponent(err.message));
+  }
+});
+
+// ─── Manual Publish ──────────────────────────────────────────────────
+app.post('/api/posts/:id/publish', async (req, res) => {
+  try {
+    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+    if (!post) return res.status(404).json({ error: 'Post not found' });
+    if (!post.image_path) return res.status(400).json({ error: 'Generate image first' });
+
+    const accessToken = getSetting('ig_access_token');
+    const igAccountId = getSetting('ig_business_account_id');
+    if (!accessToken || !igAccountId) {
+      return res.status(400).json({ error: 'Instagram not connected' });
+    }
+
+    const appUrl = getSetting('app_url') || process.env.APP_URL;
+    const imageUrl = appUrl + '/generated/' + path.basename(post.image_path);
+
+    const result = await ig.publishPost(igAccountId, accessToken, imageUrl, post.caption);
+
+    db.prepare(
+      "UPDATE posts SET status = 'posted', posted_at = ?, ig_media_id = ? WHERE id = ?"
+    ).run(new Date().toISOString(), result.mediaId, post.id);
+
+    res.json({ success: true, mediaId: result.mediaId });
+  } catch (err) {
+    console.error('[Publish]', err);
+    db.prepare("UPDATE posts SET status = 'failed' WHERE id = ?").run(req.params.id);
+    res.status(500).json({ error: err.message });
+  }
+});
+
+// ─── Gemini AI Caption Generation ────────────────────────────────────
+app.post('/api/posts/:id/generate-caption', async (req, res) => {
+  try {
+    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
+    if (!post) return res.status(404).json({ error: 'Post not found' });
+
+    const caption = await gemini.generateCaption({
+      title: post.title,
+      category: post.category,
+      storeName: 'Smoke & Vape Depot',
+    });
+
+    db.prepare('UPDATE posts SET caption = ? WHERE id = ?').run(caption, post.id);
+    res.json({ success: true, caption });
+  } catch (err) {
+    console.error('[Gemini Caption]', err);
+    res.status(500).json({ error: err.message });
+  }
+});
+
+// ─── Settings ────────────────────────────────────────────────────────
+app.get('/api/settings', (req, res) => {
+  const rows = db.prepare('SELECT * FROM settings').all();
+  const settings = {};
+  rows.forEach(r => {
+    if (r.key.includes('token') || r.key.includes('secret')) {
+      settings[r.key] = r.value ? '****' + r.value.slice(-4) : '';
+    } else {
+      settings[r.key] = r.value;
+    }
+  });
+  res.json(settings);
+});
+
+app.put('/api/settings', (req, res) => {
+  const updates = req.body;
+  for (const [key, value] of Object.entries(updates)) {
+    if (typeof value === 'string' && value.startsWith('****')) continue;
+    upsertSetting(key, value);
+  }
+  res.json({ success: true });
+});
+
+// ─── Stats ───────────────────────────────────────────────────────────
+app.get('/api/stats', (req, res) => {
+  const total = db.prepare('SELECT COUNT(*) as cnt FROM posts').get().cnt;
+  const byStatus = db.prepare('SELECT status, COUNT(*) as cnt FROM posts GROUP BY status').all();
+  const byCategory = db.prepare('SELECT category, COUNT(*) as cnt FROM posts GROUP BY category').all();
+  const upcoming = db.prepare(
+    "SELECT * FROM posts WHERE status = 'scheduled' AND scheduled_at > datetime('now') ORDER BY scheduled_at ASC LIMIT 5"
+  ).all();
+
+  res.json({ total, byStatus, byCategory, upcoming });
+});
+
+// ─── Helpers ─────────────────────────────────────────────────────────
+function getSetting(key) {
+  const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
+  return row ? row.value : null;
+}
+
+function upsertSetting(key, value) {
+  db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
+}
+
+// ─── Start Scheduler + Server ────────────────────────────────────────
+scheduler.init(db);
+scheduler.start();
+
+app.listen(PORT, () => {
+  console.log('[SmokeShop] Running on port ' + PORT);
+  console.log('[SmokeShop] Dashboard: http://45.61.58.125:' + PORT);
+});
diff --git a/shutdown-monday.sh b/shutdown-monday.sh
new file mode 100755
index 0000000..ebf591d
--- /dev/null
+++ b/shutdown-monday.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+# Shutdown SmokeShop at Monday 8am PT (scheduled via cron at 15:00 UTC)
+# Send Slack notification to Steve, then stop PM2 process
+
+SLACK_WEBHOOK="https://hooks.slack.com/services/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"
+
+# Notify Steve via Slack
+curl -s -X POST "$SLACK_WEBHOOK" \
+  -H 'Content-type: application/json' \
+  -d '{"text":"*SmokeShop (8401) has been shut down* as scheduled.\nMonday 8:00 AM PT — the demo site at http://45.61.58.125:8401 is now offline.\nTo restart: `pm2 start smokeshop`"}'
+
+# Stop the PM2 process
+pm2 stop smokeshop
+
+# Remove this cron job (self-cleanup)
+crontab -l 2>/dev/null | grep -v "shutdown-monday" | crontab -
+
+echo "[SmokeShop] Shutdown complete at $(date)"
diff --git a/templates/template-a/config.json b/templates/template-a/config.json
new file mode 100644
index 0000000..c05f662
--- /dev/null
+++ b/templates/template-a/config.json
@@ -0,0 +1,48 @@
+{
+  "name": "Smoke & Vape Depot — Dark",
+  "description": "Dark smoky palette with bold sans-serif. Primary branded template.",
+  "background": {
+    "type": "gradient",
+    "colors": ["#1a1a2e", "#16213e"],
+    "direction": "diagonal"
+  },
+  "border": {
+    "width": 40,
+    "color": "#e94560",
+    "innerColor": "#0f3460",
+    "style": "solid",
+    "cornerRadius": 0
+  },
+  "text": {
+    "fontFamily": "DejaVu Sans",
+    "fontWeight": "bold",
+    "fontSize": 72,
+    "color": "#ffffff",
+    "shadowColor": "rgba(233, 69, 96, 0.5)",
+    "shadowBlur": 20,
+    "position": "center",
+    "maxWords": 4
+  },
+  "storeName": {
+    "text": "SMOKE & VAPE DEPOT",
+    "fontSize": 28,
+    "color": "#e94560",
+    "position": "bottom"
+  },
+  "address": {
+    "text": "6100 Reseda Blvd, Reseda CA",
+    "fontSize": 18,
+    "color": "#aaaaaa"
+  },
+  "ageDisclaimer": {
+    "text": "21+ ONLY",
+    "fontSize": 16,
+    "color": "#666666"
+  },
+  "accent": "#e94560",
+  "categoryColors": {
+    "community": "#4ecdc4",
+    "product": "#e94560",
+    "promo": "#ffd93d"
+  }
+}
diff --git a/templates/template-b/config.json b/templates/template-b/config.json
new file mode 100644
index 0000000..ce1d862
--- /dev/null
+++ b/templates/template-b/config.json
@@ -0,0 +1,48 @@
+{
+  "name": "Smoke & Vape Depot — Clean",
+  "description": "Clean modern design with lighter palette. A/B test variant.",
+  "background": {
+    "type": "gradient",
+    "colors": ["#2d2d2d", "#1a1a1a"],
+    "direction": "vertical"
+  },
+  "border": {
+    "width": 32,
+    "color": "#ff6b35",
+    "innerColor": "#333333",
+    "style": "double",
+    "cornerRadius": 0
+  },
+  "text": {
+    "fontFamily": "DejaVu Sans",
+    "fontWeight": "bold",
+    "fontSize": 68,
+    "color": "#ffffff",
+    "shadowColor": "rgba(255, 107, 53, 0.4)",
+    "shadowBlur": 15,
+    "position": "center",
+    "maxWords": 4
+  },
+  "storeName": {
+    "text": "SMOKE & VAPE DEPOT",
+    "fontSize": 26,
+    "color": "#ff6b35",
+    "position": "bottom"
+  },
+  "address": {
+    "text": "6100 Reseda Blvd, Reseda CA",
+    "fontSize": 17,
+    "color": "#999999"
+  },
+  "ageDisclaimer": {
+    "text": "21+ ONLY",
+    "fontSize": 16,
+    "color": "#666666"
+  },
+  "accent": "#ff6b35",
+  "categoryColors": {
+    "community": "#00b894",
+    "product": "#ff6b35",
+    "promo": "#fdcb6e"
+  }
+}

(oldest)  ·  back to SmokeShop  ·  snapshot: 2 file(s) changed, ~2 modified 6bf7b90 →