Initial commit — L'Oréal Spec Tool

238 specs (CH/CZ/DE/NORDICS), dark/light theme, cascading filters,
side-by-side comparison, checklist export, admin panel, bulk import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Phil Dore 2026-04-27 18:28:26 +01:00
parent 696d54dd93
commit 21fcf63431
10 changed files with 8273 additions and 47 deletions

51
.gitignore vendored
View file

@ -1,50 +1,7 @@
# These are some examples of commonly ignored file patterns.
# You should customize this list as applicable to your project.
# Learn more about .gitignore:
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
# Node artifact files
node_modules/
dist/
# Compiled Java class files
*.class
# Compiled Python bytecode
*.py[cod]
# Log files
server/node_modules/
.env
.env.*
*.log
# Package files
*.jar
# Maven
target/
dist/
# JetBrains IDE
.idea/
# Unit test reports
TEST*.xml
# Generated by MacOS
.DS_Store
# Generated by Windows
Thumbs.db
# Applications
*.app
*.exe
*.war
# Large media files
*.mp4
*.tiff
*.avi
*.flv
*.mov
*.wmv
.deployed

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM node:20-alpine
WORKDIR /app/server
COPY server/package*.json ./
RUN npm ci --only=production
COPY server/ ./
COPY index.html script.js /app/
ENV NODE_ENV=production
ENV PORT=3101
EXPOSE 3101
CMD ["node", "index.js"]

88
convert_specs.py Normal file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Convert All_Regions_Specs.xlsx → specs.json for the L'Oréal Spec Tool."""
import json, uuid, re
from datetime import date
import openpyxl
XLSX = "/Users/phildore/Desktop/All_Regions_Specs.xlsx"
OUT = "/Users/phildore/Documents/CLAUDE_PROJECTS/loreal-spec-tool/specs.json"
def clean(v):
if v is None:
return ""
s = str(v).strip()
return "" if s.lower() in ("none", "n/a", "-", "nan") else s
def split_file_types(raw):
if not raw:
return []
parts = re.split(r"[,/\s]+", raw.upper())
known = {"JPG","JPEG","PNG","TIFF","TIF","PSD","WEBP","SVG","ZIP","PDF","GIF","MP4","MOV","AVI","TEXT","SPREADSHEET","VIDEO"}
out = []
for p in parts:
p = p.strip(".()")
if p in known:
out.append(p)
return list(dict.fromkeys(out)) # deduplicate, preserve order
wb = openpyxl.load_workbook(XLSX)
ws = wb["Retailer Specs"]
rows = list(ws.iter_rows(values_only=True))
# Row 1 is a title row; row 2 has the actual column headers
headers = [str(h).strip() if h else "" for h in rows[1]]
print("Columns:", headers)
specs = []
today = date.today().isoformat()
for row in rows[2:]:
if not any(row):
continue
d = dict(zip(headers, row))
file_types_raw = clean(d.get("FILE TYPE", ""))
file_types = split_file_types(file_types_raw)
spec = {
"id": str(uuid.uuid4()),
"country": clean(d.get("COUNTRY", "")),
"retailer": clean(d.get("RETAILER", "")),
"contentGrouping": clean(d.get("ECOM CONTENT GROUPING", "")),
"division": clean(d.get("DIVISION", "")),
"format": clean(d.get("FORMAT", "")),
"dimensions": clean(d.get("ASSET DIMENSION", "")),
"maxWeight": clean(d.get("ASSET WEIGHT", "")),
"fileTypes": file_types,
"fileTypesRaw": file_types_raw,
"maxAssets": clean(d.get("MAX NUMBER OF ASSETS", "")),
"guidelines": clean(d.get("RETAILER ASSET GUIDELINES", "")),
"deliveryMethod": clean(d.get("DELIVERY METHOD FOR SYNDICATION", "")),
"deliveryDetail": clean(d.get("DETAILED", "")),
"handledBy": clean(d.get("HANDLED BY (WIP)", "")),
"namingConvention":clean(d.get("FILE NAMING CONVENTION FOR RETAILER", "")),
"uploadLink": clean(d.get("LINKS FOR UPLOAD", "")),
"imageExample": clean(d.get("IMAGE EXAMPLE", "")),
"notes": "",
"updatedAt": today,
}
specs.append(spec)
output = {
"specs": specs,
"meta": {
"version": "1.0",
"lastUpdated": today,
"totalSpecs": len(specs),
}
}
with open(OUT, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"Done — {len(specs)} specs written to {OUT}")
# Print sample
if specs:
print("\nSample spec:")
print(json.dumps(specs[0], indent=2, ensure_ascii=False))

80
deploy.sh Normal file
View file

@ -0,0 +1,80 @@
#!/bin/bash
# =============================================================================
# deploy.sh — idempotent deploy for loreal-spec-tool
# Usage: bash /opt/loreal-spec-tool/deploy.sh
# =============================================================================
set -euo pipefail
APP_NAME="loreal-spec-tool"
REPO_DIR="/opt/${APP_NAME}"
WEB_DIR="/var/www/html/${APP_NAME}"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
STATIC_FILES=(index.html script.js specs.json)
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; BOLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GREEN}[deploy]${NC} $1"; }
warn() { echo -e "${YELLOW}[warn]${NC} $1"; }
error() { echo -e "${RED}[error]${NC} $1"; exit 1; }
step() { echo -e "\n${BOLD}$1${NC}"; }
compose() { docker compose -f "$COMPOSE_FILE" "$@"; }
step "Checking prerequisites"
command -v git >/dev/null 2>&1 || error "git is not installed"
command -v docker >/dev/null 2>&1 || error "docker is not installed"
docker compose version >/dev/null 2>&1 || error "docker compose v2 not found"
step "Pulling latest code"
cd "$REPO_DIR"
git pull origin main
log "Code: $(git log -1 --format='%h %s')"
step "Building Docker image"
compose build --pull
log "Build complete"
step "Starting application"
compose up -d app
log "Container started"
step "Deploying static files to ${WEB_DIR}"
rm -rf "${WEB_DIR}"
mkdir -p "${WEB_DIR}"
for f in "${STATIC_FILES[@]}"; do
if [ -f "${REPO_DIR}/${f}" ]; then
cp "${REPO_DIR}/${f}" "${WEB_DIR}/${f}"
log " copied ${f}"
else
warn " ${f} not found — skipped"
fi
done
step "Health check"
sleep 2
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3101/api/health || echo "000")
[ "$HTTP_STATUS" = "200" ] && log "API OK (HTTP 200)" || error "Health check failed (HTTP ${HTTP_STATUS})"
FIRST_RUN_FLAG="${REPO_DIR}/.deployed"
if [ ! -f "$FIRST_RUN_FLAG" ]; then
touch "$FIRST_RUN_FLAG"
echo ""
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD} First deploy. Add this to your Apache VirtualHost:${NC}"
echo ""
echo " Alias /loreal-spec-tool /var/www/html/loreal-spec-tool"
echo " <Directory /var/www/html/loreal-spec-tool>"
echo " Options -Indexes"
echo " AllowOverride None"
echo " Require all granted"
echo " </Directory>"
echo " ProxyPass /loreal-spec-tool/api http://localhost:3101/api"
echo " ProxyPassReverse /loreal-spec-tool/api http://localhost:3101/api"
echo ""
echo " Then: sudo a2enmod proxy proxy_http && sudo systemctl reload apache2"
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
fi
echo ""
echo -e "${GREEN}${BOLD}Deploy complete${NC}$(date '+%Y-%m-%d %H:%M:%S')"
echo -e " App: http://localhost:3101 | API: http://localhost:3101/api/health"
echo ""

14
docker-compose.yml Normal file
View file

@ -0,0 +1,14 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3101:3101"
environment:
- PORT=3101
- NODE_ENV=production
- ADMIN_TOKEN=${ADMIN_TOKEN:-loreal2024}
volumes:
- ./specs.json:/app/specs.json

954
index.html Normal file
View file

@ -0,0 +1,954 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>L'Oréal Spec Tool</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<style>
/* ── CSS Variables — Dark (default) ─── */
:root {
--bg: #111111;
--bg-card: #1c1c1c;
--bg-modal: #1a1a1a;
--bg-input: #1c1c1c;
--bg-inset: #111111;
--border: #262626;
--border-sub: #1e1e1e;
--text: #f3f4f6;
--text-sub: #9ca3af;
--text-muted: #6b7280;
--text-faint: #4b5563;
--scrolltrack: #1a1a1a;
--scrollthumb: #3a3a3a;
--header-bg: #111111;
--header-bdr: #1e1e1e;
--shadow: none;
}
/* ── CSS Variables — Light ─── */
body.light {
--bg: #f4f4f5;
--bg-card: #ffffff;
--bg-modal: #ffffff;
--bg-input: #f9fafb;
--bg-inset: #f4f4f5;
--border: #e4e4e7;
--border-sub: #f0f0f0;
--text: #111111;
--text-sub: #52525b;
--text-muted: #71717a;
--text-faint: #a1a1aa;
--scrolltrack: #e4e4e7;
--scrollthumb: #d4d4d8;
--header-bg: #ffffff;
--header-bdr: #e4e4e7;
--shadow: 0 1px 3px rgba(0,0,0,0.08);
}
* { box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: background 0.2s, color 0.2s;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--scrolltrack); }
::-webkit-scrollbar-thumb { background: var(--scrollthumb); border-radius: 3px; }
/* ── Inputs ─── */
.filter-select, .filter-input, .form-input {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
outline: none;
transition: border-color 0.2s, background 0.2s;
width: 100%;
}
.filter-select:focus, .filter-input:focus, .form-input:focus { border-color: #f59e0b; }
.filter-select option { background: var(--bg-input); color: var(--text); }
textarea.form-input { resize: vertical; }
/* ── Cards ─── */
.spec-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
cursor: pointer;
transition: border-color 0.2s, transform 0.15s, box-shadow 0.2s;
box-shadow: var(--shadow);
}
.spec-card:hover { border-color: #f59e0b; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(245,158,11,0.1); }
/* ── Badges ─── */
.badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 4px;
}
.badge-country { background: #292524; color: #f59e0b; border: 1px solid #44403c; }
body.light .badge-country { background: #fef3c7; color: #d97706; border-color: #fde68a; }
.badge-banners { background: #1e1b4b; color: #818cf8; }
body.light .badge-banners { background: #eef2ff; color: #4f46e5; }
.badge-brand { background: #14532d; color: #86efac; }
body.light .badge-brand { background: #f0fdf4; color: #16a34a; }
.badge-pdp { background: #1c1917; color: #a8a29e; }
body.light .badge-pdp { background: #f5f5f4; color: #78716c; }
.badge-crm { background: #451a03; color: #fdba74; }
body.light .badge-crm { background: #fff7ed; color: #ea580c; }
.badge-retail { background: #0c4a6e; color: #7dd3fc; }
body.light .badge-retail { background: #f0f9ff; color: #0284c7; }
.file-type-pill {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 4px;
background: var(--bg-inset);
color: var(--text-sub);
border: 1px solid var(--border);
margin-right: 3px;
margin-top: 2px;
}
/* ── Tabs ─── */
.tab-btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
white-space: nowrap;
background: none;
border-top: none;
border-left: none;
border-right: none;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: #f59e0b; border-bottom-color: #f59e0b; }
/* ── Section containers ─── */
.panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
box-shadow: var(--shadow);
}
/* ── Section header ─── */
.section-header {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #f59e0b;
margin-bottom: 14px;
}
/* ── Modals ─── */
.modal-overlay {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
padding: 16px;
overflow-y: auto;
}
.modal-overlay.hidden { display: none; }
.modal-box {
background: var(--bg-modal);
border: 1px solid var(--border);
border-radius: 12px;
width: 100%; max-width: 780px;
max-height: 90vh; overflow-y: auto;
padding: 28px; position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.spec-detail-row {
display: grid;
grid-template-columns: 160px 1fr;
gap: 8px 16px;
padding: 10px 0;
border-bottom: 1px solid var(--border-sub);
}
.spec-detail-row:last-child { border-bottom: none; }
.spec-detail-label { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); padding-top: 2px; }
.spec-detail-value { font-size: 14px; color: var(--text); line-height: 1.6; }
.spec-detail-value.empty { color: var(--text-faint); font-style: italic; }
/* ── Buttons ─── */
.btn-primary {
background: #f59e0b; color: #111; font-weight: 700;
padding: 10px 20px; border-radius: 6px; font-size: 14px;
border: none; cursor: pointer; transition: background 0.2s;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-primary:hover { background: #d97706; }
.btn-ghost {
background: transparent; color: var(--text-sub); font-weight: 500;
padding: 8px 16px; border-radius: 6px; font-size: 14px;
border: 1px solid var(--border); cursor: pointer; transition: all 0.2s;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-ghost:hover { border-color: var(--text-muted); color: var(--text); }
.btn-danger {
background: transparent; color: #f87171; font-weight: 500;
padding: 6px 12px; border-radius: 6px; font-size: 13px;
border: 1px solid #3f1212; cursor: pointer; transition: all 0.2s;
}
.btn-danger:hover { background: #3f1212; }
body.light .btn-danger { border-color: #fecaca; }
body.light .btn-danger:hover { background: #fef2f2; }
.btn-edit {
background: transparent; color: var(--text-sub); font-weight: 500;
padding: 6px 12px; border-radius: 6px; font-size: 13px;
border: 1px solid var(--border); cursor: pointer; transition: all 0.2s;
}
.btn-edit:hover { border-color: var(--text-muted); color: var(--text); }
/* ── Recently viewed ─── */
.recent-strip {
display: flex; gap: 10px; overflow-x: auto;
padding-bottom: 4px; scrollbar-width: thin;
}
.recent-strip::-webkit-scrollbar { height: 4px; }
.recent-chip {
flex-shrink: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 14px;
cursor: pointer;
transition: border-color 0.15s;
max-width: 220px;
}
.recent-chip:hover { border-color: #f59e0b; }
.recent-chip-title { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.recent-chip-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Copy link button ─── */
.btn-copy-link {
display: inline-flex; align-items: center; gap: 5px;
font-size: 13px; font-weight: 500;
color: var(--text-muted);
padding: 7px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
cursor: pointer;
transition: all 0.2s;
}
.btn-copy-link:hover { border-color: #f59e0b; color: #f59e0b; }
/* ── Naming preview ─── */
.naming-preview {
margin-top: 6px;
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #f59e0b;
word-break: break-all;
}
.naming-preview-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-faint); margin-bottom: 4px; font-family: inherit; }
/* ── Comparison ─── */
.compare-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 45;
background: #1a1a1a;
border-top: 1px solid #f59e0b;
padding: 12px 24px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
transform: translateY(100%);
transition: transform 0.25s ease;
box-shadow: 0 -4px 24px rgba(0,0,0,0.4);
}
body.light .compare-bar { background: #fff; border-top-color: #f59e0b; box-shadow: 0 -4px 24px rgba(0,0,0,0.1); }
.compare-bar.visible { transform: translateY(0); }
.compare-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.compare-chip {
background: #2a2a2a; border: 1px solid #3a3a3a;
border-radius: 6px; padding: 5px 10px;
font-size: 12px; color: #f3f4f6;
display: flex; align-items: center; gap: 6px;
max-width: 200px;
}
body.light .compare-chip { background: #f4f4f5; border-color: #e4e4e7; color: #111; }
.compare-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.compare-chip-remove { cursor: pointer; color: #6b7280; flex-shrink: 0; line-height: 1; }
.compare-chip-remove:hover { color: #f87171; }
/* Card checkbox */
.card-checkbox {
width: 17px; height: 17px;
border: 2px solid var(--border);
border-radius: 4px;
background: var(--bg-inset);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.card-checkbox.checked { background: #f59e0b; border-color: #f59e0b; }
.spec-card.selected { border-color: #f59e0b; }
/* Compare table */
.compare-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.compare-table th { padding: 12px 14px; text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #f59e0b; border-bottom: 2px solid #f59e0b; background: var(--bg-inset); }
.compare-table td { padding: 10px 14px; border-bottom: 1px solid var(--border-sub); vertical-align: top; line-height: 1.5; color: var(--text-sub); }
.compare-table tr:hover td { background: var(--bg-inset); }
.compare-table td.row-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); width: 140px; }
.compare-table td.diff { color: var(--text); font-weight: 600; background: rgba(245,158,11,0.06); }
.compare-table td.empty-val { color: var(--text-faint); font-style: italic; }
/* ── Checklist overlay ─── */
#checklistOverlay {
position: fixed; inset: 0; z-index: 60;
background: var(--bg);
overflow-y: auto;
display: none;
}
#checklistOverlay.open { display: block; }
.checklist-toolbar {
position: sticky; top: 0; z-index: 10;
background: var(--header-bg);
border-bottom: 1px solid var(--header-bdr);
padding: 12px 24px;
display: flex; align-items: center; gap: 12px;
}
@media print {
.checklist-toolbar { display: none !important; }
#checklistOverlay { position: static; overflow: visible; }
body > *:not(#checklistOverlay) { display: none !important; }
#checklistOverlay { display: block !important; }
}
/* ── Theme toggle ─── */
.theme-toggle {
width: 36px; height: 36px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.theme-toggle:hover { border-color: #f59e0b; color: #f59e0b; }
/* ── Header ─── */
.app-header {
background: var(--header-bg);
border-bottom: 1px solid var(--header-bdr);
box-shadow: var(--shadow);
}
.app-tabbar {
background: var(--header-bg);
border-bottom: 1px solid var(--header-bdr);
}
/* ── Admin table ─── */
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.admin-table th { text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); padding: 8px 12px; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-sub); vertical-align: middle; color: var(--text-sub); }
.admin-table tr:hover td { background: var(--bg-inset); }
/* ── Toast ─── */
#toast {
position: fixed; bottom: 24px; right: 24px; z-index: 100;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px; padding: 12px 18px;
font-size: 14px; color: var(--text);
transform: translateY(80px); opacity: 0;
transition: all 0.3s ease;
pointer-events: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-left: 3px solid #34d399; }
#toast.error { border-left: 3px solid #f87171; }
/* ── Grid ─── */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
/* ── Quick stat card ─── */
.quick-stat {
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
}
/* ── Form label ─── */
.form-label { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; display: block; }
/* ── Empty state ─── */
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-faint); }
/* ── Results count ─── */
.results-count { font-size: 13px; color: var(--text-muted); }
/* ── Review status badges ─── */
.badge-overdue { background: #3f1212; color: #f87171; border: 1px solid #7f1d1d; }
.badge-due-soon { background: #3f2800; color: #fbbf24; border: 1px solid #78350f; }
.badge-verified { background: #052e16; color: #4ade80; border: 1px solid #14532d; }
body.light .badge-overdue { background: #fef2f2; color: #dc2626; border-color: #fecaca; }
body.light .badge-due-soon { background: #fffbeb; color: #d97706; border-color: #fde68a; }
body.light .badge-verified { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
/* ── Card download button ─── */
.card-download-btn {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; font-weight: 600;
color: var(--text-muted);
padding: 4px 8px;
border-radius: 5px;
border: 1px solid var(--border);
background: transparent;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.card-download-btn:hover { border-color: #f59e0b; color: #f59e0b; }
.card-compare-btn {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: 600;
color: var(--text-muted);
padding: 4px 8px;
border-radius: 5px;
border: 1px solid var(--border);
background: transparent;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.card-compare-btn:hover { border-color: #f59e0b; color: #f59e0b; }
.card-compare-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.08); }
body.light .card-compare-btn.active { background: rgba(245,158,11,0.12); }
/* ── Bulk import ─── */
.drop-zone {
border: 2px dashed var(--border);
border-radius: 10px;
padding: 36px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone:hover, .drop-zone.drag-over { border-color: #f59e0b; background: rgba(245,158,11,0.04); }
.drop-zone input[type=file] { display: none; }
.import-preview { margin-top: 16px; }
.import-stat {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px;
border-radius: 7px;
margin-bottom: 8px;
font-size: 13px;
}
.import-stat.new { background: rgba(74,222,128,0.06); border: 1px solid #14532d; }
.import-stat.updated { background: rgba(251,191,36,0.06); border: 1px solid #78350f; }
.import-stat.unchanged{ background: var(--bg-inset); border: 1px solid var(--border); }
.import-stat-count { font-size: 20px; font-weight: 700; min-width: 36px; }
.import-stat.new .import-stat-count { color: #4ade80; }
.import-stat.updated .import-stat-count { color: #fbbf24; }
.import-stat.unchanged .import-stat-count { color: var(--text-muted); }
.btn-disabled {
background: transparent; color: var(--text-faint); font-weight: 500;
padding: 10px 20px; border-radius: 6px; font-size: 14px;
border: 1px dashed var(--border); cursor: not-allowed;
display: inline-flex; align-items: center; gap: 6px; opacity: 0.6;
}
/* ── Verify button ─── */
.btn-verify {
background: transparent; color: #4ade80; font-weight: 500;
padding: 6px 12px; border-radius: 6px; font-size: 13px;
border: 1px solid #14532d; cursor: pointer; transition: all 0.2s;
}
.btn-verify:hover { background: #052e16; }
body.light .btn-verify { border-color: #bbf7d0; color: #16a34a; }
body.light .btn-verify:hover { background: #f0fdf4; }
/* ── Print / PDF ─── */
#printArea { display: none; }
@media print {
body > *:not(#printArea) { display: none !important; }
#printArea { display: block !important; }
}
/* ── Animations ─── */
@keyframes fadeUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.2s ease both; }
/* ── Close btn ─── */
.close-btn {
color: var(--text-muted);
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.2s, color 0.2s;
flex-shrink: 0;
}
.close-btn:hover { background: var(--bg-inset); color: var(--text); }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════
HEADER
═══════════════════════════════════════════════════ -->
<header class="app-header sticky top-0 z-40 px-6 py-4">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div>
<h1 style="font-size:17px;font-weight:700;letter-spacing:-0.01em;">L'Oréal Spec Tool</h1>
<p style="font-size:12px;color:var(--text-muted);margin-top:1px;">Retailer asset specifications library</p>
</div>
<div class="flex items-center gap-3">
<!-- Theme toggle -->
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle light/dark mode">
<svg id="iconDark" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
<svg id="iconLight" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="display:none;">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</button>
<button onclick="showTab('admin')"
style="font-size:13px;font-weight:500;color:var(--text-muted);border:1px solid var(--border);border-radius:6px;padding:7px 16px;background:transparent;cursor:pointer;transition:all 0.2s;"
onmouseover="this.style.borderColor='#f59e0b';this.style.color='#f59e0b'"
onmouseout="this.style.borderColor='';this.style.color=''">
Admin
</button>
</div>
</div>
</header>
<!-- ═══════════════════════════════════════════════════
TAB BAR
═══════════════════════════════════════════════════ -->
<div class="app-tabbar px-6 sticky top-[65px] z-30">
<div class="max-w-7xl mx-auto flex">
<button class="tab-btn active" id="tab-browse" onclick="showTab('browse')">Browse Specs</button>
<button class="tab-btn" id="tab-admin" onclick="showTab('admin')">Admin</button>
<button class="tab-btn" id="tab-help" onclick="showTab('help')">Help</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════
BROWSE TAB
═══════════════════════════════════════════════════ -->
<div id="tab-browse-content" class="max-w-7xl mx-auto px-6 py-6">
<!-- Recently viewed -->
<div id="recentSection" class="hidden mb-5">
<p style="font-size:11px;font-weight:700;letter-spacing:0.07em;text-transform:uppercase;color:var(--text-muted);margin-bottom:8px;">Recently Viewed</p>
<div id="recentStrip" class="recent-strip"></div>
</div>
<div class="panel mb-6">
<p class="section-header">Filter Specs</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div>
<label class="form-label">Country</label>
<select id="filterCountry" class="filter-select" onchange="onFilterChange()">
<option value="">All Countries</option>
</select>
</div>
<div>
<label class="form-label">Retailer</label>
<select id="filterRetailer" class="filter-select" onchange="onFilterChange()">
<option value="">All Retailers</option>
</select>
</div>
<div>
<label class="form-label">Content Type</label>
<select id="filterContent" class="filter-select" onchange="onFilterChange()">
<option value="">All Types</option>
</select>
</div>
<div>
<label class="form-label">Search</label>
<input id="filterSearch" type="text" class="filter-input" placeholder="Format, dimensions, guidelines..." oninput="onFilterChange()">
</div>
</div>
<div class="flex items-center justify-between flex-wrap gap-2">
<span id="resultsCount" class="results-count"></span>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button id="checklistBtn" onclick="openChecklist()" class="btn-ghost hidden" style="padding:6px 14px;font-size:13px;">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
Export Checklist
</button>
<button onclick="clearFilters()" class="btn-ghost" style="padding:6px 14px;font-size:13px;">Clear filters</button>
</div>
</div>
</div>
<!-- Prompt shown before any filter is selected -->
<div id="filterPrompt" style="text-align:center;padding:64px 20px;">
<svg width="48" height="48" fill="none" stroke="var(--text-faint)" stroke-width="1.2" viewBox="0 0 24 24" style="margin:0 auto 14px;">
<path d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z"/>
</svg>
<p style="font-size:16px;font-weight:600;color:var(--text-muted);margin-bottom:6px;">Select a filter to get started</p>
<p style="font-size:13px;color:var(--text-faint);">Choose a country, retailer, or content type above — or type in the search box</p>
</div>
<div id="cardsGrid" class="cards-grid"></div>
<div id="emptyState" class="empty-state hidden">
<svg width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="margin:0 auto 12px;">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<p style="font-weight:600;">No specs match your filters</p>
<p style="font-size:13px;margin-top:4px;">Try adjusting the filters or clearing them</p>
</div>
</div>
<!-- ═══════════════════════════════════════════════════
ADMIN TAB
═══════════════════════════════════════════════════ -->
<div id="tab-admin-content" class="max-w-7xl mx-auto px-6 py-6 hidden">
<div id="adminPasswordOverlay" class="panel" style="text-align:center;padding:48px 24px;">
<p class="section-header" style="display:flex;justify-content:center;">Admin Access</p>
<p style="font-size:14px;color:var(--text-muted);margin-bottom:24px;">Enter the admin password to manage specs</p>
<div style="max-width:320px;margin:0 auto;">
<input id="adminPasswordInput" type="password" class="form-input" placeholder="Password"
style="margin-bottom:12px;"
onkeydown="if(event.key==='Enter')checkAdminPassword()">
<button class="btn-primary" style="width:100%;justify-content:center;" onclick="checkAdminPassword()">Sign In</button>
<p id="adminPasswordError" class="hidden" style="color:#f87171;font-size:13px;margin-top:12px;">Incorrect password</p>
</div>
</div>
<div id="adminPanel" class="hidden">
<div class="panel mb-6">
<div class="flex items-center justify-between mb-4">
<p class="section-header" style="margin-bottom:0;">Add New Spec</p>
<button onclick="toggleAddForm()" id="toggleAddBtn" class="btn-ghost" style="padding:6px 14px;font-size:13px;">Show form</button>
</div>
<div id="addForm" class="hidden">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<div><label class="form-label">Country *</label><input id="newCountry" class="form-input" placeholder="e.g. DE"></div>
<div><label class="form-label">Retailer *</label><input id="newRetailer" class="form-input" placeholder="e.g. Apodiscounter"></div>
<div><label class="form-label">Content Grouping *</label><input id="newContentGrouping" class="form-input" placeholder="e.g. BANNERS / PROMO BANNERS"></div>
<div><label class="form-label">Format Name *</label><input id="newFormat" class="form-input" placeholder="e.g. Homepage Banner"></div>
<div><label class="form-label">Dimensions</label><input id="newDimensions" class="form-input" placeholder="e.g. 1200x628px"></div>
<div><label class="form-label">Max File Weight</label><input id="newMaxWeight" class="form-input" placeholder="e.g. max 300KB"></div>
<div><label class="form-label">File Types</label><input id="newFileTypes" class="form-input" placeholder="e.g. JPG, PNG, WEBP"></div>
<div><label class="form-label">Max Number of Assets</label><input id="newMaxAssets" class="form-input" placeholder="e.g. 1-3 product packshots"></div>
<div><label class="form-label">Naming Convention</label><input id="newNamingConvention" class="form-input" placeholder="e.g. Brand-Product-Type"></div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div><label class="form-label">Asset Guidelines</label><textarea id="newGuidelines" class="form-input" rows="3" placeholder="Resolution, colour mode, safe zones..."></textarea></div>
<div><label class="form-label">Delivery Detail</label><textarea id="newDeliveryDetail" class="form-input" rows="3" placeholder="Email address, upload method, deadline..."></textarea></div>
</div>
<div class="mb-4"><label class="form-label">Notes</label><input id="newNotes" class="form-input" placeholder="Any additional notes"></div>
<button onclick="addSpec()" class="btn-primary">Add Spec</button>
</div>
</div>
<!-- Bulk Import -->
<div class="panel mb-6">
<div style="margin-bottom:16px;">
<p class="section-header" style="margin-bottom:4px;">Bulk Import</p>
<p style="font-size:13px;color:var(--text-muted);">Upload an Excel or CSV file to add or update specs in bulk.</p>
</div>
<div id="importPanel">
<!-- Format buttons -->
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px;">
<button onclick="document.getElementById('importFileInput').click()" class="btn-primary" style="font-size:13px;padding:8px 16px;">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
Upload Excel or CSV
</button>
<div style="position:relative;">
<button class="btn-disabled" title="Requires Anthropic API key — coming soon">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Extract from PDF
</button>
<span style="position:absolute;top:-8px;right:-8px;background:#2a2a2a;border:1px solid #3a3a3a;color:var(--text-muted);font-size:10px;padding:1px 6px;border-radius:10px;white-space:nowrap;">API key needed</span>
</div>
</div>
<!-- Drop zone -->
<div class="drop-zone" id="dropZone" onclick="document.getElementById('importFileInput').click()">
<input type="file" id="importFileInput" accept=".xlsx,.xls,.csv" onchange="handleImportFile(this.files[0])">
<svg width="32" height="32" fill="none" stroke="var(--text-faint)" stroke-width="1.5" viewBox="0 0 24 24" style="margin:0 auto 10px;">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
<p style="font-size:14px;font-weight:500;color:var(--text-muted);">Drop an Excel or CSV file here</p>
<p style="font-size:12px;color:var(--text-faint);margin-top:4px;">.xlsx, .xls, .csv — max 20MB</p>
</div>
<!-- Import preview (hidden until file parsed) -->
<div id="importPreview" class="import-preview hidden">
<div style="display:flex;flex-direction:column;gap:0;margin-bottom:16px;">
<div class="import-stat new">
<span class="import-stat-count" id="importCountNew">0</span>
<div>
<p style="font-weight:600;color:#4ade80;">New specs</p>
<p style="font-size:12px;color:var(--text-muted);">Will be added</p>
</div>
</div>
<div class="import-stat updated">
<span class="import-stat-count" id="importCountUpdated">0</span>
<div>
<p style="font-weight:600;color:#fbbf24;">Updated specs</p>
<p style="font-size:12px;color:var(--text-muted);">Matched by retailer + format name</p>
</div>
</div>
<div class="import-stat unchanged">
<span class="import-stat-count" id="importCountUnchanged">0</span>
<div>
<p style="font-weight:600;color:var(--text-sub);">Unchanged</p>
<p style="font-size:12px;color:var(--text-muted);">No changes detected</p>
</div>
</div>
</div>
<div style="display:flex;gap:10px;">
<button onclick="confirmImport()" class="btn-primary">Confirm Import</button>
<button onclick="cancelImport()" class="btn-ghost">Cancel</button>
</div>
</div>
<p id="importStatus" style="font-size:13px;color:var(--text-muted);margin-top:12px;"></p>
</div>
</div>
<div class="panel">
<div class="flex items-center justify-between mb-4">
<p class="section-header" style="margin-bottom:0;">All Specs (<span id="adminSpecCount">0</span>)</p>
<input id="adminSearch" type="text" class="filter-input" style="width:220px;" placeholder="Search specs..." oninput="renderAdminTable()">
</div>
<div style="overflow-x:auto;">
<table class="admin-table">
<thead>
<tr>
<th>Country</th><th>Retailer</th><th>Content Type</th>
<th>Format</th><th>Dimensions</th><th>File Types</th>
<th>Review Status</th>
<th style="width:150px;"></th>
</tr>
</thead>
<tbody id="adminTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════
HELP TAB
═══════════════════════════════════════════════════ -->
<div id="tab-help-content" class="max-w-4xl mx-auto px-6 py-6 hidden">
<div class="panel">
<p class="section-header">How to use the Spec Tool</p>
<div style="display:flex;flex-direction:column;gap:20px;font-size:14px;color:var(--text-sub);line-height:1.7;">
<div>
<p style="font-weight:600;color:var(--text);margin-bottom:4px;">Finding a spec</p>
<p>Use the filters at the top of the Browse page. Start by selecting a Country, then narrow down by Retailer and Content Type. You can also type in the Search box to find specs by format name, dimensions, or guidelines keywords.</p>
</div>
<div>
<p style="font-weight:600;color:var(--text);margin-bottom:4px;">Viewing full details</p>
<p>Click any spec card to open the full specification. This includes all fields: dimensions, file types, weight limit, asset guidelines, delivery instructions, naming conventions, and more.</p>
</div>
<div>
<p style="font-weight:600;color:var(--text);margin-bottom:4px;">Exporting a spec as PNG/PDF</p>
<p>Open any spec card and click <strong>Export PDF</strong>. A formatted image will be downloaded ready to share with your team or attach to a brief.</p>
</div>
<div>
<p style="font-weight:600;color:var(--text);margin-bottom:4px;">Copying spec details</p>
<p>In the spec modal, click <strong>Copy as Text</strong> to copy all spec details to your clipboard as plain text — useful for pasting into briefs or Slack.</p>
</div>
<div>
<p style="font-weight:600;color:var(--text);margin-bottom:4px;">Managing specs (Admin)</p>
<p>Click the <strong>Admin</strong> tab and enter the admin password. From there you can add new specs, edit existing ones, or delete outdated specs.</p>
</div>
<div class="panel" style="padding:16px;">
<p style="font-weight:600;color:#f59e0b;margin-bottom:8px;font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Content Groupings</p>
<div style="display:flex;flex-direction:column;gap:6px;">
<div><span class="badge badge-banners" style="margin-right:8px;">Banners</span>Homepage banners, promo banners, category banners</div>
<div><span class="badge badge-brand" style="margin-right:8px;">Brand Store</span>Landing pages and brand store assets</div>
<div><span class="badge badge-pdp" style="margin-right:8px;">PDP</span>Product Detail Page images and packshots</div>
<div><span class="badge badge-crm" style="margin-right:8px;">CRM</span>Newsletter and email marketing assets</div>
<div><span class="badge badge-retail" style="margin-right:8px;">Retail Media</span>Sponsored product and retail media placements</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════
SPEC DETAIL MODAL
═══════════════════════════════════════════════════ -->
<div id="specModal" class="modal-overlay hidden" onclick="closeModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:20px;">
<div style="padding-right:32px;">
<div id="modalBadges" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;"></div>
<h2 id="modalTitle" style="font-size:20px;font-weight:700;color:var(--text);line-height:1.3;"></h2>
<p id="modalSubtitle" style="font-size:13px;color:var(--text-muted);margin-top:4px;"></p>
</div>
<button class="close-btn" onclick="document.getElementById('specModal').classList.add('hidden');clearURL()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div id="modalQuickStats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px;"></div>
<div id="modalDetails" style="margin-bottom:24px;"></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;padding-top:16px;border-top:1px solid var(--border);">
<button onclick="exportPDF()" class="btn-primary">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
Export PDF
</button>
<button onclick="copySpecText()" class="btn-ghost">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
Copy as Text
</button>
<button onclick="copySpecLink()" class="btn-copy-link">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
</svg>
Copy Link
</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════
EDIT SPEC MODAL
═══════════════════════════════════════════════════ -->
<div id="editModal" class="modal-overlay hidden" onclick="closeEditModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;color:var(--text);">Edit Spec</h2>
<button class="close-btn" onclick="document.getElementById('editModal').classList.add('hidden')">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<input type="hidden" id="editSpecId">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<div><label class="form-label">Country</label><input id="editCountry" class="form-input"></div>
<div><label class="form-label">Retailer</label><input id="editRetailer" class="form-input"></div>
<div><label class="form-label">Content Grouping</label><input id="editContentGrouping" class="form-input"></div>
<div><label class="form-label">Format Name</label><input id="editFormat" class="form-input"></div>
<div><label class="form-label">Dimensions</label><input id="editDimensions" class="form-input"></div>
<div><label class="form-label">Max File Weight</label><input id="editMaxWeight" class="form-input"></div>
<div><label class="form-label">File Types (comma-separated)</label><input id="editFileTypes" class="form-input"></div>
<div><label class="form-label">Max Number of Assets</label><input id="editMaxAssets" class="form-input"></div>
<div><label class="form-label">Naming Convention</label><input id="editNamingConvention" class="form-input"></div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div><label class="form-label">Asset Guidelines</label><textarea id="editGuidelines" class="form-input" rows="3"></textarea></div>
<div><label class="form-label">Delivery Detail</label><textarea id="editDeliveryDetail" class="form-input" rows="3"></textarea></div>
</div>
<div style="margin-bottom:20px;"><label class="form-label">Notes</label><input id="editNotes" class="form-input"></div>
<div style="display:flex;gap:12px;padding-top:16px;border-top:1px solid var(--border);">
<button onclick="saveEditSpec()" class="btn-primary">Save Changes</button>
<button onclick="document.getElementById('editModal').classList.add('hidden')" class="btn-ghost">Cancel</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════
COMPARE BAR (sticky bottom)
═══════════════════════════════════════════════════ -->
<div class="compare-bar" id="compareBar">
<div style="display:flex;align-items:center;gap:12px;flex:1;min-width:0;">
<span style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#f59e0b;white-space:nowrap;">
Comparing <span id="compareCount">0</span>/3
</span>
<div class="compare-chips" id="compareChips"></div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;">
<button onclick="openComparison()" id="compareBtn" class="btn-primary" style="font-size:13px;padding:7px 16px;">Compare</button>
<button onclick="clearComparison()" class="btn-ghost" style="font-size:13px;padding:7px 12px;">Clear</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════
COMPARE MODAL
═══════════════════════════════════════════════════ -->
<div id="compareModal" class="modal-overlay hidden" onclick="closeCompareModal(event)">
<div class="modal-box" style="max-width:960px;" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;color:var(--text);">Spec Comparison</h2>
<button class="close-btn" onclick="document.getElementById('compareModal').classList.add('hidden')">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div style="overflow-x:auto;">
<table class="compare-table" id="compareTable"></table>
</div>
<div style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:center;">
<span style="font-size:12px;color:var(--text-muted);">
<span style="display:inline-block;width:10px;height:10px;background:rgba(245,158,11,0.15);border:1px solid #f59e0b;border-radius:2px;margin-right:4px;vertical-align:middle;"></span>
Highlighted cells differ between specs
</span>
</div>
</div>
</div>
<div id="toast"></div>
<div id="printArea"></div>
<!-- ═══════════════════════════════════════════════════
CHECKLIST OVERLAY
═══════════════════════════════════════════════════ -->
<div id="checklistOverlay">
<div class="checklist-toolbar">
<button onclick="closeChecklist()" class="btn-ghost" style="padding:6px 12px;font-size:13px;">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
Close
</button>
<button onclick="window.print()" class="btn-primary" style="font-size:13px;padding:7px 16px;">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
Print / Save PDF
</button>
<span id="checklistTitle" style="font-size:14px;font-weight:600;color:var(--text-muted);margin-left:4px;"></span>
</div>
<div id="checklistContent" style="max-width:900px;margin:0 auto;padding:32px 24px;"></div>
</div>
<script src="script.js"></script>
</body>
</html>

1185
script.js Normal file

File diff suppressed because it is too large Load diff

152
server/index.js Normal file
View file

@ -0,0 +1,152 @@
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const XLSX = require('xlsx');
const { v4: uuidv4 } = require('uuid');
const app = express();
const PORT = process.env.ADMIN_TOKEN || 3101;
const ADMIN_TOKEN= process.env.ADMIN_TOKEN || 'loreal2024';
const STATIC_DIR = path.join(__dirname, '..');
const SPECS_FILE = path.join(STATIC_DIR, 'specs.json');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
app.use(express.json({ limit: '10mb' }));
app.set('trust proxy', 1);
// ── Auth middleware ───────────────────────────────────────────────────────────
function requireAdmin(req, res, next) {
if (req.headers['x-admin-token'] !== ADMIN_TOKEN) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', specs: loadSpecs().specs.length }));
// ── Get specs ─────────────────────────────────────────────────────────────────
app.get('/api/specs', (_req, res) => res.json(loadSpecs()));
// ── Save specs ────────────────────────────────────────────────────────────────
app.post('/api/specs', requireAdmin, (req, res) => {
const data = req.body;
if (!data || !Array.isArray(data.specs)) return res.status(400).json({ error: 'Invalid payload' });
try {
fs.writeFileSync(SPECS_FILE, JSON.stringify(data, null, 2), 'utf8');
res.json({ ok: true, totalSpecs: data.specs.length });
} catch (e) {
console.error('Write error:', e);
res.status(500).json({ error: 'Failed to save specs' });
}
});
// ── Parse uploaded file (Excel or CSV) ───────────────────────────────────────
app.post('/api/import/parse', requireAdmin, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const wb = XLSX.read(req.file.buffer, { type: 'buffer' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });
// Normalise: try row 0 as headers, if it looks like a title row skip it
let dataRows = rows;
if (rows.length && typeof rows[0] === 'object') {
const firstKey = Object.keys(rows[0])[0] || '';
// If first row looks like a title (no COUNTRY/RETAILER key), try raw with header row
if (!firstKey.toUpperCase().includes('COUNTRY') && !firstKey.toUpperCase().includes('RETAILER')) {
const raw = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
// Find the header row (first row that contains COUNTRY or RETAILER)
let headerIdx = 0;
for (let i = 0; i < Math.min(5, raw.length); i++) {
const rowStr = raw[i].join(' ').toUpperCase();
if (rowStr.includes('COUNTRY') || rowStr.includes('RETAILER')) { headerIdx = i; break; }
}
const headers = raw[headerIdx].map(h => String(h).trim());
dataRows = raw.slice(headerIdx + 1)
.filter(r => r.some(c => c !== ''))
.map(r => Object.fromEntries(headers.map((h, i) => [h, String(r[i] || '').trim()])));
}
}
const today = new Date().toISOString().split('T')[0];
const parsed = dataRows
.filter(r => getField(r, ['RETAILER', 'retailer', 'Retailer']) ||
getField(r, ['FORMAT', 'format', 'Format', 'FORMAT NAME', 'format name']))
.map(r => {
const fileTypesRaw = getField(r, ['FILE TYPE', 'file type', 'File Type', 'fileType', 'file_type', 'FILE_TYPE']);
return {
id: uuidv4(),
country: getField(r, ['COUNTRY', 'country', 'Country']),
retailer: getField(r, ['RETAILER', 'retailer', 'Retailer']),
contentGrouping: getField(r, ['ECOM CONTENT GROUPING', 'Content Grouping', 'contentGrouping', 'content_grouping', 'CONTENT GROUPING', 'category', 'Category']),
division: getField(r, ['DIVISION', 'division', 'Division']),
format: getField(r, ['FORMAT', 'format', 'Format', 'FORMAT NAME', 'format name', 'Format Name']),
dimensions: getField(r, ['ASSET DIMENSION', 'dimensions', 'Dimensions', 'dimension', 'size', 'Size', 'DIMENSIONS']),
maxWeight: getField(r, ['ASSET WEIGHT', 'maxWeight', 'max weight', 'Max Weight', 'file size', 'File Size', 'MAX WEIGHT']),
fileTypes: splitFileTypes(fileTypesRaw),
fileTypesRaw,
maxAssets: getField(r, ['MAX NUMBER OF ASSETS', 'maxAssets', 'max assets', 'Max Assets', 'MAX ASSETS']),
guidelines: getField(r, ['RETAILER ASSET GUIDELINES', 'guidelines', 'Guidelines', 'GUIDELINES', 'notes', 'Notes']),
deliveryMethod: getField(r, ['DELIVERY METHOD FOR SYNDICATION', 'deliveryMethod', 'delivery method', 'Delivery Method']),
deliveryDetail: getField(r, ['DETAILED', 'deliveryDetail', 'delivery detail', 'Delivery Detail', 'delivery', 'Delivery']),
handledBy: getField(r, ['HANDLED BY (WIP)', 'handledBy', 'handled by', 'Handled By']),
namingConvention:getField(r, ['FILE NAMING CONVENTION FOR RETAILER', 'namingConvention', 'naming convention', 'Naming Convention', 'file naming', 'File Naming']),
uploadLink: getField(r, ['LINKS FOR UPLOAD', 'uploadLink', 'upload link', 'Upload Link']),
imageExample: getField(r, ['IMAGE EXAMPLE', 'imageExample', 'image example', 'Image Example']),
notes: getField(r, ['notes', 'Notes', 'NOTES', 'additional notes', 'Additional Notes']),
updatedAt: today,
lastVerified: today,
};
});
res.json({ ok: true, parsed, count: parsed.length });
} catch (e) {
console.error('Parse error:', e);
res.status(500).json({ error: `Failed to parse file: ${e.message}` });
}
});
// ── Static files ──────────────────────────────────────────────────────────────
app.use(express.static(STATIC_DIR, { index: 'index.html' }));
app.get('*', (_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html')));
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(process.env.PORT || 3101, () => {
console.log(`L'Oréal Spec Tool running on port ${process.env.PORT || 3101}`);
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function loadSpecs() {
try { return JSON.parse(fs.readFileSync(SPECS_FILE, 'utf8')); }
catch { return { specs: [], meta: {} }; }
}
function getField(row, keys) {
for (const k of keys) {
if (row[k] !== undefined && row[k] !== null) {
const v = String(row[k]).trim();
if (v && !['NO INFO', 'N/A', '-'].includes(v)) return v;
}
}
return '';
}
function splitFileTypes(raw) {
if (!raw) return [];
const known = new Set(['JPG','JPEG','PNG','TIFF','TIF','PSD','WEBP','SVG','ZIP','PDF','GIF','MP4','MOV','AVI']);
return [...new Set(
raw.toUpperCase().split(/[\s,/|]+/)
.map(p => p.replace(/[^A-Z]/g, ''))
.filter(p => known.has(p))
)];
}

20
server/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "loreal-spec-tool-server",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"express": "^4.18.3",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}

5760
specs.json Normal file

File diff suppressed because it is too large Load diff