Replace entire repo content with kottgard-production-v1.1.zip (for shahikitchen-prod repo)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare a sharp, full-frame beef boneless product photo for the shop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageFilter, ImageOps, ImageStat
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SITE = ROOT / "public" / "images" / "site"
|
||||
OUT_PATH = SITE / "beef-boneless.jpg"
|
||||
SOURCE_CACHE = SITE / "_beef_boneless_src.jpg"
|
||||
|
||||
# High-res premium raw boneless beef (Unsplash)
|
||||
PRIMARY_URL = (
|
||||
"https://images.unsplash.com/photo-1603048297172-c92544798d5a"
|
||||
"?auto=format&fit=crop&w=1800&h=1800&crop=center&q=95"
|
||||
)
|
||||
# Original product reference (lower res fallback)
|
||||
FALLBACK_URL = (
|
||||
"https://www.hotcurrymarket.fi/wp-content/uploads/2025/01/"
|
||||
"FRESH-BEEF-BONELESS-WITHOUT-FAT-1KG.jpg"
|
||||
)
|
||||
|
||||
OUTPUT_SIZE = (1400, 1400)
|
||||
|
||||
|
||||
def download(url: str, dest: Path) -> None:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "Mozilla/5.0 (compatible; Kottgard-site-builder/1.0)"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
dest.write_bytes(resp.read())
|
||||
|
||||
|
||||
def trim_near_white(img: Image.Image, tolerance: int = 18) -> Image.Image:
|
||||
"""Remove excess light borders from the source photo."""
|
||||
rgb = img.convert("RGB")
|
||||
pixels = rgb.load()
|
||||
w, h = rgb.size
|
||||
|
||||
def row_is_border(y: int) -> bool:
|
||||
samples = [pixels[x, y] for x in range(0, w, max(1, w // 24))]
|
||||
return all(min(p) >= 255 - tolerance for p in samples)
|
||||
|
||||
def col_is_border(x: int) -> bool:
|
||||
samples = [pixels[x, y] for y in range(0, h, max(1, h // 24))]
|
||||
return all(min(p) >= 255 - tolerance for p in samples)
|
||||
|
||||
top = 0
|
||||
while top < h and row_is_border(top):
|
||||
top += 1
|
||||
bottom = h - 1
|
||||
while bottom > top and row_is_border(bottom):
|
||||
bottom -= 1
|
||||
left = 0
|
||||
while left < w and col_is_border(left):
|
||||
left += 1
|
||||
right = w - 1
|
||||
while right > left and col_is_border(right):
|
||||
right -= 1
|
||||
|
||||
if right - left > 80 and bottom - top > 80:
|
||||
return rgb.crop((left, top, right + 1, bottom + 1))
|
||||
return rgb
|
||||
|
||||
|
||||
def enhance_photo(img: Image.Image) -> Image.Image:
|
||||
img = ImageOps.autocontrast(img, cutoff=0.5)
|
||||
img = ImageEnhance.Brightness(img).enhance(1.03)
|
||||
img = ImageEnhance.Contrast(img).enhance(1.08)
|
||||
img = ImageEnhance.Color(img).enhance(1.1)
|
||||
img = ImageEnhance.Sharpness(img).enhance(1.35)
|
||||
return img.filter(ImageFilter.UnsharpMask(radius=1.0, percent=110, threshold=2))
|
||||
|
||||
|
||||
def crop_center_cover(img: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
tw, th = size
|
||||
w, h = img.size
|
||||
scale = max(tw / w, th / h)
|
||||
nw, nh = int(w * scale), int(h * scale)
|
||||
resized = img.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||
left = (nw - tw) // 2
|
||||
top = (nh - th) // 2
|
||||
return resized.crop((left, top, left + tw, top + th))
|
||||
|
||||
|
||||
def prepare_product_image(source: Path, output: Path = OUT_PATH) -> None:
|
||||
beef = Image.open(source).convert("RGB")
|
||||
beef = trim_near_white(beef)
|
||||
beef = enhance_photo(beef)
|
||||
# Full-frame crop — meat fills the product image (no tiny subject in white void)
|
||||
beef = crop_center_cover(beef, OUTPUT_SIZE)
|
||||
beef.save(output, "JPEG", quality=96, optimize=True, subsampling=0)
|
||||
print(f"Saved product photo {OUTPUT_SIZE[0]}x{OUTPUT_SIZE[1]} → {output}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Prepare beef boneless product photo")
|
||||
parser.add_argument("--url", default=PRIMARY_URL)
|
||||
parser.add_argument("--input", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=OUT_PATH)
|
||||
parser.add_argument("--use-cache", action="store_true")
|
||||
parser.add_argument("--fallback", action="store_true", help="Use hot curry source")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.input:
|
||||
src = args.input
|
||||
elif args.use_cache and SOURCE_CACHE.exists():
|
||||
src = SOURCE_CACHE
|
||||
print(f"Using cached source → {src}")
|
||||
else:
|
||||
url = FALLBACK_URL if args.fallback else args.url
|
||||
print(f"Downloading source image…\n {url}")
|
||||
try:
|
||||
download(url, SOURCE_CACHE)
|
||||
except Exception as exc:
|
||||
print(f"Primary download failed ({exc}), trying fallback…")
|
||||
download(FALLBACK_URL, SOURCE_CACHE)
|
||||
src = SOURCE_CACHE
|
||||
|
||||
prepare_product_image(src, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Production build must not reuse a dev .next folder.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
pkill -f "next dev" 2>/dev/null || true
|
||||
rm -rf .next
|
||||
echo "Building production bundle (clean cache)…"
|
||||
exec npx next build
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Shahi Kitchen Production Deploy Script
|
||||
# Run as: sudo -u deploy /var/www/shahikitchen.se/scripts/deploy.sh
|
||||
#
|
||||
# This script supports two modes:
|
||||
# 1. Tarball mode (current primary method): Place new shahi.tar.gz in /tmp or /root and run
|
||||
# 2. Git mode (if you initialize git in the future)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="/var/www/shahikitchen.se"
|
||||
PM2_APP_NAME="shahikitchen"
|
||||
PORT=3001
|
||||
|
||||
echo "=========================================="
|
||||
echo " Shahi Kitchen - Production Deploy"
|
||||
echo " $(date)"
|
||||
echo "=========================================="
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# --- Detect mode ---
|
||||
if [ -f "/tmp/shahi.tar.gz" ] || [ -f "/root/shahi.tar.gz" ]; then
|
||||
echo "[1/6] Tarball update detected"
|
||||
TARBALL=""
|
||||
if [ -f "/tmp/shahi.tar.gz" ]; then TARBALL="/tmp/shahi.tar.gz"; fi
|
||||
if [ -f "/root/shahi.tar.gz" ]; then TARBALL="/root/shahi.tar.gz"; fi
|
||||
|
||||
echo "Using tarball: $TARBALL"
|
||||
|
||||
echo "Stopping PM2 app (graceful)..."
|
||||
pm2 stop "$PM2_APP_NAME" || true
|
||||
|
||||
echo "Backing up current .next (quick safety)..."
|
||||
rm -rf .next.bak 2>/dev/null || true
|
||||
cp -a .next .next.bak 2>/dev/null || true
|
||||
|
||||
echo "Extracting new tarball..."
|
||||
tar --strip-components=1 -xzf "$TARBALL"
|
||||
|
||||
echo "Cleaning shipped node_modules + cache..."
|
||||
rm -rf node_modules .next/cache 2>/dev/null || true
|
||||
|
||||
echo "Running npm ci..."
|
||||
npm ci
|
||||
|
||||
echo "Building..."
|
||||
npm run build
|
||||
|
||||
elif git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "[1/6] Git update mode"
|
||||
pm2 stop "$PM2_APP_NAME" || true
|
||||
git fetch --all
|
||||
git reset --hard origin/main || git reset --hard origin/master
|
||||
npm ci
|
||||
npm run build
|
||||
else
|
||||
echo "ERROR: No tarball found in /tmp or /root, and no git repository."
|
||||
echo "Please either:"
|
||||
echo " - scp your new shahi.tar.gz to the server, or"
|
||||
echo " - Run: cp /path/to/shahi.tar.gz /tmp/shahi.tar.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[2/6] Dependencies and build complete"
|
||||
|
||||
echo "[3/6] Starting / restarting PM2..."
|
||||
pm2 start ecosystem.config.cjs --only "$PM2_APP_NAME" || pm2 reload "$PM2_APP_NAME" --update-env || true
|
||||
pm2 save
|
||||
|
||||
echo "[4/6] Reloading Nginx..."
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
echo "[5/6] Post-deploy health checks..."
|
||||
echo "PM2 status:"
|
||||
pm2 list | grep -E "shahikitchen|App name" || true
|
||||
|
||||
echo ""
|
||||
echo "Testing local Next.js process..."
|
||||
curl -s --max-time 5 "http://127.0.0.1:${PORT}" | head -c 300 || echo "(first request may be slow)"
|
||||
|
||||
echo ""
|
||||
echo "[6/6] Deploy finished successfully at $(date)"
|
||||
echo "=========================================="
|
||||
echo "Website should be live at: http://76.13.210.183"
|
||||
echo "=========================================="
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Always start dev with a fresh .next cache — prevents missing vendor-chunks (zustand.js).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
pkill -f "next dev" 2>/dev/null || true
|
||||
if lsof -ti:3000 >/dev/null 2>&1; then
|
||||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
rm -rf .next
|
||||
echo "Starting Next.js dev server (clean cache)…"
|
||||
exec npx next dev
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate 80 QR code SVGs for table ordering (40 Backaplan + 40 Askim).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/generate-table-qr-codes.mjs
|
||||
*
|
||||
* Output:
|
||||
* public/images/booking/backaplan/qr-backaplan-001.svg ... qr-backaplan-040.svg
|
||||
* public/images/booking/askim/qr-askim-001.svg ... qr-askim-040.svg
|
||||
*
|
||||
* Each QR points to:
|
||||
* https://shahikitchen.se/orderfromtable?table=backaplan-001
|
||||
* https://shahikitchen.se/orderfromtable?table=askim-001
|
||||
* etc.
|
||||
*/
|
||||
|
||||
import QRCode from 'qrcode';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const BASE_URL = 'https://shahikitchen.se/orderfromtable';
|
||||
|
||||
const BRANCHES = [
|
||||
{ key: 'backaplan', label: 'Backaplan', dir: 'backaplan' },
|
||||
{ key: 'askim', label: 'Askim', dir: 'askim' },
|
||||
];
|
||||
|
||||
const MIN_TABLE = 1;
|
||||
const MAX_TABLE = 40;
|
||||
|
||||
function pad3(n) {
|
||||
return String(n).padStart(3, '0');
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function generateOne(branchKey, tableNum, outputPath) {
|
||||
const tableId = `${branchKey}-${pad3(tableNum)}`;
|
||||
const url = `${BASE_URL}?table=${tableId}`;
|
||||
|
||||
const svg = await QRCode.toString(url, {
|
||||
type: 'svg',
|
||||
width: 320,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'Q', // Good balance for physical use (tables, stands, possibly dirty)
|
||||
color: {
|
||||
dark: '#111111', // near-black for high contrast print
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
// Add a small metadata comment (harmless in SVG)
|
||||
const svgWithMeta = svg.replace(
|
||||
'<svg ',
|
||||
`<!-- Shahi Kitchen Table QR: ${tableId} -->\n<svg `
|
||||
);
|
||||
|
||||
await fs.writeFile(outputPath, svgWithMeta, 'utf8');
|
||||
console.log(`✓ ${tableId} → ${path.relative(ROOT, outputPath)}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Generating Shahi Kitchen table QR codes (80 total)...\n');
|
||||
|
||||
for (const branch of BRANCHES) {
|
||||
const outDir = path.join(ROOT, 'public', 'images', 'booking', branch.dir);
|
||||
await ensureDir(outDir);
|
||||
|
||||
for (let i = MIN_TABLE; i <= MAX_TABLE; i++) {
|
||||
const filename = `qr-${branch.key}-${pad3(i)}.svg`;
|
||||
const fullPath = path.join(outDir, filename);
|
||||
await generateOne(branch.key, i, fullPath);
|
||||
}
|
||||
console.log(''); // blank line between branches
|
||||
}
|
||||
|
||||
console.log('All 80 QR codes generated successfully.');
|
||||
console.log('They all target the single /orderfromtable page with ?table=... param.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('QR generation failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compress site images to <= 100 KB while preserving original colors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SITE = ROOT / "public" / "images" / "site"
|
||||
BACKUP = SITE / "_originals"
|
||||
SKIP = {"logo.jpeg", "README.txt"}
|
||||
SKIP_PATTERNS = (" copy", "-1.jpg")
|
||||
|
||||
|
||||
def should_skip(path: Path) -> bool:
|
||||
if path.name in SKIP or path.name.startswith("_"):
|
||||
return True
|
||||
return any(p in path.name for p in SKIP_PATTERNS)
|
||||
|
||||
|
||||
def encode_jpeg(img: Image.Image, quality: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def compress_to_target(img: Image.Image, max_bytes: int) -> tuple[Image.Image, int]:
|
||||
working = img.convert("RGB")
|
||||
scale = 1.0
|
||||
|
||||
while scale >= 0.35:
|
||||
if scale < 1.0:
|
||||
w, h = working.size
|
||||
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
|
||||
candidate = working.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||
else:
|
||||
candidate = working
|
||||
|
||||
lo, hi = 20, 92
|
||||
best_q = lo
|
||||
best_data = encode_jpeg(candidate, lo)
|
||||
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
data = encode_jpeg(candidate, mid)
|
||||
if len(data) <= max_bytes:
|
||||
best_q = mid
|
||||
best_data = data
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
|
||||
if len(best_data) <= max_bytes:
|
||||
return Image.open(io.BytesIO(best_data)).convert("RGB"), best_q
|
||||
|
||||
scale *= 0.88
|
||||
|
||||
data = encode_jpeg(candidate, 20)
|
||||
return Image.open(io.BytesIO(data)).convert("RGB"), 20
|
||||
|
||||
|
||||
def optimize_file(path: Path, max_bytes: int, dry_run: bool) -> tuple[int, int]:
|
||||
before = path.stat().st_size
|
||||
if before <= max_bytes:
|
||||
return before, before
|
||||
|
||||
with Image.open(path) as img:
|
||||
out, quality = compress_to_target(img, max_bytes)
|
||||
|
||||
if dry_run:
|
||||
buf = io.BytesIO()
|
||||
out.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
|
||||
return before, len(buf.getvalue())
|
||||
|
||||
out.save(path, format="JPEG", quality=quality, optimize=True, subsampling=2)
|
||||
return before, path.stat().st_size
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Optimize site images to <= 100 KB")
|
||||
parser.add_argument("--max-kb", type=int, default=100)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
max_bytes = args.max_kb * 1024
|
||||
BACKUP.mkdir(exist_ok=True)
|
||||
|
||||
files = sorted(SITE.glob("*.jpg")) + sorted(SITE.glob("*.jpeg"))
|
||||
changed = 0
|
||||
|
||||
for path in files:
|
||||
if should_skip(path):
|
||||
continue
|
||||
|
||||
backup = BACKUP / path.name
|
||||
if not backup.exists() and not args.dry_run:
|
||||
shutil.copy2(path, backup)
|
||||
|
||||
before, after = optimize_file(path, max_bytes, args.dry_run)
|
||||
if after < before or before > max_bytes:
|
||||
changed += 1
|
||||
print(
|
||||
f"{'~' if args.dry_run else '✓'} {path.name}: "
|
||||
f"{before // 1024} KB → {after // 1024} KB"
|
||||
)
|
||||
else:
|
||||
print(f"○ {path.name}: {before // 1024} KB (ok)")
|
||||
|
||||
print(f"\nDone — {changed} optimized, originals in {BACKUP}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Re-download default images into public/images/site/ (only image folder used by the site)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SITE="$ROOT/public/images/site"
|
||||
mkdir -p "$SITE"
|
||||
|
||||
dl() {
|
||||
curl -fsSL "$1" -o "$2"
|
||||
}
|
||||
|
||||
# Keep existing logo if present; otherwise skip (add logo.jpeg manually)
|
||||
if [[ ! -f "$SITE/logo.jpeg" ]]; then
|
||||
echo "Note: add logo.jpeg to $SITE manually if missing."
|
||||
fi
|
||||
|
||||
dl "https://images.unsplash.com/photo-1607623814075-e51df1bdc82f?auto=format&fit=crop&w=2560&q=90" "$SITE/hero.jpg"
|
||||
dl "https://images.unsplash.com/photo-1529692236671-f1f6cf9683ba?auto=format&fit=crop&w=1920&q=90" "$SITE/about.jpg"
|
||||
cp "$SITE/hero.jpg" "$SITE/weekly-offers.jpg"
|
||||
|
||||
dl "https://images.unsplash.com/photo-1587593810167-a84920ea0781?auto=format&fit=crop&w=1400&q=90" "$SITE/category-chicken.jpg"
|
||||
dl "https://images.unsplash.com/photo-1558030006-450675393462?auto=format&fit=crop&w=1400&q=90" "$SITE/category-beef.jpg"
|
||||
dl "https://images.unsplash.com/photo-1615937657715-bc7b4b7962c1?auto=format&fit=crop&w=1400&q=90" "$SITE/category-lamb.jpg"
|
||||
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&q=90" "$SITE/category-fish.jpg"
|
||||
|
||||
dl "https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?auto=format&fit=crop&w=1400&q=90" "$SITE/chicken-whole.jpg"
|
||||
dl "https://images.unsplash.com/photo-1587593810167-a84920ea0781?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/chicken-whole-2.jpg"
|
||||
dl "https://images.unsplash.com/photo-1587593810167-a84920ea0781?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/chicken-breast.jpg"
|
||||
cp "$SITE/chicken-whole.jpg" "$SITE/chicken-breast-2.jpg"
|
||||
dl "https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?auto=format&fit=crop&w=1400&h=1100&crop=entropy&q=90" "$SITE/chicken-thighs.jpg"
|
||||
dl "https://images.unsplash.com/photo-1587593810167-a84920ea0781?auto=format&fit=crop&w=1400&h=900&crop=top&q=90" "$SITE/chicken-wings.jpg"
|
||||
|
||||
dl "https://images.unsplash.com/photo-1546833999-b9f581a1996d?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-nihari.jpg"
|
||||
dl "https://images.unsplash.com/photo-1559847844-5315695dadae?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-nihari-2.jpg"
|
||||
dl "https://images.unsplash.com/photo-1559847844-5315695dadae?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-steak.jpg"
|
||||
dl "https://images.unsplash.com/photo-1546833999-b9f581a1996d?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-steak-2.jpg"
|
||||
dl "https://images.unsplash.com/photo-1603048297172-c92544798d5a?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-mince.jpg"
|
||||
dl "https://images.unsplash.com/photo-1558030006-450675393462?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/beef-boneless.jpg"
|
||||
|
||||
dl "https://images.unsplash.com/photo-1615937657715-bc7b4b7962c1?auto=format&fit=crop&w=1400&q=90" "$SITE/lamb-shoulder.jpg"
|
||||
dl "https://images.unsplash.com/photo-1544025162-d76694265947?auto=format&fit=crop&w=1400&q=90" "$SITE/lamb-shoulder-2.jpg"
|
||||
dl "https://images.unsplash.com/photo-1544025162-d76694265947?auto=format&fit=crop&w=1400&q=90" "$SITE/lamb-leg.jpg"
|
||||
dl "https://images.unsplash.com/photo-1574672280600-4accfa5b6f98?auto=format&fit=crop&w=1400&q=90" "$SITE/lamb-chops.jpg"
|
||||
dl "https://images.unsplash.com/photo-1574672280600-4accfa5b6f98?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/lamb-mince.jpg"
|
||||
|
||||
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&q=90" "$SITE/fish-salmon.jpg"
|
||||
dl "https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&w=1400&h=1100&crop=entropy&q=90" "$SITE/fish-salmon-2.jpg"
|
||||
dl "https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&w=1400&h=1100&crop=entropy&q=90" "$SITE/fish-rohu.jpg"
|
||||
dl "https://images.unsplash.com/photo-1565680018434-b513d5e5fd47?auto=format&fit=crop&w=1400&q=90" "$SITE/fish-prawns.jpg"
|
||||
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&h=900&crop=center&q=90" "$SITE/fish-basa.jpg"
|
||||
|
||||
echo "Done. $(ls -1 "$SITE" | wc -l | tr -d ' ') files in $SITE"
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize site images: square canvas, HALAL badge + Kött Gård logo. Colors preserved."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SITE = ROOT / "public" / "images" / "site"
|
||||
BACKUP = SITE / "_originals"
|
||||
LOGO_PATH = SITE / "logo.jpeg"
|
||||
BRAND_NAME = "Kött Gård"
|
||||
|
||||
SIZE = (1400, 1400)
|
||||
BG = (255, 255, 255)
|
||||
BURGUNDY = (139, 31, 31)
|
||||
GOLD = (212, 175, 55)
|
||||
|
||||
SKIP = {"logo.jpeg", "README.txt"}
|
||||
SKIP_PATTERNS = (" copy", "-1.jpg") # duplicate uploads from user
|
||||
FILL_PRODUCT = 0.9
|
||||
FILL_PAGE = 0.94
|
||||
|
||||
PAGE_IMAGES = {"hero.jpg", "about.jpg", "weekly-offers.jpg"}
|
||||
CATEGORY_IMAGES = {
|
||||
"category-chicken.jpg",
|
||||
"category-beef.jpg",
|
||||
"category-lamb.jpg",
|
||||
"category-fish.jpg",
|
||||
}
|
||||
|
||||
|
||||
def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
paths = [
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||
if bold
|
||||
else "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/Library/Fonts/Arial.ttf",
|
||||
]
|
||||
for p in paths:
|
||||
try:
|
||||
return ImageFont.truetype(p, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def preserve_colors(img: Image.Image) -> Image.Image:
|
||||
"""Keep original photo pixels — no contrast, brightness, or color tweaks."""
|
||||
return img.convert("RGB")
|
||||
|
||||
|
||||
def prep_logo(diameter: int) -> Image.Image:
|
||||
logo = Image.open(LOGO_PATH).convert("RGBA")
|
||||
w, h = logo.size
|
||||
side = min(w, h)
|
||||
logo = logo.crop(((w - side) // 2, (h - side) // 2, (w + side) // 2, (h + side) // 2))
|
||||
logo = logo.resize((diameter, diameter), Image.Resampling.LANCZOS)
|
||||
mask = Image.new("L", (diameter, diameter), 0)
|
||||
ImageDraw.Draw(mask).ellipse((0, 0, diameter - 1, diameter - 1), fill=255)
|
||||
logo.putalpha(mask)
|
||||
return logo
|
||||
|
||||
|
||||
def draw_halal_badge(canvas: Image.Image, x: int, y: int, size: int = 130) -> None:
|
||||
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
draw.ellipse((x + 4, y + 5, x + size + 4, y + size + 5), fill=(0, 0, 0, 100))
|
||||
draw.ellipse((x - 2, y - 2, x + size + 2, y + size + 2), fill=(*GOLD, 255))
|
||||
draw.ellipse((x + 6, y + 6, x + size - 6, y + size - 6), fill=(255, 255, 255, 255))
|
||||
draw.ellipse((x + 10, y + 10, x + size - 10, y + size - 10), outline=(*BURGUNDY, 255), width=4)
|
||||
|
||||
f_ar = load_font(24, bold=True)
|
||||
f_halal = load_font(28, bold=True)
|
||||
f_sub = load_font(11, bold=True)
|
||||
|
||||
ar = draw.textbbox((0, 0), "حلال", font=f_ar)
|
||||
draw.text((x + (size - ar[2] + ar[0]) // 2, y + 22), "حلال", fill=(*BURGUNDY, 255), font=f_ar)
|
||||
hl = draw.textbbox((0, 0), "HALAL", font=f_halal)
|
||||
draw.text((x + (size - hl[2] + hl[0]) // 2, y + 52), "HALAL", fill=(*BURGUNDY, 255), font=f_halal)
|
||||
ce = draw.textbbox((0, 0), "CERTIFIED", font=f_sub)
|
||||
draw.text((x + (size - ce[2] + ce[0]) // 2, y + 92), "CERTIFIED", fill=(*GOLD, 255), font=f_sub)
|
||||
|
||||
canvas.alpha_composite(layer)
|
||||
|
||||
|
||||
def draw_logo_badge(canvas: Image.Image, logo: Image.Image, x: int, y: int) -> None:
|
||||
d = logo.size[0]
|
||||
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
draw.ellipse((x + 3, y + 4, x + d + 3, y + d + 4), fill=(0, 0, 0, 90))
|
||||
draw.ellipse((x - 4, y - 4, x + d + 4, y + d + 4), fill=(255, 255, 255, 245), outline=(*GOLD, 255), width=3)
|
||||
canvas.alpha_composite(layer)
|
||||
canvas.alpha_composite(logo, (x, y))
|
||||
|
||||
|
||||
def draw_brand_name(canvas: Image.Image, x: int, y: int) -> None:
|
||||
"""Brand name pill beside the logo badge."""
|
||||
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
font = load_font(22, bold=True)
|
||||
bbox = draw.textbbox((0, 0), BRAND_NAME, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
pad_x, pad_y = 14, 8
|
||||
box = (x - tw - pad_x * 2, y, x - 8, y + th + pad_y * 2)
|
||||
draw.rounded_rectangle(box, radius=10, fill=(255, 255, 255, 235), outline=(*GOLD, 255), width=2)
|
||||
draw.text((box[0] + pad_x, box[1] + pad_y - 2), BRAND_NAME, fill=(*BURGUNDY, 255), font=font)
|
||||
canvas.alpha_composite(layer)
|
||||
|
||||
|
||||
def already_branded(path: Path) -> bool:
|
||||
"""Skip images already at catalog size (user uploads with logos baked in)."""
|
||||
with Image.open(path) as img:
|
||||
return img.size == SIZE
|
||||
|
||||
|
||||
def should_skip(path: Path) -> bool:
|
||||
if path.name in SKIP or path.name.startswith("_"):
|
||||
return True
|
||||
return any(p in path.name for p in SKIP_PATTERNS)
|
||||
|
||||
|
||||
def compose(source: Path, dest: Path, logo: Image.Image) -> None:
|
||||
photo = preserve_colors(Image.open(source))
|
||||
fill = FILL_PAGE if dest.name in PAGE_IMAGES else FILL_PRODUCT
|
||||
if dest.name in CATEGORY_IMAGES:
|
||||
fill = 0.92
|
||||
|
||||
canvas = Image.new("RGBA", SIZE, (*BG, 255))
|
||||
max_side = int(SIZE[0] * fill)
|
||||
photo.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
|
||||
|
||||
shadow = Image.new("RGBA", photo.size, (0, 0, 0, 0))
|
||||
sh_draw = ImageDraw.Draw(shadow)
|
||||
sh_draw.rounded_rectangle(
|
||||
(8, 12, photo.width - 8, photo.height - 4),
|
||||
radius=18,
|
||||
fill=(0, 0, 0, 35),
|
||||
)
|
||||
px = (SIZE[0] - photo.width) // 2
|
||||
py = (SIZE[1] - photo.height) // 2 - 16
|
||||
canvas.alpha_composite(shadow, (px, py + 6))
|
||||
canvas.alpha_composite(photo.convert("RGBA"), (px, py))
|
||||
|
||||
margin = 28
|
||||
draw_halal_badge(canvas, margin, margin)
|
||||
logo_size = 108
|
||||
logo_x = SIZE[0] - logo_size - margin
|
||||
logo_y = SIZE[1] - logo_size - margin
|
||||
draw_logo_badge(canvas, logo, logo_x, logo_y)
|
||||
draw_brand_name(canvas, logo_x, logo_y + logo_size // 2 - 12)
|
||||
|
||||
canvas.convert("RGB").save(dest, "JPEG", quality=94, optimize=True, subsampling=0)
|
||||
|
||||
|
||||
def ensure_catalog_files() -> None:
|
||||
pairs = [
|
||||
("beef-steak.jpg", "beef-steak-2.jpg"),
|
||||
("beef-nihari-2.jpg", "beef-nihari.jpg"),
|
||||
("category-lamb.jpg", "lamb-shoulder.jpg"),
|
||||
]
|
||||
for target, source in pairs:
|
||||
t, s = SITE / target, SITE / source
|
||||
if not t.exists() and s.exists():
|
||||
shutil.copy2(s, t)
|
||||
print(f"Created missing {target} from {source}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Brand site images (colors preserved)")
|
||||
parser.add_argument("--force", action="store_true", help="Re-process even if already 1400×1400")
|
||||
parser.add_argument("--only", nargs="*", help="Process only these filenames")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not LOGO_PATH.exists():
|
||||
raise SystemExit(f"Missing logo: {LOGO_PATH}")
|
||||
|
||||
BACKUP.mkdir(exist_ok=True)
|
||||
ensure_catalog_files()
|
||||
|
||||
logo = prep_logo(108)
|
||||
files = sorted(SITE.glob("*.jpg"))
|
||||
processed = 0
|
||||
skipped = 0
|
||||
|
||||
for path in files:
|
||||
if should_skip(path):
|
||||
continue
|
||||
if args.only and path.name not in args.only:
|
||||
continue
|
||||
if not args.force and already_branded(path):
|
||||
print(f"○ {path.name} (already branded, skipped)")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
backup = BACKUP / path.name
|
||||
if not backup.exists():
|
||||
shutil.copy2(path, backup)
|
||||
source = backup if backup.exists() else path
|
||||
compose(source, path, logo)
|
||||
print(f"✓ {path.name}")
|
||||
processed += 1
|
||||
|
||||
print(f"\nDone — {processed} processed, {skipped} skipped. Originals in {BACKUP}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Sync dish photos from ftp-images/ → public/images/dishes/
|
||||
* - Matches folders by menu item id (e.g. ftp-images/bilder-bp/chicken-biryani/bild/)
|
||||
* - Center-crops to 4:3, resizes to 1200×900, exports unified JPEG
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { menuCategories } from '../infrastructure/menu/static-menu-data';
|
||||
import { videoBaseName } from '../application/media/asset-resolver';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const FTP_ROOT = path.join(ROOT, 'ftp-images/bilder-bp');
|
||||
const OUT_DIR = path.join(ROOT, 'public/images/dishes');
|
||||
const TARGET_W = 1200;
|
||||
const TARGET_H = 900;
|
||||
const JPEG_QUALITY = 88;
|
||||
|
||||
/** Menu ids that share another dish's ftp photo (main bilder-bp folder) */
|
||||
const FTP_ALIASES: Record<string, string> = {
|
||||
'tikka-boti': 'chicken-tikka',
|
||||
};
|
||||
|
||||
/** Menu id → folder name under ftp-images/bilder-bp/sweets/ */
|
||||
const SWEETS_FOLDERS: Record<string, string> = {
|
||||
'namakpare': 'namak-paray',
|
||||
'shakar-paray': 'Shakar paray',
|
||||
'chocolate-barfi': 'chochlate-barfi',
|
||||
'pistachio-barfi': 'pistacho-barfi',
|
||||
};
|
||||
|
||||
function pickLargestImage(dir: string): string | null {
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return [];
|
||||
if (!/\.(jpe?g|png|webp)$/i.test(entry.name)) return [];
|
||||
return [{ full, size: fs.statSync(full).size }];
|
||||
})
|
||||
.sort((a, b) => b.size - a.size);
|
||||
|
||||
return files[0]?.full ?? null;
|
||||
}
|
||||
|
||||
function findSourceImage(dishId: string): string | null {
|
||||
const mainFolder = FTP_ALIASES[dishId] ?? dishId;
|
||||
const sweetsFolder = SWEETS_FOLDERS[dishId] ?? dishId;
|
||||
|
||||
const candidates = [
|
||||
path.join(FTP_ROOT, mainFolder, 'bild'),
|
||||
path.join(FTP_ROOT, 'sweets', sweetsFolder, 'bild'),
|
||||
path.join(FTP_ROOT, 'sweets', sweetsFolder),
|
||||
];
|
||||
|
||||
for (const dir of candidates) {
|
||||
const found = pickLargestImage(dir);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function run(cmd: string) {
|
||||
execSync(cmd, { stdio: 'pipe' });
|
||||
}
|
||||
|
||||
function processToJpeg(src: string, dest: string) {
|
||||
const tmp = path.join(OUT_DIR, `.tmp-${path.basename(dest)}`);
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// Normalize to JPEG working copy
|
||||
run(`sips -s format jpeg "${src}" --out "${tmp}"`);
|
||||
|
||||
const width = Number(
|
||||
execSync(`sips -g pixelWidth "${tmp}"`).toString().match(/pixelWidth: (\d+)/)?.[1]
|
||||
);
|
||||
const height = Number(
|
||||
execSync(`sips -g pixelHeight "${tmp}"`).toString().match(/pixelHeight: (\d+)/)?.[1]
|
||||
);
|
||||
|
||||
if (!width || !height) throw new Error(`Could not read dimensions for ${src}`);
|
||||
|
||||
const targetAspect = TARGET_W / TARGET_H;
|
||||
const sourceAspect = width / height;
|
||||
|
||||
let cropW = width;
|
||||
let cropH = height;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (sourceAspect > targetAspect) {
|
||||
cropW = Math.round(height * targetAspect);
|
||||
offsetX = Math.round((width - cropW) / 2);
|
||||
} else if (sourceAspect < targetAspect) {
|
||||
cropH = Math.round(width / targetAspect);
|
||||
offsetY = Math.round((height - cropH) / 2);
|
||||
}
|
||||
|
||||
run(
|
||||
`sips --cropToHeightWidth ${cropH} ${cropW} --cropOffset ${offsetY} ${offsetX} "${tmp}"`
|
||||
);
|
||||
run(
|
||||
`sips -z ${TARGET_H} ${TARGET_W} -s format jpeg -s formatOptions ${JPEG_QUALITY} "${tmp}" --out "${dest}"`
|
||||
);
|
||||
|
||||
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const written = new Set<string>();
|
||||
let synced = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const category of menuCategories) {
|
||||
for (const item of category.items) {
|
||||
const src = findSourceImage(item.id);
|
||||
if (!src) {
|
||||
skipped++;
|
||||
console.log(` skip (no ftp image): ${item.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputs = new Set<string>();
|
||||
if (item.image) outputs.add(path.join(OUT_DIR, item.image));
|
||||
|
||||
if (item.video) {
|
||||
const base = videoBaseName(item.video);
|
||||
outputs.add(path.join(OUT_DIR, `${base}-poster.jpg`));
|
||||
outputs.add(path.join(OUT_DIR, `${base}-optimized-poster.jpg`));
|
||||
}
|
||||
|
||||
for (const out of outputs) {
|
||||
if (written.has(out)) continue;
|
||||
processToJpeg(src, out);
|
||||
written.add(out);
|
||||
console.log(` ✓ ${path.basename(out)} ← ${item.id}`);
|
||||
}
|
||||
|
||||
synced++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${synced} dishes synced, ${skipped} without ftp source, ${written.size} files written.`);
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Replace fish images (frozen seafood style)
|
||||
set -euo pipefail
|
||||
SITE="$(cd "$(dirname "$0")/.." && pwd)/public/images/site"
|
||||
dl() { curl -fsSL "$1" -o "$2"; }
|
||||
|
||||
dl "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=1400&q=90" "$SITE/category-fish.jpg"
|
||||
dl "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/fish-salmon.jpg"
|
||||
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&q=90" "$SITE/fish-salmon-2.jpg"
|
||||
dl "https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&w=1400&h=1100&crop=entropy&q=90" "$SITE/fish-rohu.jpg"
|
||||
dl "https://images.unsplash.com/photo-1565680018434-b513d5e5fd47?auto=format&fit=crop&w=1400&q=90" "$SITE/fish-prawns.jpg"
|
||||
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&h=900&crop=center&q=90" "$SITE/fish-basa.jpg"
|
||||
|
||||
echo "Fish images updated in $SITE"
|
||||
Reference in New Issue
Block a user