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:
parent
696d54dd93
commit
21fcf63431
10 changed files with 8273 additions and 47 deletions
51
.gitignore
vendored
51
.gitignore
vendored
|
|
@ -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
16
Dockerfile
Normal 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
88
convert_specs.py
Normal 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
80
deploy.sh
Normal 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
14
docker-compose.yml
Normal 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
954
index.html
Normal 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>
|
||||
152
server/index.js
Normal file
152
server/index.js
Normal 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
20
server/package.json
Normal 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
5760
specs.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue