Dealer Dashboard
Sign in to access your tools
Good Morning!
Preparing your dashboard...
Initializing...
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NIVS Dealer Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://elfsightcdn.com/platform.js" async></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Montserrat', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
color: #f5f5f5;
min-height: 100vh;
}
/* Notification Bar */
.notification-bar-wrapper {
width: 100%;
background: rgba(255, 255, 255, 0.05);
}
/* Slider */
.slider-wrapper {
width: 100%;
background: rgba(0, 0, 0, 0.2);
padding: 0;
}
/* Main Content Area */
.content-wrapper {
max-width: 1800px;
margin: 40px auto;
padding: 0 20px;
display: grid;
grid-template-columns: 75% 25%;
gap: 30px;
}
/* Left Column - Stats Counter */
.stats-column {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 30px;
}
/* Right Column - Promotional Area */
.promo-column {
background: white;
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
display: flex;
flex-direction: column;
gap: 25px;
align-items: center;
justify-content: flex-start;
}
/* Video Styling */
.promo-video {
width: 100%;
border-radius: 12px;
overflow: hidden;
}
.promo-video video {
width: 100%;
height: auto;
display: block;
border-radius: 12px;
}
/* Logo Styling */
.promo-logo {
width: 100%;
padding: 20px;
background: #f5f5f5;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.promo-logo img {
width: 100%;
height: auto;
max-width: 250px;
}
/* Button Styling */
.promo-button {
display: inline-block;
padding: 15px 40px;
background: #ff4605;
color: white;
text-decoration: none;
font-weight: 700;
font-size: 1.1rem;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(255, 70, 5, 0.3);
}
.promo-button:hover {
background: #1e293b;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(30, 41, 59, 0.4);
}
/* Responsive Design */
@media (max-width: 1200px) {
.content-wrapper {
grid-template-columns: 70% 30%;
}
}
@media (max-width: 900px) {
.content-wrapper {
grid-template-columns: 1fr;
gap: 20px;
}
.promo-column {
min-height: auto;
}
.footer-slider-wrapper {
grid-template-columns: 1fr;
}
}
/* Elfsight Container Styling */
.elfsight-app-989b9ddd-07e6-46d9-90a3-445bff5ae3eb,
.elfsight-app-6cb36a42-3d52-4632-a2a3-18f455e0d844,
.elfsight-app-9daf6073-eac6-4f8c-8931-4a1f5b8b3b5d {
width: 100%;
}
/* Footer Slider Section */
.footer-slider-wrapper {
max-width: 1800px;
margin: 40px auto;
padding: 0 20px;
display: grid;
grid-template-columns: 75% 25%;
gap: 30px;
}
.footer-slider-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 30px;
}
.social-feed-container {
background: white;
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
}
.section-title {
font-size: 10px;
font-weight: 600;
margin-bottom: 15px;
letter-spacing: 0.5px;
}
.section-title.white {
color: white;
}
.section-title.navy {
color: #1e293b;
}
@media (max-width: 900px) {
.footer-slider-container {
width: 100%;
}
}
</style>
</head>
<body>
<!-- Notification Bar at Top -->
<div class="notification-bar-wrapper">
<!-- Elfsight Announcement Bar | NIVS - Dealer Dashboard Announcement Bar -->
<div class="elfsight-app-989b9ddd-07e6-46d9-90a3-445bff5ae3eb" data-elfsight-app-lazy></div>
</div>
<!-- Slider Below Notification -->
<div class="slider-wrapper">
<!-- Elfsight Slider | Untitled Slider -->
<div class="elfsight-app-6cb36a42-3d52-4632-a2a3-18f455e0d844" data-elfsight-app-lazy></div>
</div>
<!-- Main Content: Two Columns -->
<div class="content-wrapper">
<!-- Left Column (75%) - Stats Counter -->
<div class="stats-column">
<div class="section-title white">NiVehicleSales.com Stats:</div>
<!-- Elfsight Number Counter | NIVS Dashboard -->
<div class="elfsight-app-9daf6073-eac6-4f8c-8931-4a1f5b8b3b5d" data-elfsight-app-lazy></div>
</div>
<!-- Right Column (25%) - Promotional Area -->
<div class="promo-column">
<!-- Autoplay Muted Looping Video -->
<div class="promo-video">
<video autoplay muted loop playsinline>
<source src="https://vid.cdn-website.com/dd6b0ba2/videos/SY467LIlQAmaLGJc5dJD_Banner1+updated-v.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>
<!-- Logo Image -->
<div class="promo-logo">
<img src="https://lirp.cdn-website.com/dd6b0ba2/dms3rep/multi/opt/logoheader-1920w.jpg" alt="Momentum Warranties">
</div>
<!-- More Info Button -->
<a href="https://momentumwarranties.co.uk/our-dealer-warranty-plans/" target="_blank" class="promo-button">
More Info >
</a>
</div>
</div>
<!-- Footer Section: Slider + Social Feed -->
<div class="footer-slider-wrapper">
<div class="footer-slider-container">
<!-- Elfsight Slider | NIVS FOOTER SLIDER -->
<div class="elfsight-app-6f1de221-424d-4fb6-91ed-0ca135399673" data-elfsight-app-lazy></div>
</div>
<div class="social-feed-container">
<div class="section-title navy">#NIVS Social Feed:</div>
<!-- Elfsight Social Feed | NIVS Social Feed -->
<div class="elfsight-app-54575cf4-7077-4aba-a564-c820fc49453b" data-elfsight-app-lazy></div>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dealership Ad Generator</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<style>
:root {
--widget-bg: #222732;
--primary-orange: #ff4605;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Montserrat', sans-serif;
background: var(--widget-bg);
color: #f5f5f5;
padding: 40px 20px;
min-height: 100vh;
}
.container {
max-width: 1800px;
margin: 0 auto;
}
.content-wrapper {
display: grid;
grid-template-columns: minmax(400px, 500px) auto auto;
gap: 30px;
align-items: start;
}
.left-column {
position: sticky;
top: 20px;
}
.right-column {
display: flex;
flex-direction: column;
align-items: center;
}
.story-column {
display: flex;
flex-direction: column;
align-items: center;
}
h1 {
font-family: 'Montserrat', sans-serif;
font-size: 3rem;
font-weight: 700;
margin-bottom: 0;
line-height: 1.2;
}
.subtitle {
font-family: 'Montserrat', sans-serif;
font-size: 1rem;
font-weight: 400;
color: white;
margin-top: 8px;
line-height: 1.4;
}
.title-orange {
color: #ff4605;
}
.title-white {
color: white;
}
.instructions {
background: rgba(255, 70, 5, 0.1);
border-left: 4px solid #ff4605;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 0;
}
.instructions h3 {
color: #ff4605;
margin-bottom: 8px;
font-size: 1rem;
line-height: 1.2;
}
.instructions ol {
margin-left: 0;
list-style-position: inside;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 20px;
}
.instructions li {
margin-bottom: 0;
line-height: 1.4;
font-size: 0.85rem;
}
.header-wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: start;
margin-bottom: 30px;
}
.controls {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 25px;
margin-bottom: 40px;
}
.control-section {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.control-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-title {
font-family: 'Montserrat', sans-serif;
font-size: 1.3rem;
font-weight: 700;
color: #ff4605;
margin-bottom: 12px;
}
.control-group {
margin-bottom: 12px;
}
.control-group:last-child {
margin-bottom: 0;
}
label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #ff4605;
}
input[type="text"],
input[type="number"],
input[type="color"],
input[type="file"] {
width: 100%;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-size: 0.95rem;
transition: all 0.3s ease;
font-family: 'Montserrat', sans-serif;
}
input[type="color"] {
height: 45px;
cursor: pointer;
padding: 4px;
}
input[type="file"] {
padding: 8px 14px;
cursor: pointer;
}
input[type="file"]::file-selector-button {
background: #ff4605;
color: white;
border: none;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-family: 'Montserrat', sans-serif;
font-weight: 600;
margin-right: 10px;
font-size: 0.9rem;
}
input[type="file"]::file-selector-button:hover {
background: #e63e04;
}
input:focus {
outline: none;
border-color: #ff4605;
background: rgba(255, 255, 255, 0.15);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.color-preview {
display: flex;
align-items: center;
gap: 15px;
}
.color-swatch {
width: 60px;
height: 40px;
border-radius: 6px;
border: 2px solid rgba(255, 255, 255, 0.3);
}
button {
background: #ff4605;
color: white;
border: none;
padding: 14px 32px;
font-size: 1rem;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-family: 'Montserrat', sans-serif;
letter-spacing: 0.5px;
text-transform: uppercase;
}
button:hover {
background: #e63e04;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(255, 70, 5, 0.4);
}
button:active {
transform: translateY(0);
}
.button-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.helper-text {
font-size: 0.85rem;
color: #999;
margin-top: 5px;
}
.ad-wrapper {
position: sticky;
top: 20px;
}
/* Scale down the ad for better fit */
#dealership-ad {
width: 700px;
height: 700px;
background: white;
position: relative;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border-radius: 0;
}
/* Main Image - Full Background */
.ad-main-image-container {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 66%;
background: #f0f0f0;
overflow: hidden;
}
.ad-main-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 50%;
cursor: grab;
user-select: none;
position: relative;
z-index: 4;
}
.ad-main-image.dragging {
cursor: grabbing;
}
/* Placeholder text positioning */
.ad-main-image-container::before {
content: 'Main Vehicle Image';
position: absolute;
top: 35%;
left: 50%;
transform: translate(-50%, -50%);
font-family: 'Montserrat', sans-serif;
font-weight: bold;
font-size: 28px;
color: #999;
z-index: 1;
pointer-events: none;
opacity: 1;
transition: opacity 0.3s;
}
.ad-main-image-container.has-image::before {
opacity: 0;
}
/* Image positioning controls */
.image-position-controls {
display: none;
margin-top: 10px;
}
.image-position-controls.active {
display: block;
}
.position-sliders {
display: flex;
gap: 15px;
align-items: center;
}
.slider-group {
flex: 1;
}
.slider-group label {
font-size: 0.75rem;
margin-bottom: 4px;
}
input[type="range"] {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
outline: none;
-webkit-appearance: none;
appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #ff4605;
border-radius: 50%;
cursor: pointer;
margin-top: -5px;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: #ff4605;
border-radius: 50%;
cursor: pointer;
border: none;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
background: linear-gradient(to right, #ff4605 0%, #ff4605 var(--value), rgba(255, 255, 255, 0.2) var(--value), rgba(255, 255, 255, 0.2) 100%);
border-radius: 3px;
}
input[type="range"]::-moz-range-track {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
input[type="range"]::-moz-range-progress {
height: 6px;
background: #ff4605;
border-radius: 3px;
}
.image-position-controls {
display: none;
margin-top: 20px;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 20px;
}
.image-position-controls.active {
display: block;
}
.position-sliders {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.slider-group label {
font-family: 'Montserrat', sans-serif;
font-size: 0.9rem;
font-weight: 600;
color: white;
}
/* Overlay gradient for better card visibility */
.ad-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 50%, rgba(44,62,80,0.95) 100%);
z-index: 2;
pointer-events: none;
}
.ad-gradient-overlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 400px;
background: linear-gradient(to top, #2c3e50 0%, rgba(44,62,80,0) 100%);
z-index: 3;
pointer-events: none;
}
/* Solid background color section */
.ad-background-section {
position: absolute;
left: 0;
right: 0;
top: 66%;
bottom: 0;
background: #2c3e50;
z-index: 1;
}
/* Footer line */
.ad-footer-line {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 8px;
background: #3498db;
z-index: 20;
}
/* Story Ad - 9:16 ratio (1080x1920) scaled to 393x700 for display */
#story-ad {
width: 393px;
height: 700px;
background: white;
position: relative;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border-radius: 0;
}
/* Story: Top box - same color as background to create 4:3 image ratio */
#story-ad .ad-top-box {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60px;
background: #2c3e50;
z-index: 1;
}
/* Story: Main image container - adjusted for top box */
#story-ad .ad-main-image-container {
position: absolute;
top: 60px;
left: 0;
right: 0;
height: 295px;
background: #f0f0f0;
overflow: hidden;
}
/* Story: Background section from bottom of image to bottom */
#story-ad .ad-background-section {
top: 355px;
bottom: 0;
}
/* Story: Banner on image area - top right corner */
#story-ad .ad-banner {
position: absolute;
top: 75px;
right: 15px;
font-size: 9pt;
padding: 8px 18px;
border-radius: 20px;
z-index: 5;
}
/* Story: Info card - white card positioned in background section (not floating) */
#story-ad .ad-vehicle-info {
position: absolute;
bottom: auto;
top: 370px;
left: 20px;
right: 20px;
padding: 20px;
border-radius: 20px;
transform: none;
z-index: 10;
}
/* Story: Smaller text for compact vertical layout */
#story-ad .ad-year {
font-size: 7.5pt;
padding: 5px 11px;
margin-bottom: 6px;
border-radius: 6px;
}
#story-ad .ad-make {
font-size: 22pt;
margin-bottom: 6px;
line-height: 1.1;
}
#story-ad .ad-model-variant {
font-size: 10pt;
margin-bottom: 12px;
}
#story-ad .ad-pipe {
height: 14px;
width: 2px;
}
#story-ad .ad-price {
font-size: 26pt;
margin-bottom: 0;
}
/* Story: Specs box - rounded box below card, centered */
#story-ad .ad-specs-box {
position: absolute;
bottom: auto;
top: 560px;
left: 20px;
right: 20px;
padding: 12px 15px;
gap: 6px;
min-width: auto;
border-radius: 15px;
z-index: 15;
}
#story-ad .ad-spec-item {
gap: 4px;
}
#story-ad .ad-spec-icon {
width: 18px;
height: 18px;
}
#story-ad .ad-spec-label {
font-size: 5.5pt;
}
#story-ad .ad-spec-text {
font-size: 8pt;
}
/* Story: Footer line - thinner red line at very bottom */
#story-ad .ad-footer-line {
height: 6px;
z-index: 20;
}
/* Floating Info Card */
.ad-vehicle-info {
position: absolute;
bottom: 50px;
left: 50px;
right: 50px;
background: white;
border-radius: 25px;
padding: 35px 40px;
box-shadow: 0 15px 60px rgba(0, 0, 0, 0.4);
z-index: 10;
}
.ad-year {
font-family: 'Montserrat', sans-serif;
font-weight: 800;
font-size: 9pt;
color: white;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 12px;
display: inline-block;
padding: 8px 16px;
border-radius: 8px;
}
.ad-make {
font-family: 'Montserrat', sans-serif;
font-weight: 900;
font-size: 36pt;
color: #222;
letter-spacing: 0px;
text-transform: uppercase;
line-height: 0.95;
margin-bottom: 12px;
}
.ad-model-variant {
font-family: 'Montserrat', sans-serif;
font-weight: 600;
font-size: 14pt;
color: #666;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.ad-pipe {
display: inline-block;
width: 2px;
height: 20px;
background: #ddd;
}
/* Bottom Row - Price */
.ad-bottom-row {
position: relative;
}
.ad-price {
font-family: 'Montserrat', sans-serif;
font-weight: 900;
font-size: 42pt;
line-height: 1;
flex-shrink: 0;
}
/* Specs Box */
.ad-specs-box {
background: #2c3e50;
border-radius: 15px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding: 10px;
padding-right: 42px;
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
position: absolute;
right: -40px;
bottom: 0;
}
.ad-spec-item {
display: flex;
align-items: center;
gap: 5px;
flex: 1;
min-width: 0;
}
.ad-spec-icon {
width: 22px;
height: 22px;
flex-shrink: 0;
}
.ad-spec-icon svg {
width: 100%;
height: 100%;
fill: white;
}
.ad-spec-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.ad-spec-label {
font-family: 'Montserrat', sans-serif;
font-weight: 600;
font-size: 6.5pt;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1;
}
.ad-spec-text {
font-family: 'Montserrat', sans-serif;
font-weight: 700;
font-size: 9pt;
color: white;
letter-spacing: 0.3px;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Banner Badge */
.ad-banner {
position: absolute;
top: 30px;
right: 30px;
background: white;
color: #222;
padding: 15px 30px;
font-family: 'Montserrat', sans-serif;
font-weight: 800;
font-size: 12pt;
text-transform: uppercase;
letter-spacing: 1.5px;
border-radius: 12px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.25);
z-index: 15;
display: none;
}
.ad-banner.active {
display: block;
}
/* Remove old elements */
.ad-sub-images {
display: none;
}
.ad-color-bar {
display: none;
}
/* Responsive Design */
@media (max-width: 1400px) {
.header-wrapper {
grid-template-columns: 1fr;
gap: 20px;
}
.content-wrapper {
grid-template-columns: 1fr;
}
.left-column {
position: relative;
top: 0;
}
.right-column {
min-height: auto;
}
.ad-wrapper {
position: relative;
top: 0;
margin: 40px auto 0;
}
#dealership-ad {
max-width: 600px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header-wrapper">
<div>
<h1><span class="title-orange">Dealership</span> <span class="title-white">Ad</span> <span class="title-orange">Generator</span></h1>
<p class="subtitle">Create professional vehicle ads in seconds with <span class="title-orange">#NIVS</span>!</p>
</div>
<div>
<div class="instructions">
<h3>🚀 How to Use</h3>
<ol>
<li>Customise your dealership brand colours</li>
<li>Upload Vehicle Image & Adjust</li>
<li>Fill in Vehicle Details & Specifications</li>
<li>Click "Generate" to preview or "Download"</li>
</ol>
</div>
</div>
</div>
<div class="content-wrapper">
<!-- Left Column - Controls -->
<div class="left-column">
<div class="controls">
<!-- Brand Colours Section -->
<div class="control-section">
<h2 class="section-title">🎨 Brand Colours</h2>
<div class="grid-2">
<div class="control-group">
<label for="primary-color">Primary Colour</label>
<div class="color-preview">
<input type="color" id="primary-color" value="#3498db" onchange="updateColors()">
<div class="color-swatch" id="primary-swatch" style="background: #3498db;"></div>
</div>
<div class="helper-text">Used for price, year and footer line</div>
</div>
<div class="control-group">
<label for="secondary-color">Secondary Colour</label>
<div class="color-preview">
<input type="color" id="secondary-color" value="#2c3e50" onchange="updateColors()">
<div class="color-swatch" id="secondary-swatch" style="background: #2c3e50;"></div>
</div>
<div class="helper-text">Used for divider and spec box</div>
</div>
<div class="control-group">
<label for="banner-color">Banner Colour</label>
<div class="color-preview">
<input type="color" id="banner-color" value="#ffffff" onchange="updateColors()">
<div class="color-swatch" id="banner-swatch" style="background: #ffffff;"></div>
</div>
<div class="helper-text">Colour of optional banner</div>
</div>
<div class="control-group">
<label for="background-color">Background Colour</label>
<div class="color-preview">
<input type="color" id="background-color" value="#2c3e50" onchange="updateColors()">
<div class="color-swatch" id="background-swatch" style="background: #2c3e50;"></div>
</div>
<div class="helper-text">Solid background for bottom section</div>
</div>
</div>
</div>
<!-- Vehicle Details Section -->
<div class="control-section">
<h2 class="section-title">🚗 Vehicle Details</h2>
<div class="grid-3">
<div class="control-group">
<label for="vehicle-year">Year *</label>
<input type="text" id="vehicle-year" value="2020" placeholder="e.g., 2020">
</div>
<div class="control-group">
<label for="vehicle-make">Make *</label>
<input type="text" id="vehicle-make" value="BMW" placeholder="e.g., BMW">
</div>
<div class="control-group">
<label for="vehicle-model">Model *</label>
<input type="text" id="vehicle-model" value="3 Series" placeholder="e.g., 3 Series">
</div>
</div>
<div class="grid-3">
<div class="control-group">
<label for="vehicle-variant">Variant</label>
<input type="text" id="vehicle-variant" value="330i M Sport" placeholder="e.g., 330i M Sport">
</div>
<div class="control-group">
<label for="vehicle-price">Price (£) *</label>
<input type="number" id="vehicle-price" value="24995" placeholder="24995">
</div>
<div class="control-group">
<label for="vehicle-banner">Banner</label>
<input type="text" id="vehicle-banner" value="" placeholder="e.g., New Arrival, Low Mileage">
</div>
</div>
<div class="grid-3">
<div class="control-group">
<label for="vehicle-mileage">Mileage</label>
<input type="text" id="vehicle-mileage" value="12,450" placeholder="e.g., 12,450">
</div>
<div class="control-group">
<label for="vehicle-fuel">Fuel Type</label>
<input type="text" id="vehicle-fuel" value="Diesel" placeholder="e.g., Diesel, Petrol">
</div>
<div class="control-group">
<label for="vehicle-transmission">Transmission</label>
<input type="text" id="vehicle-transmission" value="Automatic" placeholder="e.g., Automatic, Manual">
</div>
</div>
</div>
<!-- Images Section -->
<div class="control-section">
<h2 class="section-title">📸 Image</h2>
<div class="control-group">
<label for="main-image">Main Vehicle Image *</label>
<input type="file" id="main-image" accept="image/*" onchange="handleMainImageUpload(event)">
<div class="helper-text">Hero image will fill entire background</div>
</div>
</div>
<div class="button-group">
<button onclick="updateAd()">Generate Ads</button>
</div>
</div>
</div>
<!-- Right Column - Ad Preview -->
<div class="right-column">
<div class="ad-wrapper">
<h2 class="section-title" style="margin-bottom: 20px;">👀 Ad Preview</h2>
<div id="dealership-ad">
<!-- Banner Badge -->
<div class="ad-banner" id="ad-banner"></div>
<!-- Solid Background Color Section -->
<div class="ad-background-section" id="ad-background-section"></div>
<!-- Footer Line -->
<div class="ad-footer-line" id="ad-footer-line"></div>
<!-- Main Hero Image (Full Background) -->
<div class="ad-main-image-container">
<img class="ad-main-image" id="ad-main-image" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1080' height='1080'%3E%3Crect fill='%23e0e0e0' width='1080' height='1080'/%3E%3C/svg%3E" alt="Main Vehicle">
<div class="ad-overlay"></div>
<div class="ad-gradient-overlay" id="ad-gradient-overlay"></div>
</div>
<!-- Floating Info Card -->
<div class="ad-vehicle-info">
<div class="ad-year" id="ad-year">2020</div>
<div class="ad-make" id="ad-make">BMW</div>
<div class="ad-model-variant">
<span id="ad-model">3 Series</span>
<span class="ad-pipe" id="ad-pipe"></span>
<span id="ad-variant">330i M Sport</span>
</div>
<div class="ad-bottom-row">
<div class="ad-price" id="ad-price">£24,995</div>
<div class="ad-specs-box" id="ad-specs-box">
<div class="ad-spec-item">
<div class="ad-spec-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4C7.03 4 3 8.03 3 13c0 3.54 2.06 6.58 5.05 8.05l.95-1.9C6.47 17.87 5 15.59 5 13c0-3.87 3.13-7 7-7s7 3.13 7 7c0 2.59-1.47 4.87-3.8 6.05l.95 1.9C19.94 19.58 22 16.54 22 13c0-4.97-4.03-9-10-9zm0 4c-2.76 0-5 2.24-5 5 0 1.82 1.03 3.38 2.51 4.19l.99-1.98C9.56 14.63 9 13.87 9 13c0-1.66 1.34-3 3-3s3 1.34 3 3c0 .87-.56 1.63-1.5 2.21l.99 1.98C16.97 16.38 18 14.82 18 13c0-2.76-2.24-5-5-5zm-.5 4c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5z"/>
<circle cx="12" cy="12.5" r="1.5"/>
<path d="M12 9v3.5"/>
</svg>
</div>
<div class="ad-spec-content">
<div class="ad-spec-label">MILEAGE:</div>
<div class="ad-spec-text" id="ad-mileage">12,450</div>
</div>
</div>
<div class="ad-spec-item">
<div class="ad-spec-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.77 7.23l.01-.01-3.72-3.72L15 4.56l2.11 2.11c-.94.36-1.61 1.26-1.61 2.33 0 1.38 1.12 2.5 2.5 2.5.36 0 .69-.08 1-.21v7.21c0 .55-.45 1-1 1s-1-.45-1-1V14c0-1.1-.9-2-2-2h-1V5c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v16h10v-7.5h1.5v5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V9c0-.69-.28-1.32-.73-1.77zM12 10H6V5h6v5zm6 0c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1z"/>
</svg>
</div>
<div class="ad-spec-content">
<div class="ad-spec-label">FUEL:</div>
<div class="ad-spec-text" id="ad-fuel">Diesel</div>
</div>
</div>
<div class="ad-spec-item">
<div class="ad-spec-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M7 19h10V5H7v14zM9 7h6v10H9V7z"/>
<path d="M11 3h2v2h-2zM11 19h2v2h-2z"/>
<rect x="10" y="9" width="4" height="2"/>
<rect x="10" y="13" width="4" height="2"/>
</svg>
</div>
<div class="ad-spec-content">
<div class="ad-spec-label">TRANS:</div>
<div class="ad-spec-text" id="ad-transmission">Automatic</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Image Position Controls for Square Ad -->
<div class="image-position-controls" id="square-position-controls">
<div class="position-sliders">
<div class="slider-group">
<label for="square-position-y">Vertical</label>
<input type="range" id="square-position-y" min="0" max="100" value="50" oninput="updateSquareImagePosition()">
</div>
<div class="slider-group">
<label for="square-zoom">Zoom</label>
<input type="range" id="square-zoom" min="100" max="200" value="100" oninput="updateSquareImagePosition()">
</div>
<div class="slider-group">
<label for="square-position-x">Horizontal</label>
<input type="range" id="square-position-x" min="0" max="100" value="50" oninput="updateSquareImagePosition()">
</div>
</div>
</div>
<button onclick="downloadSquareAd()" style="width: 100%; margin-top: 15px; background: white; color: #222732; border: none; padding: 14px 32px; font-size: 1rem; font-weight: 700; border-radius: 8px; cursor: pointer; font-family: 'Montserrat', sans-serif;">Download Ad</button>
</div>
<!-- Story Column - Story Ad Preview -->
<div class="story-column">
<div>
<h2 class="section-title" style="margin-bottom: 20px;">📱 Story Preview</h2>
<div class="ad-wrapper">
<div id="story-ad">
<!-- Top Box - same color as background -->
<div class="ad-top-box" id="story-top-box"></div>
<!-- Banner Badge -->
<div class="ad-banner" id="story-banner"></div>
<!-- Solid Background Color Section -->
<div class="ad-background-section" id="story-background-section"></div>
<!-- Footer Line -->
<div class="ad-footer-line" id="story-footer-line"></div>
<!-- Main Hero Image (Full Background) -->
<div class="ad-main-image-container">
<img class="ad-main-image" id="story-main-image" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1080' height='1080'%3E%3Crect fill='%23e0e0e0' width='1080' height='1080'/%3E%3C/svg%3E" alt="Main Vehicle">
<div class="ad-overlay"></div>
<div class="ad-gradient-overlay" id="story-gradient-overlay"></div>
</div>
<!-- Floating Info Card -->
<div class="ad-vehicle-info">
<div class="ad-year" id="story-year">2020</div>
<div class="ad-make" id="story-make">BMW</div>
<div class="ad-model-variant">
<span id="story-model">3 Series</span>
<span class="ad-pipe" id="story-pipe"></span>
<span id="story-variant">330i M Sport</span>
</div>
<div class="ad-price" id="story-price">£24,995</div>
</div>
<!-- Specs Box - Separate from card, at bottom -->
<div class="ad-specs-box" id="story-specs-box">
<div class="ad-spec-item">
<div class="ad-spec-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4C7.03 4 3 8.03 3 13c0 3.54 2.06 6.58 5.05 8.05l.95-1.9C6.47 17.87 5 15.59 5 13c0-3.87 3.13-7 7-7s7 3.13 7 7c0 2.59-1.47 4.87-3.8 6.05l.95 1.9C19.94 19.58 22 16.54 22 13c0-4.97-4.03-9-10-9zm0 4c-2.76 0-5 2.24-5 5 0 1.82 1.03 3.38 2.51 4.19l.99-1.98C9.56 14.63 9 13.87 9 13c0-1.66 1.34-3 3-3s3 1.34 3 3c0 .87-.56 1.63-1.5 2.21l.99 1.98C16.97 16.38 18 14.82 18 13c0-2.76-2.24-5-5-5zm-.5 4c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5z"/>
<circle cx="12" cy="12.5" r="1.5"/>
<path d="M12 9v3.5"/>
</svg>
</div>
<div class="ad-spec-content">
<div class="ad-spec-label">MILEAGE:</div>
<div class="ad-spec-text" id="story-mileage">12,450</div>
</div>
</div>
<div class="ad-spec-item">
<div class="ad-spec-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.77 7.23l.01-.01-3.72-3.72L15 4.56l2.11 2.11c-.94.36-1.61 1.26-1.61 2.33 0 1.38 1.12 2.5 2.5 2.5.36 0 .69-.08 1-.21v7.21c0 .55-.45 1-1 1s-1-.45-1-1V14c0-1.1-.9-2-2-2h-1V5c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v16h10v-7.5h1.5v5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V9c0-.69-.28-1.32-.73-1.77zM12 10H6V5h6v5zm6 0c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1z"/>
</svg>
</div>
<div class="ad-spec-content">
<div class="ad-spec-label">FUEL:</div>
<div class="ad-spec-text" id="story-fuel">Diesel</div>
</div>
</div>
<div class="ad-spec-item">
<div class="ad-spec-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M7 19h10V5H7v14zM9 7h6v10H9V7z"/>
<path d="M11 3h2v2h-2zM11 19h2v2h-2z"/>
<rect x="10" y="9" width="4" height="2"/>
<rect x="10" y="13" width="4" height="2"/>
</svg>
</div>
<div class="ad-spec-content">
<div class="ad-spec-label">TRANS:</div>
<div class="ad-spec-text" id="story-transmission">Automatic</div>
</div>
</div>
</div>
</div>
</div>
<!-- Image Position Controls for Story Ad -->
<div class="image-position-controls" id="story-position-controls">
<div class="position-sliders">
<div class="slider-group">
<label for="story-position-y">Vertical</label>
<input type="range" id="story-position-y" min="0" max="100" value="50" oninput="updateStoryImagePosition()">
</div>
<div class="slider-group">
<label for="story-zoom">Zoom</label>
<input type="range" id="story-zoom" min="100" max="200" value="100" oninput="updateStoryImagePosition()">
</div>
<div class="slider-group">
<label for="story-position-x">Horizontal</label>
<input type="range" id="story-position-x" min="0" max="100" value="50" oninput="updateStoryImagePosition()">
</div>
</div>
</div>
<button onclick="downloadStoryAd()" style="width: 100%; margin-top: 15px; background: white; color: #222732; border: none; padding: 14px 32px; font-size: 1rem; font-weight: 700; border-radius: 8px; cursor: pointer; font-family: 'Montserrat', sans-serif;">Download Story</button>
</div>
</div>
</div>
<script>
let mainImageData = null;
function handleMainImageUpload(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
mainImageData = e.target.result;
const imgElement = document.getElementById('ad-main-image');
const storyImgElement = document.getElementById('story-main-image');
const containerElement = document.querySelector('.ad-main-image-container');
imgElement.src = mainImageData;
storyImgElement.src = mainImageData;
// Hide placeholder text and show position controls
containerElement.classList.add('has-image');
document.getElementById('square-position-controls').classList.add('active');
document.getElementById('story-position-controls').classList.add('active');
// Reset square ad sliders
document.getElementById('square-position-x').value = 50;
document.getElementById('square-position-y').value = 50;
document.getElementById('square-zoom').value = 100;
// Reset story ad sliders
document.getElementById('story-position-x').value = 50;
document.getElementById('story-position-y').value = 50;
document.getElementById('story-zoom').value = 100;
updateSquareImagePosition();
updateStoryImagePosition();
};
reader.readAsDataURL(file);
}
}
function updateSquareImagePosition() {
const posX = document.getElementById('square-position-x').value;
const posY = document.getElementById('square-position-y').value;
const zoom = document.getElementById('square-zoom').value;
// Update slider fill
document.getElementById('square-position-x').style.setProperty('--value', posX + '%');
document.getElementById('square-position-y').style.setProperty('--value', posY + '%');
document.getElementById('square-zoom').style.setProperty('--value', ((zoom - 100) / 100 * 100) + '%');
// Update SQUARE ad image
const imgElement = document.getElementById('ad-main-image');
imgElement.style.objectPosition = `${posX}% ${posY}%`;
imgElement.style.transform = `scale(${zoom / 100})`;
imgElement.style.transformOrigin = `${posX}% ${posY}%`;
}
function updateStoryImagePosition() {
const posX = document.getElementById('story-position-x').value;
const posY = document.getElementById('story-position-y').value;
const zoom = document.getElementById('story-zoom').value;
// Update slider fill
document.getElementById('story-position-x').style.setProperty('--value', posX + '%');
document.getElementById('story-position-y').style.setProperty('--value', posY + '%');
document.getElementById('story-zoom').style.setProperty('--value', ((zoom - 100) / 100 * 100) + '%');
// Update STORY ad image
const storyImgElement = document.getElementById('story-main-image');
storyImgElement.style.objectPosition = `${posX}% ${posY}%`;
storyImgElement.style.transform = `scale(${zoom / 100})`;
storyImgElement.style.transformOrigin = `${posX}% ${posY}%`;
}
function updateColors() {
const primaryColor = document.getElementById('primary-color').value;
const secondaryColor = document.getElementById('secondary-color').value;
const bannerColor = document.getElementById('banner-color').value;
const backgroundColor = document.getElementById('background-color').value;
// Update color swatches
document.getElementById('primary-swatch').style.background = primaryColor;
document.getElementById('secondary-swatch').style.background = secondaryColor;
document.getElementById('banner-swatch').style.background = bannerColor;
document.getElementById('background-swatch').style.background = backgroundColor;
// Update SQUARE ad elements
document.getElementById('ad-price').style.color = primaryColor;
document.getElementById('ad-year').style.background = primaryColor;
document.getElementById('ad-footer-line').style.background = primaryColor;
document.getElementById('ad-pipe').style.background = secondaryColor;
document.getElementById('ad-specs-box').style.background = secondaryColor;
document.getElementById('ad-background-section').style.background = backgroundColor;
// Update STORY ad elements
document.getElementById('story-price').style.color = primaryColor;
document.getElementById('story-year').style.background = primaryColor;
document.getElementById('story-footer-line').style.background = primaryColor;
document.getElementById('story-pipe').style.background = secondaryColor;
document.getElementById('story-specs-box').style.background = secondaryColor;
document.getElementById('story-background-section').style.background = backgroundColor;
document.getElementById('story-top-box').style.background = backgroundColor;
// Update gradient overlays with background color
const rgb = parseInt(backgroundColor.slice(1), 16);
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = (rgb >> 0) & 0xff;
document.getElementById('ad-gradient-overlay').style.background = `linear-gradient(to top, rgb(${r},${g},${b}) 0%, rgba(${r},${g},${b},0) 100%)`;
document.getElementById('story-gradient-overlay').style.background = `linear-gradient(to top, rgb(${r},${g},${b}) 0%, rgba(${r},${g},${b},0) 100%)`;
// Update banner backgrounds
const bannerElement = document.getElementById('ad-banner');
const storyBannerElement = document.getElementById('story-banner');
bannerElement.style.background = bannerColor;
storyBannerElement.style.background = bannerColor;
// Calculate text color based on banner background brightness
const bannerRgb = parseInt(bannerColor.slice(1), 16);
const bannerR = (bannerRgb >> 16) & 0xff;
const bannerG = (bannerRgb >> 8) & 0xff;
const bannerB = (bannerRgb >> 0) & 0xff;
const brightness = (bannerR * 299 + bannerG * 587 + bannerB * 114) / 1000;
const textColor = brightness > 128 ? '#222' : '#fff';
bannerElement.style.color = textColor;
storyBannerElement.style.color = textColor;
}
function updateAd() {
const year = document.getElementById('vehicle-year').value;
const make = document.getElementById('vehicle-make').value.toUpperCase();
const model = document.getElementById('vehicle-model').value;
const variant = document.getElementById('vehicle-variant').value;
const price = document.getElementById('vehicle-price').value;
const banner = document.getElementById('vehicle-banner').value;
const mileage = document.getElementById('vehicle-mileage').value;
const fuel = document.getElementById('vehicle-fuel').value;
const transmission = document.getElementById('vehicle-transmission').value;
// Update SQUARE ad
document.getElementById('ad-year').textContent = year;
document.getElementById('ad-make').textContent = make;
document.getElementById('ad-model').textContent = model;
document.getElementById('ad-variant').textContent = variant;
document.getElementById('ad-price').textContent = `£${parseInt(price).toLocaleString()}`;
document.getElementById('ad-mileage').textContent = mileage || '12,450';
document.getElementById('ad-fuel').textContent = fuel;
document.getElementById('ad-transmission').textContent = transmission;
// Update STORY ad
document.getElementById('story-year').textContent = year;
document.getElementById('story-make').textContent = make;
document.getElementById('story-model').textContent = model;
document.getElementById('story-variant').textContent = variant;
document.getElementById('story-price').textContent = `£${parseInt(price).toLocaleString()}`;
document.getElementById('story-mileage').textContent = mileage || '12,450';
document.getElementById('story-fuel').textContent = fuel;
document.getElementById('story-transmission').textContent = transmission;
// Update banner for both ads
const bannerElement = document.getElementById('ad-banner');
const storyBannerElement = document.getElementById('story-banner');
if (banner) {
bannerElement.textContent = banner;
bannerElement.classList.add('active');
storyBannerElement.textContent = banner;
storyBannerElement.classList.add('active');
} else {
bannerElement.classList.remove('active');
storyBannerElement.classList.remove('active');
}
// Hide divider if no variant (for both ads)
const divider = document.getElementById('ad-pipe');
const variantElement = document.getElementById('ad-variant');
const storyDivider = document.getElementById('story-pipe');
const storyVariantElement = document.getElementById('story-variant');
if (!variant) {
divider.style.display = 'none';
variantElement.style.display = 'none';
storyDivider.style.display = 'none';
storyVariantElement.style.display = 'none';
} else {
divider.style.display = 'block';
variantElement.style.display = 'block';
storyDivider.style.display = 'block';
storyVariantElement.style.display = 'block';
}
// Update colors
updateColors();
}
async function downloadSquareAd() {
const adElement = document.getElementById('dealership-ad');
const imgElement = document.getElementById('ad-main-image');
// Store original image
const originalSrc = imgElement.src;
const originalTransform = imgElement.style.transform;
const originalObjectPosition = imgElement.style.objectPosition;
try {
// Create a temporary canvas to pre-render the image with transforms
const tempCanvas = document.createElement('canvas');
const container = imgElement.parentElement;
tempCanvas.width = container.offsetWidth * 2;
tempCanvas.height = container.offsetHeight * 2;
const ctx = tempCanvas.getContext('2d');
// Load the image
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = originalSrc;
});
// Get transform values for SQUARE ad
const posX = parseFloat(document.getElementById('square-position-x').value);
const posY = parseFloat(document.getElementById('square-position-y').value);
const zoom = parseFloat(document.getElementById('square-zoom').value) / 100;
// Calculate dimensions
const canvasWidth = tempCanvas.width;
const canvasHeight = tempCanvas.height;
const imgRatio = img.width / img.height;
const containerRatio = canvasWidth / canvasHeight;
let drawWidth, drawHeight;
if (imgRatio > containerRatio) {
drawHeight = canvasHeight * zoom;
drawWidth = drawHeight * imgRatio;
} else {
drawWidth = canvasWidth * zoom;
drawHeight = drawWidth / imgRatio;
}
// Calculate position based on object-position percentages
const offsetX = (canvasWidth - drawWidth) * (posX / 100);
const offsetY = (canvasHeight - drawHeight) * (posY / 100);
// Draw the image
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
// Replace the image element temporarily with the canvas
const canvasDataUrl = tempCanvas.toDataURL('image/png');
imgElement.src = canvasDataUrl;
imgElement.style.transform = 'none';
imgElement.style.objectPosition = 'center';
imgElement.style.objectFit = 'fill';
// Wait for image to update
await new Promise(resolve => setTimeout(resolve, 100));
// Capture the ad
const canvas = await html2canvas(adElement, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
// Restore original image
imgElement.src = originalSrc;
imgElement.style.transform = originalTransform;
imgElement.style.objectPosition = originalObjectPosition;
imgElement.style.objectFit = 'cover';
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const make = document.getElementById('vehicle-make').value;
const model = document.getElementById('vehicle-model').value;
link.download = `${make}-${model}-dealership-ad.png`.replace(/\s+/g, '-');
link.href = url;
link.click();
URL.revokeObjectURL(url);
});
} catch (error) {
console.error('Error generating image:', error);
alert('Error generating image. Please try again.');
// Restore original on error
imgElement.src = originalSrc;
imgElement.style.transform = originalTransform;
imgElement.style.objectPosition = originalObjectPosition;
imgElement.style.objectFit = 'cover';
}
}
async function downloadStoryAd() {
const adElement = document.getElementById('story-ad');
const imgElement = document.getElementById('story-main-image');
// Store original image
const originalSrc = imgElement.src;
const originalTransform = imgElement.style.transform;
const originalObjectPosition = imgElement.style.objectPosition;
try {
// Create a temporary canvas to pre-render the image with transforms
const tempCanvas = document.createElement('canvas');
const container = imgElement.parentElement;
tempCanvas.width = container.offsetWidth * 2;
tempCanvas.height = container.offsetHeight * 2;
const ctx = tempCanvas.getContext('2d');
// Load the image
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = originalSrc;
});
// Get transform values for STORY ad
const posX = parseFloat(document.getElementById('story-position-x').value);
const posY = parseFloat(document.getElementById('story-position-y').value);
const zoom = parseFloat(document.getElementById('story-zoom').value) / 100;
// Calculate dimensions
const canvasWidth = tempCanvas.width;
const canvasHeight = tempCanvas.height;
const imgRatio = img.width / img.height;
const containerRatio = canvasWidth / canvasHeight;
let drawWidth, drawHeight;
if (imgRatio > containerRatio) {
drawHeight = canvasHeight * zoom;
drawWidth = drawHeight * imgRatio;
} else {
drawWidth = canvasWidth * zoom;
drawHeight = drawWidth / imgRatio;
}
// Calculate position based on object-position percentages
const offsetX = (canvasWidth - drawWidth) * (posX / 100);
const offsetY = (canvasHeight - drawHeight) * (posY / 100);
// Draw the image
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
// Replace the image element temporarily with the canvas
const canvasDataUrl = tempCanvas.toDataURL('image/png');
imgElement.src = canvasDataUrl;
imgElement.style.transform = 'none';
imgElement.style.objectPosition = 'center';
imgElement.style.objectFit = 'fill';
// Wait for image to update
await new Promise(resolve => setTimeout(resolve, 100));
// Capture the ad
const canvas = await html2canvas(adElement, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
// Restore original image
imgElement.src = originalSrc;
imgElement.style.transform = originalTransform;
imgElement.style.objectPosition = originalObjectPosition;
imgElement.style.objectFit = 'cover';
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const make = document.getElementById('vehicle-make').value;
const model = document.getElementById('vehicle-model').value;
link.download = `${make}-${model}-story-ad.png`.replace(/\s+/g, '-');
link.href = url;
link.click();
URL.revokeObjectURL(url);
});
} catch (error) {
console.error('Error generating image:', error);
alert('Error generating image. Please try again.');
// Restore original on error
imgElement.src = originalSrc;
imgElement.style.transform = originalTransform;
imgElement.style.objectPosition = originalObjectPosition;
imgElement.style.objectFit = 'cover';
}
}
// Initialize
updateAd();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Social Post Text Generator - NIVS</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--widget-bg: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Montserrat', sans-serif;
background: var(--widget-bg);
color: #f5f5f5;
padding: 40px 20px;
min-height: 100vh;
}
.container {
max-width: 1600px;
margin: 0 auto;
}
h1 {
font-family: 'Montserrat', sans-serif;
font-size: 3rem;
font-weight: 700;
margin-bottom: 10px;
line-height: 1.2;
}
.title-orange {
color: #ff4605;
}
.title-white {
color: white;
}
.subtitle {
font-family: 'Montserrat', sans-serif;
font-size: 1rem;
font-weight: 400;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 40px;
line-height: 1.4;
}
.content-wrapper {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 30px;
align-items: start;
}
.controls {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 30px;
}
.control-section {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.control-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-title {
font-family: 'Montserrat', sans-serif;
font-size: 1.2rem;
font-weight: 700;
color: #ff4605;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.control-group {
margin-bottom: 12px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 6px;
color: white;
font-size: 0.85rem;
}
input[type="text"],
input[type="number"],
select,
textarea {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: white;
font-family: 'Montserrat', sans-serif;
font-size: 0.9rem;
transition: all 0.3s ease;
}
textarea {
resize: vertical;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #ff4605;
background: rgba(255, 255, 255, 0.15);
}
input::placeholder,
textarea::placeholder {
color: rgba(255, 255, 255, 0.5);
}
select {
cursor: pointer;
}
option {
background: #1e293b;
color: white;
}
.helper-text {
font-size: 0.8rem;
color: #999;
margin-top: 4px;
}
button {
background: #ff4605;
color: white;
border: none;
padding: 14px 32px;
font-size: 1rem;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
font-family: 'Montserrat', sans-serif;
transition: all 0.3s ease;
width: 100%;
}
button:hover {
background: #e03d04;
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 70, 5, 0.4);
}
button:active {
transform: translateY(0);
}
.preview-section {
position: sticky;
top: 20px;
}
.preview-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 30px;
min-height: 400px;
}
.generated-text {
background: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 25px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 1.05rem;
line-height: 1.6;
color: #1c1e21;
white-space: pre-wrap;
min-height: 300px;
margin-bottom: 20px;
}
.copy-button {
background: white;
color: #222732;
margin-top: 15px;
}
.copy-button:hover {
background: #f0f0f0;
}
.toggle-container {
display: flex;
align-items: center;
gap: 10px;
}
.toggle-switch {
position: relative;
width: 50px;
height: 26px;
background: rgba(255, 255, 255, 0.2);
border-radius: 13px;
cursor: pointer;
transition: background 0.3s;
}
.toggle-switch.active {
background: #ff4605;
}
.toggle-slider {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.3s;
}
.toggle-switch.active .toggle-slider {
transform: translateX(24px);
}
.toggle-label {
font-weight: 600;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px;">
<div>
<h1><span class="title-orange">Social Post</span> <span class="title-white">Text</span> <span class="title-orange">Generator</span></h1>
<p class="subtitle" style="margin-bottom: 0;">Create engaging social media posts for your vehicle listings with <span style="color: #ff4605;">#NIVS</span>!</p>
</div>
<!-- How to Use Section -->
<div style="background: rgba(255, 70, 5, 0.1); border-left: 4px solid #ff4605; border-radius: 8px; padding: 15px 20px; max-width: 750px;">
<h2 style="color: #ff4605; font-size: 1rem; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 8px;">
🚀 How to Use
</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px 40px; color: white; font-size: 0.9rem;">
<div>1. Customise title prefix and style</div>
<div>2. Fill in Vehicle Details & Key Features</div>
<div>3. Enter Dealership contact information</div>
<div>4. Click "Generate Post" to preview or "Copy"</div>
</div>
</div>
</div>
<div class="content-wrapper">
<!-- Left Column - Title, Vehicle Details & Features -->
<div class="controls">
<div class="control-section">
<h2 class="section-title">📢 Title</h2>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 12px;">
<div>
<label for="title-prefix">Prefix</label>
<select id="title-prefix">
<option value="none">None</option>
<option value="forsale">FOR SALE</option>
<option value="justin">JUST IN</option>
</select>
</div>
<div>
<label for="title-style">Style</label>
<select id="title-style">
<option value="normal">Normal</option>
<option value="bold">Bold</option>
<option value="italic">Italic</option>
<option value="bolditalic">Bold Italic</option>
</select>
</div>
</div>
</div>
<div class="control-section">
<h2 class="section-title">🚗 Vehicle Details</h2>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 10px;">
<div>
<label for="vehicle-year">Year</label>
<input type="number" id="vehicle-year" placeholder="2023" value="2023">
</div>
<div>
<label for="vehicle-make">Make</label>
<input type="text" id="vehicle-make" placeholder="BMW" value="BMW">
</div>
<div>
<label for="vehicle-model">Model</label>
<input type="text" id="vehicle-model" placeholder="3 Series" value="3 Series">
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 10px;">
<div>
<label for="vehicle-variant">Variant</label>
<input type="text" id="vehicle-variant" placeholder="330i M Sport">
</div>
<div>
<label for="vehicle-price">Price (£)</label>
<input type="number" id="vehicle-price" placeholder="24995" value="24995">
</div>
<div>
<label for="vehicle-mileage">Mileage</label>
<input type="text" id="vehicle-mileage" placeholder="12,450" value="12,450">
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;">
<div>
<label for="vehicle-fuel">Fuel</label>
<select id="vehicle-fuel">
<option value="Petrol">Petrol</option>
<option value="Diesel" selected>Diesel</option>
<option value="Hybrid">Hybrid</option>
<option value="Electric">Electric</option>
</select>
</div>
<div>
<label for="vehicle-engine">Engine Size</label>
<input type="text" id="vehicle-engine" placeholder="2.0L" value="2.0L">
</div>
<div>
<label for="vehicle-transmission">Trans.</label>
<select id="vehicle-transmission">
<option value="Manual">Manual</option>
<option value="Automatic" selected>Automatic</option>
<option value="Semi-Auto">Semi-Auto</option>
</select>
</div>
</div>
</div>
<div class="control-section" style="border-bottom: none; padding-bottom: 0;">
<h2 class="section-title">✅ Key Features</h2>
<div class="control-group" style="margin-bottom: 0;">
<textarea id="vehicle-features" placeholder="Full leather interior, Panoramic sunroof, Apple CarPlay" style="min-height: 100px;">Full leather interior, Panoramic sunroof, Apple CarPlay</textarea>
<div class="helper-text">Separate each feature with a comma</div>
</div>
</div>
</div>
<!-- Middle Column - Preview -->
<div class="preview-section">
<div class="preview-container">
<h2 class="section-title">📱 Preview</h2>
<div class="generated-text" id="generated-text">Your generated post will appear here...</div>
<button class="copy-button" onclick="copyToClipboard()">📋 Copy to Clipboard</button>
</div>
</div>
<!-- Right Column - Dealership, Dividers & Hashtags -->
<div class="controls">
<div class="control-section">
<h2 class="section-title">🏢 Dealership</h2>
<div style="display: grid; grid-template-columns: 1fr; gap: 10px;">
<div>
<label for="dealer-phone">Telephone</label>
<input type="text" id="dealer-phone" placeholder="028 00 000 000" value="028 00 000 000">
</div>
<div>
<label for="dealer-mobile">Mobile</label>
<input type="text" id="dealer-mobile" placeholder="07000 000 000" value="07000 000 000">
</div>
<div>
<label for="dealer-town">Town/City</label>
<input type="text" id="dealer-town" placeholder="Lisburn" value="Lisburn">
</div>
</div>
</div>
<div class="control-section">
<h2 class="section-title">➖ Dividers</h2>
<div class="toggle-container">
<div class="toggle-switch active" id="dividers-toggle" onclick="toggleDividers()">
<div class="toggle-slider"></div>
</div>
<span class="toggle-label">Enable Dividers</span>
</div>
<div class="helper-text" style="margin-top: 8px;">Adds lines between sections</div>
</div>
<div class="control-section">
<h2 class="section-title">#️⃣ Hashtags</h2>
<div class="control-group" style="margin-bottom: 0;">
<textarea id="hashtags-display" placeholder="Hashtags will appear here..." style="min-height: 120px;" readonly></textarea>
<div class="helper-text">Auto-generated. Click to edit if needed.</div>
</div>
</div>
<button onclick="updatePost()">Generate Post</button>
</div>
</div>
</div>
<script>
let dividersEnabled = true;
function toggleDividers() {
dividersEnabled = !dividersEnabled;
const toggle = document.getElementById('dividers-toggle');
if (dividersEnabled) {
toggle.classList.add('active');
} else {
toggle.classList.remove('active');
}
updatePost();
}
// Make hashtags textarea editable on click
document.getElementById('hashtags-display').addEventListener('click', function() {
this.readOnly = false;
this.focus();
});
document.getElementById('hashtags-display').addEventListener('blur', function() {
updatePost();
});
function updatePost() {
const year = document.getElementById('vehicle-year').value;
const make = document.getElementById('vehicle-make').value;
const model = document.getElementById('vehicle-model').value;
const variant = document.getElementById('vehicle-variant').value;
const price = parseInt(document.getElementById('vehicle-price').value).toLocaleString();
const mileage = document.getElementById('vehicle-mileage').value;
const fuel = document.getElementById('vehicle-fuel').value;
const engine = document.getElementById('vehicle-engine').value;
const transmission = document.getElementById('vehicle-transmission').value;
const features = document.getElementById('vehicle-features').value;
const titlePrefix = document.getElementById('title-prefix').value;
const titleStyle = document.getElementById('title-style').value;
const dealerPhone = document.getElementById('dealer-phone').value;
const dealerMobile = document.getElementById('dealer-mobile').value;
const dealerTown = document.getElementById('dealer-town').value;
// Build vehicle name with variant divider
let vehicleName;
if (variant) {
vehicleName = `${year} ${make} ${model} | ${variant}`;
} else {
vehicleName = `${year} ${make} ${model}`;
}
// Build title with prefix and emoji
let titleText = vehicleName;
let titleEmoji = '🚗';
if (titlePrefix === 'forsale') {
titleText = `FOR SALE | ${vehicleName}`;
titleEmoji = '🏷️';
} else if (titlePrefix === 'justin') {
titleText = `JUST IN | ${vehicleName}`;
titleEmoji = '🚨';
}
// Apply text styling using proper Unicode ranges
let styledText = titleText;
switch(titleStyle) {
case 'bold':
styledText = toBoldUnicode(titleText);
break;
case 'italic':
styledText = toItalicUnicode(titleText);
break;
case 'bolditalic':
styledText = toBoldItalicUnicode(titleText);
break;
}
const title = `${titleEmoji} ${styledText}`;
const featuresList = features.split(',').map(f => f.trim()).filter(f => f.length > 0);
const featuresText = featuresList.map(f => `✅ ${f}`).join('\n');
// Generate or get existing hashtags
let hashtags;
const hashtagsField = document.getElementById('hashtags-display');
if (hashtagsField.value && !hashtagsField.readOnly) {
// Use edited hashtags
hashtags = hashtagsField.value;
} else {
// Generate new hashtags
hashtags = generateHashtags(make, model, fuel, transmission, dealerTown, featuresList);
hashtagsField.value = hashtags;
}
// Build specs with emojis (stacked)
const specsText = `🛣️ ${mileage} miles
⛽ ${fuel}
💨 ${engine}
⚙️ ${transmission}`;
// Build post with dividers (extended by 4 characters)
const featureDivider = dividersEnabled ? '\n.' : '';
const dealerSpacing = dividersEnabled ? '' : '\n';
const hashtagDivider = dividersEnabled ? '\n__________________________' : '';
const post = `${title}
${specsText}
💷 £${price}
${featuresText}${featureDivider}${dealerSpacing}
📞 ${dealerPhone}
📱 ${dealerMobile}
📍 ${dealerTown}${hashtagDivider}
${hashtags}`;
document.getElementById('generated-text').textContent = post;
}
function toBoldUnicode(text) {
return text.split('').map(char => {
const code = char.charCodeAt(0);
// A-Z
if (code >= 65 && code <= 90) return String.fromCodePoint(code - 65 + 0x1D400);
// a-z
if (code >= 97 && code <= 122) return String.fromCodePoint(code - 97 + 0x1D41A);
// 0-9
if (code >= 48 && code <= 57) return String.fromCodePoint(code - 48 + 0x1D7CE);
return char;
}).join('');
}
function toItalicUnicode(text) {
return text.split('').map(char => {
const code = char.charCodeAt(0);
// A-Z
if (code >= 65 && code <= 90) return String.fromCodePoint(code - 65 + 0x1D434);
// a-z (special handling for h)
if (code === 104) return String.fromCodePoint(0x210E); // italic h
if (code >= 97 && code <= 122) return String.fromCodePoint(code - 97 + 0x1D44E);
return char;
}).join('');
}
function toBoldItalicUnicode(text) {
return text.split('').map(char => {
const code = char.charCodeAt(0);
// A-Z
if (code >= 65 && code <= 90) return String.fromCodePoint(code - 65 + 0x1D468);
// a-z
if (code >= 97 && code <= 122) return String.fromCodePoint(code - 97 + 0x1D482);
return char;
}).join('');
}
function generateHashtags(make, model, fuel, transmission, town, features) {
const tags = new Set();
// Vehicle tags
tags.add(`#${make}`);
tags.add(`#${model.replace(/\s+/g, '')}`);
tags.add(`#${make}${model.replace(/\s+/g, '')}`);
// Specs tags
if (fuel === 'Electric') tags.add('#ElectricCar');
if (fuel === 'Hybrid') tags.add('#HybridCar');
if (fuel === 'Diesel') tags.add('#Diesel');
if (fuel === 'Petrol') tags.add('#Petrol');
if (transmission === 'Automatic') tags.add('#Automatic');
// Location tags
tags.add(`#${town.replace(/\s+/g, '')}`);
tags.add('#NorthernIreland');
tags.add('#NI');
// Feature-based tags
features.forEach(f => {
if (f.toLowerCase().includes('leather')) tags.add('#Leather');
if (f.toLowerCase().includes('sunroof') || f.toLowerCase().includes('panoramic')) tags.add('#Sunroof');
if (f.toLowerCase().includes('carplay')) tags.add('#AppleCarPlay');
if (f.toLowerCase().includes('camera')) tags.add('#ReverseCam');
});
// Generic tags
tags.add('#UsedCars');
tags.add('#CarSales');
tags.add('#CarsForSale');
tags.add('#NIVS');
return Array.from(tags).join(' ');
}
function copyToClipboard() {
const text = document.getElementById('generated-text').textContent;
navigator.clipboard.writeText(text).then(() => {
const button = event.target;
const originalText = button.textContent;
button.textContent = '✓ Copied!';
button.style.background = '#22c55e';
setTimeout(() => {
button.textContent = originalText;
button.style.background = '';
}, 2000);
});
}
// Generate initial post on load
updatePost();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Vehicle Flyer Generator - NIVS</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/dom-to-image/2.6.0/dom-to-image.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<style>
:root { --widget-bg: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Montserrat', sans-serif;
background: var(--widget-bg);
color: #f5f5f5;
padding: 40px 20px;
min-height: 100vh;
}
.container { max-width: 1900px; margin: 0 auto; }
h1 { font-size: 3rem; font-weight: 700; margin-bottom: 10px; line-height: 1.2; }
.title-orange { color: #ff4605; }
.title-white { color: white; }
.subtitle { font-size: 1rem; color: rgba(255, 255, 255, 0.8); line-height: 1.4; }
.instructions {
background: rgba(255, 70, 5, 0.1);
border-left: 4px solid #ff4605;
padding: 15px 20px;
border-radius: 8px;
max-width: 750px;
}
.instructions h3 { color: #ff4605; margin-bottom: 8px; font-size: 1rem; }
.instructions .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 40px; }
.instructions .grid-2 div { color: white; font-size: 0.9rem; }
.content-wrapper {
display: grid;
grid-template-columns: 380px 1fr 600px;
gap: 25px;
margin-top: 40px;
}
.controls {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 25px;
}
.control-section {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.control-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.section-title {
font-size: 1.2rem;
font-weight: 700;
color: #ff4605;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.control-group { margin-bottom: 12px; }
label { display: block; font-weight: 600; margin-bottom: 6px; color: white; font-size: 0.85rem; }
input[type="text"], input[type="number"], input[type="file"], input[type="color"] {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: white;
font-family: 'Montserrat', sans-serif;
font-size: 0.9rem;
transition: all 0.3s ease;
}
input[type="color"] { height: 45px; cursor: pointer; }
input:focus { outline: none; border-color: #ff4605; background: rgba(255, 255, 255, 0.15); }
input::placeholder { color: rgba(255, 255, 255, 0.5); }
button {
background: #ff4605;
color: white;
border: none;
padding: 12px 28px;
font-size: 0.95rem;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
font-family: 'Montserrat', sans-serif;
transition: all 0.3s ease;
width: 100%;
}
button:hover {
background: #e03d04;
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 70, 5, 0.4);
}
.vehicle-list { max-height: calc(100vh - 180px); overflow-y: auto; padding-right: 10px; }
.vehicle-editor {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0;
margin-bottom: 10px;
}
.vehicle-editor-header {
padding: 12px 15px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
.vehicle-editor-header:hover {
background: rgba(255, 255, 255, 0.05);
}
.vehicle-editor-content {
padding: 0 15px 15px 15px;
display: none;
}
.vehicle-editor-content.expanded {
display: block;
}
.collapse-icon {
transition: transform 0.3s ease;
}
.collapse-icon.rotated {
transform: rotate(180deg);
}
.vehicle-number { font-weight: 700; color: white; font-size: 0.95rem; }
input[type="range"] {
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
outline: none;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ff4605;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #ff4605;
cursor: pointer;
border: none;
}
input[type="range"]::-webkit-slider-runnable-track {
background: white;
height: 6px;
border-radius: 3px;
}
input[type="range"]::-moz-range-track {
background: white;
height: 6px;
border-radius: 3px;
}
.slider-mini label { font-size: 0.75rem; margin-bottom: 3px; }
.slider-mini { margin-bottom: 8px; }
.preview-section { position: sticky; top: 20px; }
.preview-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 25px;
}
#flyer {
width: 595px;
height: 842px;
max-height: 842px;
background: white;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
overflow: hidden;
transform: scale(0.9);
transform-origin: top center;
display: flex;
flex-direction: column;
position: relative;
}
.flyer-header {
background: #ff4605;
padding: 20px 30px;
color: white;
display: flex;
align-items: center;
gap: 20px;
}
.flyer-logo {
width: 80px;
height: 80px;
background: white;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.flyer-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.flyer-logo-placeholder {
color: #ccc;
font-size: 0.7rem;
font-weight: 600;
text-align: center;
padding: 10px;
line-height: 1.2;
}
.flyer-header-text {
flex: 1;
text-align: left;
}
.flyer-title {
font-size: 2rem;
font-weight: 800;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 1px;
text-align: left;
}
.flyer-dealership {
font-size: 1.2rem;
font-weight: 600;
text-align: left;
}
.flyer-website-row {
display: flex;
align-items: center;
height: 40px;
}
.website-label {
width: 20%;
background: #1e293b;
color: white;
padding: 0 15px;
font-size: 0.7rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
}
.website-url-section {
width: 80%;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 0.95rem;
font-weight: 700;
color: #1e293b;
}
.flyer-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 8px 8px 8px 8px;
background: white;
}
.featured-vehicle-box {
background: white;
padding: 0 8px 8px 8px;
display: flex;
align-items: center;
}
.featured-content {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
background: #f8f9fa;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.featured-image-section {
position: relative;
}
.featured-banner {
position: absolute;
top: 0;
right: 0;
background: #ff4605;
color: white;
padding: 6px 12px;
font-size: 0.75rem;
font-weight: 700;
z-index: 2;
border-bottom-left-radius: 8px;
}
.featured-image {
width: 100%;
height: 150px;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.featured-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.featured-details {
padding: 12px;
display: flex;
flex-direction: column;
justify-content: center;
}
.featured-year {
font-size: 0.7rem;
font-weight: 700;
color: #ff4605;
margin-bottom: 2px;
}
.featured-make {
font-size: 1.1rem;
font-weight: 800;
color: #1e293b;
line-height: 1.2;
}
.featured-model {
font-size: 0.9rem;
font-weight: 600;
color: #666;
margin-bottom: 8px;
}
.featured-description {
font-size: 0.75rem;
color: #666;
margin-bottom: 8px;
line-height: 1.3;
}
.featured-price {
font-size: 1.4rem;
font-weight: 800;
color: #ff4605;
}
.flyer-footer {
background: #1e293b;
padding: 10px 20px;
display: flex;
justify-content: space-around;
align-items: center;
color: white;
font-size: 0.8rem;
margin-top: auto;
}
.vehicle-tile {
background: #f8f9fa;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
.vehicle-image-container {
width: 100%;
height: 115px;
overflow: hidden;
background: #e0e0e0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.vehicle-image {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
}
.placeholder-text { color: #999; font-size: 0.85rem; font-weight: 600; }
.year-badge {
position: absolute;
top: 6px;
left: 6px;
background: #ff4605;
color: white;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 700;
z-index: 1;
}
.vehicle-info {
padding: 4px 6px 3px 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.vehicle-make-model { flex: 1; line-height: 1.1; }
.vehicle-make { font-weight: 700; color: #1e293b; font-size: 0.75rem; line-height: 1.1; margin-bottom: 1px; }
.vehicle-model { font-weight: 400; color: #666; font-size: 0.7rem; line-height: 1.1; }
.vehicle-price { font-size: 0.9rem; font-weight: 800; color: #ff4605; line-height: 1; }
.flyer-footer {
background: #1e293b;
padding: 10px 20px;
display: flex;
justify-content: space-around;
align-items: center;
color: white;
font-size: 0.8rem;
margin-top: auto;
}
.footer-contact {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.footer-contact svg { flex-shrink: 0; }
</style>
</head>
<body>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px;">
<div>
<h1><span class="title-orange">Multi-Vehicle</span> <span class="title-white">Flyer</span> <span class="title-orange">Generator</span></h1>
<p class="subtitle">Create professional A5 flyers showcasing 9 vehicles with <span style="color: #ff4605;">#NIVS</span>!</p>
</div>
<div class="instructions">
<h3>🚀 How to Use</h3>
<div class="grid-2">
<div>1. Fill in dealership details & colors</div>
<div>2. Edit vehicle details & upload images</div>
<div>3. Adjust image positioning with sliders</div>
<div>4. Click "Export to PDF" for print file</div>
</div>
</div>
</div>
<div class="content-wrapper">
<!-- Column 1 -->
<div>
<div class="controls">
<div class="control-section">
<h2 class="section-title">📝 Title</h2>
<div class="control-group" style="margin-bottom: 0;">
<label for="flyer-title">Flyer Title</label>
<input type="text" id="flyer-title" value="Latest Stock">
</div>
</div>
<div class="control-section">
<h2 class="section-title">🏢 Dealership Details</h2>
<div style="display: grid; grid-template-columns: 110px 1fr; gap: 12px;">
<!-- Logo Column -->
<div>
<label style="font-size: 0.8rem;">Logo</label>
<div id="logo-preview" style="width: 100px; height: 100px; background: rgba(255,255,255,0.1); border: 2px dashed rgba(255,255,255,0.3); border-radius: 8px; display: flex; align-items: center; justify-content: center; overflow: hidden; margin-bottom: 8px; cursor: pointer;" onclick="document.getElementById('logo-upload').click()">
<span style="color: rgba(255,255,255,0.5); font-size: 0.7rem; text-align: center; padding: 10px;">Click to<br>Upload<br>Logo</span>
</div>
<input type="file" id="logo-upload" accept="image/*" style="display: none;">
</div>
<!-- Details Column -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem;">Dealership Name</label>
<input type="text" id="dealership-name" value="NI Vehicle Sales" style="padding: 8px 10px; font-size: 0.85rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem;">Phone</label>
<input type="text" id="phone" value="07936 317781" style="padding: 8px 10px; font-size: 0.85rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem;">Email</label>
<input type="text" id="email" value="info@nivehiclesales.com" style="padding: 8px 10px; font-size: 0.85rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem;">Website</label>
<input type="text" id="website" value="nivehiclesales.com" style="padding: 8px 10px; font-size: 0.85rem;">
</div>
</div>
</div>
</div>
<div class="control-section" style="border-bottom: none; padding-bottom: 0; margin-bottom: 0;">
<h2 class="section-title">🎨 Colours</h2>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;">
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Primary</label>
<input type="color" id="primary-color" value="#ff4605" style="height: 40px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Web Label BG</label>
<input type="color" id="website-label-bg" value="#1e293b" style="height: 40px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Web Label Text</label>
<input type="color" id="website-label-text" value="#ffffff" style="height: 40px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Web URL BG</label>
<input type="color" id="website-bg-color" value="#f8f9fa" style="height: 40px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Web URL Text</label>
<input type="color" id="website-url-text" value="#1e293b" style="height: 40px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Footer BG</label>
<input type="color" id="footer-bg-color" value="#1e293b" style="height: 40px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Year Badge</label>
<input type="color" id="year-badge-color" value="#ff4605" style="height: 40px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Price Colour</label>
<input type="color" id="price-color" value="#ff4605" style="height: 40px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Featured Banner</label>
<input type="color" id="featured-banner-color" value="#ff4605" style="height: 40px;">
</div>
</div>
</div>
</div>
</div>
<!-- Column 2 -->
<div class="controls">
<div class="control-section">
<h2 class="section-title">⭐ Featured Vehicle</h2>
<div style="display: grid; grid-template-columns: 110px 1fr; gap: 12px; margin-bottom: 10px;">
<!-- Image Preview Column -->
<div>
<div id="featured-image-preview" style="width: 100px; height: 100px; background: rgba(255,255,255,0.1); border: 2px dashed rgba(255,255,255,0.3); border-radius: 8px; display: flex; align-items: center; justify-content: center; overflow: hidden; cursor: pointer;" onclick="document.getElementById('featured-image-upload').click()">
<span style="color: rgba(255,255,255,0.5); font-size: 0.7rem; text-align: center; padding: 10px;">Click to<br>Upload<br>Image</span>
</div>
<input type="file" id="featured-image-upload" accept="image/*" style="display: none;">
</div>
<!-- Inputs Column -->
<div style="display: grid; grid-template-columns: 50px 1fr 1fr 70px; gap: 6px;">
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Year</label>
<input type="text" id="featured-year" value="2024" maxlength="4" style="padding: 6px 4px; font-size: 0.8rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Make</label>
<input type="text" id="featured-make" value="BMW" style="padding: 6px 8px; font-size: 0.8rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Model</label>
<input type="text" id="featured-model" value="X5 M Sport" style="padding: 6px 8px; font-size: 0.8rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Price (£)</label>
<input type="text" id="featured-price" value="45995" maxlength="6" style="padding: 6px 4px; font-size: 0.8rem;">
</div>
</div>
</div>
<!-- Sliders Row -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding-left: 122px; margin-bottom: 8px;">
<div class="slider-mini">
<label>Horizontal</label>
<input type="range" id="featured-pos-x" min="0" max="100" value="50">
</div>
<div class="slider-mini">
<label>Vertical</label>
<input type="range" id="featured-pos-y" min="0" max="100" value="50">
</div>
<div class="slider-mini">
<label>Zoom</label>
<input type="range" id="featured-zoom" min="100" max="200" value="100">
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem;">Description</label>
<input type="text" id="featured-description" value="Low mileage • Full service history" style="padding: 6px 8px; font-size: 0.85rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem;">Corner Banner</label>
<input type="text" id="featured-banner" value="Hot Deal 🔥" placeholder="e.g., Hot Deal 🔥" style="padding: 6px 8px; font-size: 0.85rem;">
</div>
</div>
</div>
<div class="control-section" style="border-bottom: none; margin-bottom: 0; padding-bottom: 0;">
<h2 class="section-title">🚗 Vehicles</h2>
<div class="vehicle-list" id="vehicle-list"></div>
</div>
</div>
<!-- Column 3 -->
<div class="preview-section">
<div class="preview-container">
<h2 class="section-title">📱 A5 Preview</h2>
<div id="flyer">
<div class="flyer-header" id="flyer-header">
<div class="flyer-logo" id="flyer-logo">
<span class="flyer-logo-placeholder">LOGO<br>HERE</span>
</div>
<div class="flyer-header-text">
<div class="flyer-title" id="preview-title">Latest Stock</div>
<div class="flyer-dealership" id="preview-dealership">NI Vehicle Sales</div>
</div>
</div>
<div class="flyer-website-row">
<div class="website-label" id="website-label">Website:</div>
<div class="website-url-section" id="website-url-section"><span id="preview-website">nivehiclesales.com</span></div>
</div>
<div class="featured-vehicle-box" id="featured-vehicle-box">
<div class="featured-content">
<div class="featured-image-section">
<div class="featured-banner" id="featured-banner-display">Hot Deal 🔥</div>
<div class="featured-image" id="featured-vehicle-image">
<span style="color: #999; font-size: 0.85rem;">[Featured Vehicle Image]</span>
</div>
</div>
<div class="featured-details">
<div class="featured-year" id="featured-year-display">2024</div>
<div class="featured-make" id="featured-make-display">BMW</div>
<div class="featured-model" id="featured-model-display">X5 M Sport</div>
<div class="featured-description" id="featured-description-display">Low mileage • Full service history</div>
<div class="featured-price" id="featured-price-display">£45,995</div>
</div>
</div>
</div>
<div class="flyer-grid" id="flyer-grid"></div>
<div class="flyer-footer" id="flyer-footer">
<div class="footer-contact">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"/>
</svg>
<span id="preview-phone">07936 317781</span>
</div>
<div class="footer-contact">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
<span id="preview-email"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="adc4c3cbc2edc3c4dbc8c5c4cec1c8deccc1c8de83cec2c0">[email protected]</a></span>
</div>
</div>
</div>
<button onclick="exportToPDF()" style="margin-top: 10px;">📄 Export to PDF</button>
</div>
</div>
</div>
</div>
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script><script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script><script>
const vehicles = [
{ id: 0, year: '2023', make: 'BMW', model: '3 Series', price: '24995', image: null, posX: 50, posY: 50, zoom: 100 },
{ id: 1, year: '2022', make: 'Audi', model: 'A4', price: '22500', image: null, posX: 50, posY: 50, zoom: 100 },
{ id: 2, year: '2024', make: 'Mercedes', model: 'C-Class', price: '26995', image: null, posX: 50, posY: 50, zoom: 100 },
{ id: 3, year: '2021', make: 'VW', model: 'Golf', price: '16995', image: null, posX: 50, posY: 50, zoom: 100 },
{ id: 4, year: '2022', make: 'Ford', model: 'Focus', price: '14500', image: null, posX: 50, posY: 50, zoom: 100 },
{ id: 5, year: '2023', make: 'Toyota', model: 'Corolla', price: '18995', image: null, posX: 50, posY: 50, zoom: 100 },
{ id: 6, year: '2023', make: 'Nissan', model: 'Qashqai', price: '19995', image: null, posX: 50, posY: 50, zoom: 100 },
{ id: 7, year: '2022', make: 'Honda', model: 'Civic', price: '17500', image: null, posX: 50, posY: 50, zoom: 100 },
{ id: 8, year: '2024', make: 'Mazda', model: 'CX-5', price: '23995', image: null, posX: 50, posY: 50, zoom: 100 }
];
let logoImage = null;
let featuredVehicleImage = null;
let featuredPosX = 50;
let featuredPosY = 50;
let featuredZoom = 100;
function initialize() {
renderVehicleList();
updatePreview();
// Logo upload
document.getElementById('logo-upload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(event) {
logoImage = event.target.result;
document.getElementById('logo-preview').innerHTML = `<img src="${logoImage}" style="width: 100%; height: 100%; object-fit: contain;">`;
updatePreview();
};
reader.readAsDataURL(file);
}
});
// Featured vehicle image upload
document.getElementById('featured-image-upload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(event) {
featuredVehicleImage = event.target.result;
document.getElementById('featured-image-preview').innerHTML = `<img src="${featuredVehicleImage}" style="width: 100%; height: 100%; object-fit: contain;">`;
updatePreview();
};
reader.readAsDataURL(file);
}
});
// Featured vehicle sliders
document.getElementById('featured-pos-x').addEventListener('input', function(e) {
featuredPosX = e.target.value;
updatePreview();
});
document.getElementById('featured-pos-y').addEventListener('input', function(e) {
featuredPosY = e.target.value;
updatePreview();
});
document.getElementById('featured-zoom').addEventListener('input', function(e) {
featuredZoom = e.target.value;
updatePreview();
});
['dealership-name', 'flyer-title', 'phone', 'email', 'website', 'featured-year', 'featured-make', 'featured-model', 'featured-price', 'featured-description', 'featured-banner'].forEach(id => {
document.getElementById(id).addEventListener('input', updatePreview);
});
['primary-color', 'website-label-bg', 'website-label-text', 'website-bg-color', 'website-url-text', 'footer-bg-color', 'year-badge-color', 'price-color', 'featured-banner-color'].forEach(id => {
document.getElementById(id).addEventListener('input', updateColors);
});
}
function updateColors() {
const primary = document.getElementById('primary-color').value;
const webLabelBg = document.getElementById('website-label-bg').value;
const webLabelText = document.getElementById('website-label-text').value;
const websiteBg = document.getElementById('website-bg-color').value;
const websiteUrlText = document.getElementById('website-url-text').value;
const footerBg = document.getElementById('footer-bg-color').value;
const yearBadge = document.getElementById('year-badge-color').value;
const priceColor = document.getElementById('price-color').value;
const featuredBannerColor = document.getElementById('featured-banner-color').value;
document.getElementById('flyer-header').style.background = primary;
document.getElementById('website-label').style.background = webLabelBg;
document.getElementById('website-label').style.color = webLabelText;
document.getElementById('website-url-section').style.background = websiteBg;
document.getElementById('website-url-section').style.color = websiteUrlText;
document.getElementById('flyer-footer').style.background = footerBg;
// Apply year badge color to featured year and price
document.getElementById('featured-year-display').style.color = yearBadge;
document.getElementById('featured-price-display').style.color = yearBadge;
document.getElementById('featured-banner-display').style.background = featuredBannerColor;
document.querySelectorAll('.vehicle-price').forEach(el => el.style.color = priceColor);
document.querySelectorAll('.year-badge').forEach(el => el.style.background = yearBadge);
}
function updateVehicle(id, field, value) {
const vehicle = vehicles.find(v => v.id === id);
if (vehicle) {
vehicle[field] = value;
updatePreview();
}
}
function handleImageUpload(id, event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
updateVehicle(id, 'image', e.target.result);
// Update preview in vehicle editor
const preview = document.getElementById(`vehicle-image-preview-${id}`);
if (preview) {
preview.innerHTML = `<img src="${e.target.result}" style="width: 100%; height: 100%; object-fit: contain;">`;
}
};
reader.readAsDataURL(file);
}
}
function updateImagePosition(id) {
const vehicle = vehicles.find(v => v.id === id);
if (vehicle) {
vehicle.posX = document.getElementById(`pos-x-${id}`).value;
vehicle.posY = document.getElementById(`pos-y-${id}`).value;
vehicle.zoom = document.getElementById(`zoom-${id}`).value;
updatePreview();
}
}
function renderVehicleList() {
const list = document.getElementById('vehicle-list');
list.innerHTML = vehicles.map((v, i) => `
<div class="vehicle-editor">
<div class="vehicle-editor-header" onclick="toggleVehicle(${v.id})">
<div class="vehicle-number">Vehicle ${i + 1}</div>
<div class="collapse-icon" id="icon-${v.id}">▼</div>
</div>
<div class="vehicle-editor-content" id="content-${v.id}">
<div style="display: grid; grid-template-columns: 110px 1fr; gap: 12px; margin-bottom: 10px;">
<!-- Image Preview Column -->
<div>
<div id="vehicle-image-preview-${v.id}" style="width: 100px; height: 100px; background: rgba(255,255,255,0.1); border: 2px dashed rgba(255,255,255,0.3); border-radius: 8px; display: flex; align-items: center; justify-content: center; overflow: hidden; cursor: pointer;" onclick="document.getElementById('vehicle-image-${v.id}').click()">
<span style="color: rgba(255,255,255,0.5); font-size: 0.7rem; text-align: center; padding: 10px;">Click to<br>Upload<br>Image</span>
</div>
<input type="file" id="vehicle-image-${v.id}" accept="image/*" style="display: none;" onchange="handleImageUpload(${v.id}, event)">
</div>
<!-- Inputs Column -->
<div style="display: grid; grid-template-columns: 50px 1fr 1fr 70px; gap: 6px;">
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Year</label>
<input type="text" maxlength="4" value="${v.year}" oninput="updateVehicle(${v.id}, 'year', this.value)" style="padding: 6px 4px; font-size: 0.8rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Make</label>
<input type="text" value="${v.make}" oninput="updateVehicle(${v.id}, 'make', this.value)" style="padding: 6px 8px; font-size: 0.8rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Model</label>
<input type="text" value="${v.model}" oninput="updateVehicle(${v.id}, 'model', this.value)" style="padding: 6px 8px; font-size: 0.8rem;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Price (£)</label>
<input type="text" maxlength="6" value="${v.price}" oninput="updateVehicle(${v.id}, 'price', this.value)" style="padding: 6px 4px; font-size: 0.8rem;">
</div>
</div>
</div>
<!-- Sliders Row -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding-left: 122px;">
<div class="slider-mini">
<label>Horizontal</label>
<input type="range" id="pos-x-${v.id}" min="0" max="100" value="${v.posX}" oninput="updateImagePosition(${v.id})">
</div>
<div class="slider-mini">
<label>Vertical</label>
<input type="range" id="pos-y-${v.id}" min="0" max="100" value="${v.posY}" oninput="updateImagePosition(${v.id})">
</div>
<div class="slider-mini">
<label>Zoom</label>
<input type="range" id="zoom-${v.id}" min="100" max="200" value="${v.zoom}" oninput="updateImagePosition(${v.id})">
</div>
</div>
</div>
</div>
`).join('');
}
function toggleVehicle(id) {
// Close all other vehicles first
vehicles.forEach(v => {
if (v.id !== id) {
const content = document.getElementById(`content-${v.id}`);
const icon = document.getElementById(`icon-${v.id}`);
if (content) {
content.classList.remove('expanded');
icon.classList.remove('rotated');
}
}
});
// Toggle the clicked vehicle
const content = document.getElementById(`content-${id}`);
const icon = document.getElementById(`icon-${id}`);
content.classList.toggle('expanded');
icon.classList.toggle('rotated');
}
function updatePreview() {
// Update logo
const flyerLogo = document.getElementById('flyer-logo');
if (logoImage) {
flyerLogo.innerHTML = `<img src="${logoImage}" style="width: 100%; height: 100%; object-fit: contain;">`;
} else {
flyerLogo.innerHTML = '<span class="flyer-logo-placeholder">LOGO<br>HERE</span>';
}
document.getElementById('preview-title').textContent = document.getElementById('flyer-title').value;
document.getElementById('preview-dealership').textContent = document.getElementById('dealership-name').value;
document.getElementById('preview-phone').textContent = document.getElementById('phone').value;
document.getElementById('preview-email').textContent = document.getElementById('email').value;
const website = document.getElementById('website').value;
document.getElementById('preview-website').textContent = website;
// Update featured vehicle
document.getElementById('featured-year-display').textContent = document.getElementById('featured-year').value;
document.getElementById('featured-make-display').textContent = document.getElementById('featured-make').value;
document.getElementById('featured-model-display').textContent = document.getElementById('featured-model').value;
document.getElementById('featured-description-display').textContent = document.getElementById('featured-description').value;
const featuredPrice = document.getElementById('featured-price').value;
document.getElementById('featured-price-display').textContent = `£${parseInt(featuredPrice).toLocaleString()}`;
const bannerText = document.getElementById('featured-banner').value;
const bannerDisplay = document.getElementById('featured-banner-display');
if (bannerText.trim()) {
bannerDisplay.textContent = bannerText;
bannerDisplay.style.display = 'block';
} else {
bannerDisplay.style.display = 'none';
}
const featuredImageDiv = document.getElementById('featured-vehicle-image');
if (featuredVehicleImage) {
featuredImageDiv.innerHTML = `<img src="${featuredVehicleImage}" style="width: 100%; height: 100%; object-fit: cover; object-position: ${featuredPosX}% ${featuredPosY}%; transform: scale(${featuredZoom / 100});">`;
} else {
featuredImageDiv.innerHTML = '<span style="color: #999; font-size: 0.85rem;">[Featured Vehicle Image]</span>';
}
const grid = document.getElementById('flyer-grid');
const priceColor = document.getElementById('price-color').value;
const yearBadgeColor = document.getElementById('year-badge-color').value;
grid.innerHTML = vehicles.map(v => `
<div class="vehicle-tile">
<div class="vehicle-image-container">
${v.image ? `<img class="vehicle-image" src="${v.image}" style="object-fit: cover; object-position: ${v.posX}% ${v.posY}%; transform: scale(${v.zoom / 100});">` : `<span class="placeholder-text">[Image Here]</span>`}
<div class="year-badge" style="background: ${yearBadgeColor};">${v.year}</div>
</div>
<div class="vehicle-info">
<div class="vehicle-make-model">
<div class="vehicle-make">${v.make}</div>
<div class="vehicle-model">${v.model}</div>
</div>
<div class="vehicle-price" style="color: ${priceColor};">£${parseInt(v.price).toLocaleString()}</div>
</div>
</div>
`).join('');
}
async function exportToPDF() {
const flyer = document.getElementById('flyer');
// Store original styles
const originalTransform = flyer.style.transform;
const originalBoxShadow = flyer.style.boxShadow;
try {
// Prepare for capture
flyer.style.transform = 'scale(1)';
flyer.style.boxShadow = 'none';
// Wait for styles to apply
await new Promise(resolve => setTimeout(resolve, 100));
// Use dom-to-image with HIGH RESOLUTION (3x scale = ~300 DPI)
const dataUrl = await domtoimage.toJpeg(flyer, {
quality: 1.0,
width: 595 * 3,
height: 842 * 3,
style: {
transform: 'scale(3)',
transformOrigin: 'top left',
boxShadow: 'none',
width: '595px',
height: '842px'
}
});
// Restore original styles
flyer.style.transform = originalTransform;
flyer.style.boxShadow = originalBoxShadow;
// Create PDF
const { jsPDF } = window.jspdf;
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a5'
});
// Add high-res image to PDF
pdf.addImage(dataUrl, 'JPEG', 0, 0, 148, 210, undefined, 'FAST');
// Save PDF
const name = document.getElementById('dealership-name').value.replace(/\s+/g, '-').toLowerCase();
pdf.save(`${name}-flyer.pdf`);
} catch (error) {
console.error('PDF export error:', error);
alert('Export failed: ' + error.message);
// Restore styles on error
flyer.style.transform = originalTransform;
flyer.style.boxShadow = originalBoxShadow;
}
}
initialize();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Overlay Generator - NIVS</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root { --widget-bg: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Montserrat', sans-serif;
background: var(--widget-bg);
color: #f5f5f5;
padding: 40px 20px;
min-height: 100vh;
}
.container { max-width: 1900px; margin: 0 auto; }
h1 { font-size: 3rem; font-weight: 700; margin-bottom: 10px; line-height: 1.2; }
.title-orange { color: #ff4605; }
.title-white { color: white; }
.subtitle { font-size: 1rem; color: rgba(255, 255, 255, 0.8); line-height: 1.4; }
.instructions {
background: rgba(255, 70, 5, 0.1);
border-left: 4px solid #ff4605;
padding: 15px 20px;
border-radius: 8px;
max-width: 750px;
}
.instructions h3 { color: #ff4605; margin-bottom: 8px; font-size: 1rem; }
.instructions .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 40px; }
.instructions .grid-2 div { color: white; font-size: 0.9rem; }
.content-wrapper {
display: grid;
grid-template-columns: 350px 400px 1fr;
gap: 25px;
}
.column {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 25px;
}
.column-title {
font-size: 1.3rem;
font-weight: 700;
color: #ff4605;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.control-group {
margin-bottom: 18px;
}
.control-group label {
display: block;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.9);
}
.control-group input[type="text"],
.control-group input[type="number"] {
width: 100%;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-family: 'Montserrat', sans-serif;
font-size: 0.9rem;
}
.control-group input:focus {
outline: none;
border-color: #ff4605;
background: rgba(255, 255, 255, 0.15);
}
.color-input-group {
display: grid;
grid-template-columns: 1fr 80px;
gap: 8px;
align-items: end;
}
.color-input-group input[type="color"] {
width: 100%;
height: 42px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: transparent;
cursor: pointer;
}
.upload-zone {
border: 2px dashed rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.05);
margin-bottom: 20px;
}
.upload-zone:hover {
border-color: #ff4605;
background: rgba(255, 70, 5, 0.1);
}
.upload-zone-text {
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.image-list {
max-height: 1020px;
overflow-y: auto;
}
.image-list::-webkit-scrollbar { width: 8px; }
.image-list::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.image-list::-webkit-scrollbar-thumb { background: #ff4605; border-radius: 4px; }
.image-item {
display: flex;
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
margin-bottom: 10px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.image-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.image-item.active {
border-color: #ff4605;
background: rgba(255, 70, 5, 0.1);
}
.image-item-thumb {
width: 80px;
height: 60px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.image-item-info {
flex: 1;
min-width: 0;
}
.image-item-name {
font-size: 0.85rem;
font-weight: 600;
color: white;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-item-actions {
display: flex;
gap: 6px;
}
.btn-small {
padding: 4px 10px;
font-size: 0.75rem;
font-weight: 600;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-edit {
background: #1e293b;
color: white;
}
.btn-edit:hover {
background: #334155;
}
.btn-delete {
background: #ff4605;
color: white;
}
.btn-delete:hover {
background: #e63d00;
}
.btn-replace {
background: rgba(255, 255, 255, 0.15);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.btn-replace:hover {
background: rgba(255, 255, 255, 0.25);
}
.preview-main {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
background: #000;
overflow: hidden;
margin-bottom: 20px;
}
.preview-canvas {
width: 100%;
height: 100%;
display: block;
}
.preview-controls {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 20px;
}
.preview-control {
display: flex;
flex-direction: column;
gap: 5px;
}
.preview-control label {
font-size: 0.75rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.preview-control input[type="range"] {
width: 100%;
}
/* Orange slider styling */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.15);
border-radius: 5px;
height: 6px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #ff4605;
cursor: pointer;
border: 2px solid white;
position: relative;
z-index: 2;
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #ff4605;
cursor: pointer;
border: 2px solid white;
}
input[type="range"]:active::-webkit-slider-thumb {
box-shadow: 0 0 0 3px rgba(255, 70, 5, 0.3);
}
/* Firefox progress track */
input[type="range"]::-moz-range-progress {
background: #ff4605;
height: 6px;
border-radius: 5px;
}
input[type="range"]::-moz-range-track {
background: rgba(255, 255, 255, 0.15);
height: 6px;
border-radius: 5px;
}
.preview-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 20px;
}
.thumbnail-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
padding: 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
}
.thumbnail-grid::-webkit-scrollbar { width: 8px; }
.thumbnail-grid::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.thumbnail-grid::-webkit-scrollbar-thumb { background: #ff4605; border-radius: 4px; }
.thumbnail-item {
aspect-ratio: 4 / 3;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
border: 3px solid transparent;
transition: all 0.2s ease;
}
.thumbnail-item:hover {
border-color: rgba(255, 70, 5, 0.5);
}
.thumbnail-item.active {
border-color: #ff4605;
}
.thumbnail-canvas {
width: 100%;
height: 100%;
display: block;
}
.btn {
padding: 12px 20px;
font-size: 0.95rem;
font-weight: 700;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
font-family: 'Montserrat', sans-serif;
}
.btn-primary {
background: #ff4605;
color: white;
}
.btn-primary:hover {
background: #e63d00;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 70, 5, 0.4);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.logo-upload-box {
width: 100%;
height: 100px;
background: rgba(255, 255, 255, 0.1);
border: 2px dashed rgba(255, 255, 255, 0.3);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
overflow: hidden;
}
.logo-upload-box:hover {
border-color: #ff4605;
background: rgba(255, 70, 5, 0.1);
}
.logo-upload-box img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.section-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 20px 0;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.5);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 15px;
}
</style>
</head>
<body>
<div class="container">
<div style="display: grid; grid-template-columns: 1fr auto; gap: 40px; align-items: start; margin-bottom: 40px;">
<div>
<h1>
<span class="title-orange">Image Overlay</span>
<span class="title-white">Generator</span>
</h1>
<p class="subtitle" style="margin-bottom: 0;">Create professional vehicle listing overlays with <span style="color: #ff4605; font-weight: 700;">#NIVS</span></p>
</div>
<div class="instructions" style="margin-bottom: 0;">
<h3>🚀 How to Use</h3>
<div class="grid-2">
<div>1. Upload logo & set overlay text & colors</div>
<div>2. Upload 1-20 vehicle images</div>
<div>3. Click images to adjust position & zoom</div>
<div>4. Click "Download All" for batch export</div>
</div>
</div>
</div>
<div class="content-wrapper">
<!-- Column 1: Controls -->
<div class="column">
<div class="column-title">⚙️ Overlay Settings</div>
<div class="control-group">
<label>Logo</label>
<div class="logo-upload-box" id="logo-preview" onclick="document.getElementById('logo-upload').click()">
<span style="color: rgba(255,255,255,0.5); font-size: 0.85rem;">Click to Upload Logo</span>
</div>
<input type="file" id="logo-upload" accept="image/*" style="display: none;">
<div id="logo-size-control" style="display: none; margin-top: 12px;">
<label style="font-size: 0.8rem; margin-bottom: 8px; display: block;">Logo Size</label>
<input type="range" id="logo-size" min="50" max="200" value="100" style="width: 100%;">
</div>
</div>
<div class="section-divider"></div>
<div class="control-group">
<label>Telephone</label>
<input type="text" id="telephone" placeholder="TEL: 028 00 000 000" value="">
</div>
<div class="control-group">
<label>Website</label>
<input type="text" id="website" placeholder="www.dealership.co.uk" value="">
</div>
<div class="control-group">
<label>Dealership Address</label>
<input type="text" id="address" placeholder="Street, Town, County, Post Code" value="">
</div>
<div class="section-divider"></div>
<div class="column-title" style="font-size: 1rem; margin-bottom: 15px;">Colours</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 20px;">
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Top</label>
<input type="color" id="top-bar-color-picker" value="#B9322E" style="width: 100%; height: 38px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Bottom</label>
<input type="color" id="bottom-bar-color-picker" value="#B9322E" style="width: 100%; height: 38px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Text</label>
<input type="color" id="text-color-picker" value="#FFFFFF" style="width: 100%; height: 38px;">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label style="font-size: 0.75rem;">Logo BG</label>
<input type="color" id="logo-bg-color-picker" value="#FFFFFF" style="width: 100%; height: 38px;">
</div>
</div>
<div class="section-divider"></div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 18px;">
<div class="control-group" style="margin-bottom: 0;">
<label>Bar Height (px)</label>
<input type="number" id="bar-height" value="150" min="80" max="250">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label>Bar Padding (px)</label>
<input type="number" id="bar-padding" value="20" min="10" max="50">
</div>
</div>
<div class="section-divider"></div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div class="control-group" style="margin-bottom: 0;">
<label>Tel Font (px)</label>
<input type="number" id="telephone-font-size" value="32" min="16" max="60">
</div>
<div class="control-group" style="margin-bottom: 0;">
<label>Footer Font (px)</label>
<input type="number" id="font-size" value="28" min="16" max="48">
</div>
</div>
</div>
<!-- Column 2: Image Uploads -->
<div class="column">
<div class="column-title">📸 Images (1-20)</div>
<div class="upload-zone" onclick="document.getElementById('images-upload').click()">
<div class="upload-zone-text">
📤 Click to Upload Images<br>
<span style="font-size: 0.85rem; color: rgba(255,255,255,0.5);">Max 20 images</span>
</div>
</div>
<input type="file" id="images-upload" accept="image/*" multiple style="display: none;">
<div class="image-list" id="image-list">
<div class="empty-state">
<div class="empty-state-icon">🖼️</div>
<div>No images uploaded yet</div>
</div>
</div>
</div>
<!-- Column 3: Preview -->
<div class="column">
<div class="column-title">👁️ Preview</div>
<div class="preview-main">
<canvas id="preview-canvas" class="preview-canvas" width="1600" height="1200"></canvas>
</div>
<div class="preview-controls">
<div class="preview-control">
<label>Horizontal Position</label>
<input type="range" id="pos-x" min="0" max="100" value="50" disabled>
</div>
<div class="preview-control">
<label>Vertical Position</label>
<input type="range" id="pos-y" min="0" max="100" value="50" disabled>
</div>
<div class="preview-control">
<label>Zoom</label>
<input type="range" id="zoom" min="50" max="200" value="100" disabled>
</div>
</div>
<div class="preview-buttons">
<button class="btn btn-secondary" id="btn-save" disabled>💾 Save</button>
<button class="btn btn-secondary" id="btn-reset" disabled>🔄 Reset</button>
</div>
<div class="thumbnail-grid" id="thumbnail-grid"></div>
<button class="btn btn-primary" id="btn-export" style="width: 100%; margin-top: 20px;" disabled>📥 Download All</button>
</div>
</div>
</div>
<script>
let images = [];
let activeImageIndex = -1;
let logoImage = null;
let logoScale = 100;
const elements = {
logoUpload: document.getElementById('logo-upload'),
logoPreview: document.getElementById('logo-preview'),
logoSizeControl: document.getElementById('logo-size-control'),
logoSize: document.getElementById('logo-size'),
imagesUpload: document.getElementById('images-upload'),
imageList: document.getElementById('image-list'),
previewCanvas: document.getElementById('preview-canvas'),
thumbnailGrid: document.getElementById('thumbnail-grid'),
telephone: document.getElementById('telephone'),
telephoneFontSize: document.getElementById('telephone-font-size'),
website: document.getElementById('website'),
address: document.getElementById('address'),
topBarColorPicker: document.getElementById('top-bar-color-picker'),
bottomBarColorPicker: document.getElementById('bottom-bar-color-picker'),
textColorPicker: document.getElementById('text-color-picker'),
logoBgColorPicker: document.getElementById('logo-bg-color-picker'),
barHeight: document.getElementById('bar-height'),
barPadding: document.getElementById('bar-padding'),
fontSize: document.getElementById('font-size'),
posX: document.getElementById('pos-x'),
posY: document.getElementById('pos-y'),
zoom: document.getElementById('zoom'),
btnSave: document.getElementById('btn-save'),
btnReset: document.getElementById('btn-reset'),
btnExport: document.getElementById('btn-export')
};
// Logo upload
elements.logoUpload.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
logoImage = img;
elements.logoPreview.innerHTML = `<img src="${img.src}">`;
elements.logoSizeControl.style.display = 'block';
renderAll();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
}
});
// Logo size slider
elements.logoSize.addEventListener('input', (e) => {
logoScale = parseFloat(e.target.value);
renderAll();
});
// Images upload
elements.imagesUpload.addEventListener('change', (e) => {
const files = Array.from(e.target.files).slice(0, 20 - images.length);
files.forEach(file => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
images.push({
name: file.name,
image: img,
posX: 50,
posY: 50,
zoom: 100
});
if (images.length === 1) {
setActiveImage(0);
}
updateImageList();
renderThumbnails();
updateButtons();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
});
});
// Color pickers
elements.topBarColorPicker.addEventListener('input', () => renderAll());
elements.bottomBarColorPicker.addEventListener('input', () => renderAll());
elements.textColorPicker.addEventListener('input', () => renderAll());
elements.logoBgColorPicker.addEventListener('input', () => renderAll());
// Text inputs
[elements.telephone, elements.telephoneFontSize, elements.website, elements.address, elements.barHeight, elements.barPadding, elements.fontSize].forEach(el => {
el.addEventListener('input', () => renderAll());
});
// Position controls
elements.posX.addEventListener('input', (e) => {
if (activeImageIndex >= 0) {
images[activeImageIndex].posX = parseFloat(e.target.value);
renderPreview();
renderThumbnail(activeImageIndex);
}
});
elements.posY.addEventListener('input', (e) => {
if (activeImageIndex >= 0) {
images[activeImageIndex].posY = parseFloat(e.target.value);
renderPreview();
renderThumbnail(activeImageIndex);
}
});
elements.zoom.addEventListener('input', (e) => {
if (activeImageIndex >= 0) {
images[activeImageIndex].zoom = parseFloat(e.target.value);
renderPreview();
renderThumbnail(activeImageIndex);
}
});
// Save button
elements.btnSave.addEventListener('click', () => {
if (activeImageIndex >= 0) {
alert('✓ Image position saved');
}
});
// Reset button
elements.btnReset.addEventListener('click', () => {
if (activeImageIndex >= 0) {
images[activeImageIndex].posX = 50;
images[activeImageIndex].posY = 50;
images[activeImageIndex].zoom = 100;
updatePositionControls();
renderPreview();
renderThumbnail(activeImageIndex);
}
});
// Export button
elements.btnExport.addEventListener('click', async () => {
if (images.length === 0) return;
elements.btnExport.disabled = true;
elements.btnExport.textContent = '⏳ Downloading...';
for (let i = 0; i < images.length; i++) {
await new Promise((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = 1600;
canvas.height = 1200;
const ctx = canvas.getContext('2d');
renderImageToCanvas(ctx, images[i], canvas.width, canvas.height);
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const fileName = `overlay-${String(i + 1).padStart(2, '0')}-${images[i].name.replace(/\.[^/.]+$/, '')}.jpg`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
URL.revokeObjectURL(url);
resolve();
}, 150);
}, 'image/jpeg', 0.95);
});
}
elements.btnExport.disabled = false;
elements.btnExport.textContent = '📥 Download All';
});
function setActiveImage(index) {
activeImageIndex = index;
updatePositionControls();
updateImageList();
renderPreview();
updateButtons();
}
function updatePositionControls() {
if (activeImageIndex >= 0) {
const img = images[activeImageIndex];
elements.posX.value = img.posX;
elements.posY.value = img.posY;
elements.zoom.value = img.zoom;
elements.posX.disabled = false;
elements.posY.disabled = false;
elements.zoom.disabled = false;
} else {
elements.posX.disabled = true;
elements.posY.disabled = true;
elements.zoom.disabled = true;
}
}
function updateButtons() {
const hasImages = images.length > 0;
const hasActive = activeImageIndex >= 0;
elements.btnSave.disabled = !hasActive;
elements.btnReset.disabled = !hasActive;
elements.btnExport.disabled = !hasImages;
}
function updateImageList() {
if (images.length === 0) {
elements.imageList.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🖼️</div>
<div>No images uploaded yet</div>
</div>
`;
return;
}
elements.imageList.innerHTML = images.map((img, index) => `
<div class="image-item ${index === activeImageIndex ? 'active' : ''}" onclick="setActiveImage(${index})">
<img src="${img.image.src}" class="image-item-thumb">
<div class="image-item-info">
<div class="image-item-name">${img.name}</div>
<div class="image-item-actions">
<button class="btn-small btn-edit" onclick="event.stopPropagation(); setActiveImage(${index})">Edit</button>
<button class="btn-small btn-replace" onclick="event.stopPropagation(); replaceImage(${index})">Replace</button>
<button class="btn-small btn-delete" onclick="event.stopPropagation(); deleteImage(${index})">Delete</button>
</div>
</div>
</div>
`).join('');
}
function deleteImage(index) {
images.splice(index, 1);
if (activeImageIndex === index) {
activeImageIndex = images.length > 0 ? 0 : -1;
} else if (activeImageIndex > index) {
activeImageIndex--;
}
updateImageList();
updatePositionControls();
renderThumbnails();
renderPreview();
updateButtons();
}
function replaceImage(index) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
images[index].image = img;
images[index].name = file.name;
updateImageList();
renderThumbnails();
if (activeImageIndex === index) {
renderPreview();
}
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
}
};
input.click();
}
function renderPreview() {
if (activeImageIndex < 0 || !images[activeImageIndex]) return;
const ctx = elements.previewCanvas.getContext('2d');
renderImageToCanvas(ctx, images[activeImageIndex], 1600, 1200);
}
function renderThumbnails() {
if (images.length === 0) {
elements.thumbnailGrid.innerHTML = '';
return;
}
elements.thumbnailGrid.innerHTML = images.map((img, index) => `
<div class="thumbnail-item ${index === activeImageIndex ? 'active' : ''}" onclick="setActiveImage(${index})" id="thumb-${index}">
<canvas class="thumbnail-canvas" width="400" height="300"></canvas>
</div>
`).join('');
images.forEach((img, index) => {
renderThumbnail(index);
});
}
function renderThumbnail(index) {
const thumbEl = document.querySelector(`#thumb-${index} canvas`);
if (!thumbEl) return;
const ctx = thumbEl.getContext('2d');
renderImageToCanvas(ctx, images[index], 400, 300);
}
function renderImageToCanvas(ctx, imageData, width, height) {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, width, height);
// Draw image with positioning
const img = imageData.image;
const scale = imageData.zoom / 100;
const imgAspect = img.width / img.height;
const canvasAspect = width / height;
let drawWidth, drawHeight;
if (imgAspect > canvasAspect) {
drawHeight = height * scale;
drawWidth = drawHeight * imgAspect;
} else {
drawWidth = width * scale;
drawHeight = drawWidth / imgAspect;
}
const offsetX = (width - drawWidth) * (imageData.posX / 100);
const offsetY = (height - drawHeight) * (imageData.posY / 100);
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
// Overlay settings
const barHeight = parseInt(elements.barHeight.value) * (width / 1600);
const padding = parseInt(elements.barPadding.value) * (width / 1600);
const footerFontSize = parseInt(elements.fontSize.value) * (width / 1600);
const telephoneFontSize = parseInt(elements.telephoneFontSize.value) * (width / 1600);
const topBarColor = elements.topBarColorPicker.value;
const bottomBarColor = elements.bottomBarColorPicker.value;
const textColor = elements.textColorPicker.value;
const logoBgColor = elements.logoBgColorPicker.value;
// Top bar
ctx.fillStyle = topBarColor;
ctx.fillRect(0, 0, width, barHeight);
// Logo (overlapping top bar, larger, with background and border)
if (logoImage) {
const baseLogoSize = barHeight * 1.5;
const logoSize = baseLogoSize * (logoScale / 100);
const logoWidth = (logoImage.width / logoImage.height) * logoSize;
const logoX = padding;
const logoY = barHeight * 0.25;
// Logo background
ctx.fillStyle = logoBgColor;
const bgPadding = padding * 0.5;
ctx.fillRect(logoX - bgPadding, logoY - bgPadding, logoWidth + bgPadding * 2, logoSize + bgPadding * 2);
// Logo border (same as top bar color)
ctx.strokeStyle = topBarColor;
ctx.lineWidth = padding * 0.3;
ctx.strokeRect(logoX - bgPadding, logoY - bgPadding, logoWidth + bgPadding * 2, logoSize + bgPadding * 2);
// Logo image
ctx.drawImage(logoImage, logoX, logoY, logoWidth, logoSize);
}
// Telephone (top-right) - no icon
const telephone = elements.telephone.value;
if (telephone) {
ctx.fillStyle = textColor;
ctx.font = `bold ${telephoneFontSize}px Montserrat, sans-serif`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(telephone, width - padding, barHeight / 2);
}
// Bottom bar
ctx.fillStyle = bottomBarColor;
ctx.fillRect(0, height - barHeight, width, barHeight);
// Website (bottom-left)
ctx.fillStyle = textColor;
ctx.textAlign = 'left';
ctx.font = `bold ${footerFontSize}px Montserrat, sans-serif`;
const website = elements.website.value;
if (website) {
ctx.fillText(website, padding, height - barHeight / 2);
}
// Address (bottom-right)
ctx.textAlign = 'right';
const address = elements.address.value;
if (address) {
ctx.fillText(address, width - padding, height - barHeight / 2);
}
}
function renderAll() {
renderPreview();
renderThumbnails();
}
// Initialize
renderPreview();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NIVS Stream Overlay Generator</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--panel-bg: #ff4605;
--title-bg: #ff4605;
--title-text: #ffffff;
--details-text: #000000;
--ticker-bg: #1d212c;
--ticker-title-bg: #ffff00;
--ticker-desc-text: #ffffff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Montserrat', sans-serif;
}
html {
height: 100%;
}
body {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
color: #f5f5f5;
padding: 20px;
min-height: 100%;
}
.container {
max-width: 1920px;
margin: 0 auto;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-right {
display: flex;
gap: 15px;
align-items: center;
}
.header-control {
display: flex;
flex-direction: column;
gap: 2px;
}
.header-control-row {
display: flex;
align-items: center;
gap: 8px;
}
.header-control label {
font-size: 0.8rem;
font-weight: 600;
color: rgba(255,255,255,0.9);
white-space: nowrap;
}
.header-control input {
width: 70px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: white;
font-size: 0.85rem;
}
.header-hint {
font-size: 0.65rem;
color: rgba(255,255,255,0.5);
font-style: italic;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.title-orange { color: #ff4605; }
.title-white { color: white; }
.subtitle {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0;
}
.content-wrapper {
display: grid;
grid-template-columns: 280px 1fr 280px;
gap: 20px;
margin-bottom: 20px;
}
.bottom-panel {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 20px;
}
.panel {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 20px;
}
.left-panel {
overflow-x: hidden;
overflow-y: auto;
}
.left-panel input[type="file"],
.left-panel input[type="range"],
.left-panel .video-controls {
max-width: 100%;
}
.right-panel {
overflow-x: hidden;
overflow-y: auto;
}
.section-title {
font-size: 1rem;
font-weight: 700;
color: #ff4605;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid rgba(255, 70, 5, 0.3);
}
.control-group {
margin-bottom: 15px;
}
.control-group label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 5px;
}
input[type="text"],
input[type="number"],
input[type="url"],
input[type="range"],
textarea {
width: 100%;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: white;
font-size: 0.85rem;
}
input[type="color"] {
width: 100%;
height: 36px;
border: none;
border-radius: 6px;
cursor: pointer;
}
select {
width: 100%;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: white;
font-size: 0.85rem;
cursor: pointer;
}
select option {
background: #1e293b;
color: white;
}
.color-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.color-grid .control-group {
margin-bottom: 0;
}
.color-grid .control-group label {
font-size: 0.7rem;
margin-bottom: 3px;
}
.color-grid input[type="color"] {
height: 30px;
}
.font-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.font-row .control-group {
margin-bottom: 0;
}
.font-row .control-group label {
font-size: 0.65rem;
margin-bottom: 3px;
}
.font-row select {
padding: 6px 8px;
font-size: 0.75rem;
}
input[type="range"] {
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.15);
border-radius: 5px;
height: 6px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ff4605;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #ff4605;
cursor: pointer;
}
textarea {
resize: vertical;
min-height: 50px;
}
.btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.3s ease;
width: 100%;
margin-top: 8px;
}
.btn-primary {
background: #ff4605;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #e03d04;
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.btn-small {
padding: 4px 8px;
font-size: 0.7rem;
width: auto;
margin: 0;
}
.btn-update {
background: #ffffff;
color: #1e293b;
font-weight: 700;
font-size: 0.9rem;
padding: 12px 20px;
width: auto;
margin: 0;
}
.btn-update:hover {
background: #f0f0f0;
}
.btn-export-header {
background: #ff4605;
color: white;
font-weight: 700;
font-size: 0.9rem;
padding: 12px 20px;
width: auto;
margin: 0;
position: relative;
}
.btn-export-header:hover:not(:disabled) {
background: #e03d04;
}
.btn-export-header:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.export-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.export-progress {
width: 180px;
height: 8px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
overflow: hidden;
display: none;
}
.export-progress.active {
display: block;
}
.export-progress-fill {
height: 100%;
background: #ff4605;
width: 0%;
transition: width 0.1s;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 18px 0;
}
#preview-stage {
width: 100%;
aspect-ratio: 1920 / 1080;
background: #000;
position: relative;
border-radius: 0;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.3);
}
#preview-canvas {
width: 100%;
height: 100%;
display: block;
border-radius: 0;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(10px);
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 20px;
}
.preview-overlay.active {
display: flex;
}
.render-progress {
width: 80%;
height: 40px;
background: rgba(255,255,255,0.1);
border-radius: 20px;
overflow: hidden;
position: relative;
}
.render-progress-fill {
height: 100%;
background: #ff4605;
width: 0%;
transition: width 0.1s;
}
.render-progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-weight: 700;
font-size: 1.2rem;
}
.video-controls {
margin-top: 15px;
display: flex;
gap: 10px;
align-items: center;
}
.item-list {
margin-top: 10px;
}
.left-panel .item-list,
.right-panel .item-list {
max-height: 200px;
overflow-y: auto;
}
.bottom-panel .item-list {
max-height: none;
overflow: visible;
}
.list-item {
background: rgba(255, 255, 255, 0.05);
padding: 8px;
border-radius: 6px;
margin-bottom: 6px;
font-size: 0.75rem;
}
.list-item-edit {
display: flex;
flex-direction: column;
gap: 5px;
}
.list-item-edit input {
font-size: 0.75rem;
padding: 5px 8px;
}
.list-item-buttons {
display: flex;
gap: 5px;
margin-top: 5px;
}
.list-item-content {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-container {
margin-top: 12px;
display: none;
}
.progress-bar-bg {
background: rgba(255,255,255,0.1);
border-radius: 8px;
height: 28px;
overflow: hidden;
position: relative;
}
.progress-bar-fill {
background: #ff4605;
height: 100%;
width: 0%;
transition: width 0.1s;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-weight: 600;
font-size: 0.85rem;
}
.progress-status {
text-align: center;
margin-top: 6px;
color: rgba(255,255,255,0.8);
font-size: 0.75rem;
}
.left-panel::-webkit-scrollbar,
.right-panel::-webkit-scrollbar {
width: 6px;
}
.left-panel::-webkit-scrollbar-track,
.right-panel::-webkit-scrollbar-track {
background: rgba(255,255,255,0.05);
}
.left-panel::-webkit-scrollbar-thumb,
.right-panel::-webkit-scrollbar-thumb {
background: rgba(255,70,5,0.5);
border-radius: 3px;
}
.left-panel::-webkit-scrollbar-thumb:hover,
.right-panel::-webkit-scrollbar-thumb:hover {
background: rgba(255,70,5,0.7);
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-group input[type="checkbox"] {
width: auto;
}
.hint-text {
font-size: 0.7rem;
color: rgba(255,255,255,0.6);
font-style: italic;
margin-top: 4px;
}
.slider-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.slider-row label {
font-size: 0.75rem;
margin: 0;
}
.slider-row input[type="number"] {
width: 60px;
padding: 4px 6px;
font-size: 0.75rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header-row">
<div class="header-left">
<h1>
<span class="title-orange">NIVS TV</span>
<span class="title-white">Generator</span>
</h1>
<p class="subtitle">Create your version of #NIVS TV right here and place more eyes on your dealership!</p>
</div>
<div class="header-right">
<div class="header-control">
<div class="header-control-row">
<label>Export Duration (secs):</label>
<input type="number" id="export-duration" value="30" min="0">
</div>
<span class="header-hint">0 for full video</span>
</div>
<button class="btn btn-update" id="update-preview-btn">UPDATE PREVIEW</button>
<div class="export-wrapper">
<button class="btn btn-export-header" id="export-btn">📥 EXPORT VIDEO</button>
<div class="export-progress" id="export-progress">
<div class="export-progress-fill" id="export-progress-fill"></div>
</div>
</div>
</div>
</div>
<div class="content-wrapper">
<!-- LEFT PANEL - Controls -->
<div class="panel left-panel">
<div class="section-title">📹 Main Video</div>
<div class="control-group">
<input type="file" id="video-upload" accept="video/*">
</div>
<div class="video-controls">
<button class="btn btn-secondary" id="play-btn" style="flex: 1;">▶ Play</button>
<button class="btn btn-secondary" id="pause-btn" style="flex: 1;">⏸ Pause</button>
</div>
<div class="control-group" style="margin-top: 10px;">
<label>Volume: <span id="volume-value">100</span>%</label>
<input type="range" id="volume-slider" min="0" max="100" value="100">
</div>
<div class="checkbox-group">
<input type="checkbox" id="mute-toggle">
<label for="mute-toggle" style="margin: 0;">Mute</label>
</div>
<div class="divider"></div>
<div class="section-title">🎬 Video Crop</div>
<div class="control-group">
<label>Zoom: <span id="zoom-value">100</span>%</label>
<input type="range" id="video-zoom" min="100" max="200" value="100">
</div>
<div class="control-group">
<label>Offset X: <span id="offset-x-value">50</span>%</label>
<input type="range" id="video-offset-x" min="0" max="100" value="50">
</div>
<div class="control-group">
<label>Offset Y: <span id="offset-y-value">50</span>%</label>
<input type="range" id="video-offset-y" min="0" max="100" value="50">
</div>
<div class="divider"></div>
<div class="section-title">🖼️ Logo</div>
<div class="control-group">
<input type="file" id="logo-upload" accept="image/*">
</div>
<div class="control-group">
<label>Logo Size: <span id="logo-size-value">100</span>%</label>
<input type="range" id="logo-size" min="50" max="150" value="100">
</div>
<div class="divider"></div>
<div class="section-title">🖼️ Static Image</div>
<div class="control-group">
<input type="file" id="static-image" accept="image/*">
<div class="hint-text" id="static-hint">Recommended: 356×241px @1080p</div>
</div>
</div>
<!-- MIDDLE PANEL - Preview -->
<div class="panel">
<div class="section-title">🖥️ Live Preview</div>
<div id="preview-stage">
<canvas id="preview-canvas"></canvas>
<div class="preview-overlay" id="preview-overlay">
<div style="color: white; font-size: 1.5rem; font-weight: 700;">Rendering Preview...</div>
<div class="render-progress">
<div class="render-progress-fill" id="render-progress-fill"></div>
<div class="render-progress-text" id="render-progress-text">0%</div>
</div>
</div>
</div>
</div>
<!-- RIGHT PANEL - Image Uploads -->
<div class="panel right-panel">
<div class="section-title">🚗 Vehicle Images</div>
<div class="control-group">
<input type="file" id="vehicle-images" accept="image/*" multiple>
<div class="hint-text">Square images only</div>
</div>
<div id="vehicle-list" class="item-list"></div>
<div class="divider"></div>
<div class="section-title">📷 Portrait Posts</div>
<div class="control-group">
<input type="file" id="dealer-images" accept="image/*" multiple>
<div class="hint-text">4:5 Image Size</div>
</div>
<div id="dealer-list" class="item-list"></div>
</div>
</div>
<!-- BOTTOM PANEL - 3 Columns -->
<div class="bottom-panel">
<!-- Column 1: Title & Details -->
<div class="panel">
<div class="section-title">📝 Title & Details</div>
<div class="control-group">
<input type="text" id="title-text" placeholder="Title (auto UPPERCASE)">
<textarea id="details-text" placeholder="Details text"></textarea>
<button class="btn btn-primary" id="add-title">Add Entry</button>
</div>
<div id="title-list" class="item-list"></div>
</div>
<!-- Column 2: Ticker Items -->
<div class="panel">
<div class="section-title">📰 Ticker Items</div>
<div class="control-group">
<input type="text" id="ticker-title" placeholder="Title (auto UPPERCASE)">
<input type="text" id="ticker-desc" placeholder="Description (auto UPPERCASE)">
<button class="btn btn-primary" id="add-ticker">Add Item</button>
</div>
<div id="ticker-list" class="item-list"></div>
</div>
<!-- Column 3: Timing Controls -->
<div class="panel">
<div class="section-title">⏱️ Timing Controls</div>
<div class="slider-row">
<label>Vehicle Hold (s):</label>
<input type="number" id="vehicle-hold" value="5" min="1" max="30" step="0.5">
</div>
<div class="slider-row">
<label>Vehicle Slide-In (ms):</label>
<input type="number" id="vehicle-slide-in" value="800" min="100" max="2000" step="100">
</div>
<div class="slider-row">
<label>Vehicle Fade-Out (ms):</label>
<input type="number" id="vehicle-fade-out" value="800" min="100" max="2000" step="100">
</div>
<div class="divider"></div>
<div class="slider-row">
<label>Dealer Hold (s):</label>
<input type="number" id="dealer-hold" value="5" min="1" max="30" step="0.5">
</div>
<div class="slider-row">
<label>Dealer Slide-In (ms):</label>
<input type="number" id="dealer-slide-in" value="800" min="100" max="2000" step="100">
</div>
<div class="slider-row">
<label>Dealer Slide-Out (ms):</label>
<input type="number" id="dealer-slide-out" value="800" min="100" max="2000" step="100">
</div>
<div class="divider"></div>
<div class="slider-row">
<label>Title Hold (s):</label>
<input type="number" id="title-hold" value="5" min="1" max="30" step="0.5">
</div>
<div class="slider-row">
<label>Title Wipe-In (ms):</label>
<input type="number" id="title-wipe-in" value="800" min="100" max="2000" step="100">
</div>
<div class="slider-row">
<label>Title Wipe-Out (ms):</label>
<input type="number" id="title-wipe-out" value="800" min="100" max="2000" step="100">
</div>
<div class="divider"></div>
<div class="slider-row">
<label>Details Hold (s):</label>
<input type="number" id="details-hold" value="5" min="1" max="30" step="0.5">
</div>
<div class="slider-row">
<label>Details Wipe-In (ms):</label>
<input type="number" id="details-wipe-in" value="800" min="100" max="2000" step="100">
</div>
<div class="slider-row">
<label>Details Wipe-Out (ms):</label>
<input type="number" id="details-wipe-out" value="800" min="100" max="2000" step="100">
</div>
<div class="divider"></div>
<div class="slider-row">
<label>Ticker Speed:</label>
<input type="number" id="ticker-speed" value="100" min="20" max="300" step="10">
</div>
</div>
<!-- Column 4: Colour & Fonts -->
<div class="panel">
<div class="section-title">🎨 Colour & Fonts</div>
<div class="color-grid">
<div class="control-group">
<label>Title BG</label>
<input type="color" id="color-title-bg" value="#ff4605">
</div>
<div class="control-group">
<label>Title Font</label>
<input type="color" id="color-title-text" value="#ffffff">
</div>
</div>
<div class="color-grid" style="margin-top: 8px;">
<div class="control-group">
<label>Details BG</label>
<input type="color" id="color-details-bg" value="#ffffff">
</div>
<div class="control-group">
<label>Details Font</label>
<input type="color" id="color-details-text" value="#000000">
</div>
</div>
<div class="color-grid" style="margin-top: 8px;">
<div class="control-group">
<label>Ticker BG</label>
<input type="color" id="color-ticker-bg" value="#1d212c">
</div>
<div class="control-group">
<label>Ticker Desc Font</label>
<input type="color" id="color-ticker-desc" value="#ffffff">
</div>
</div>
<div class="color-grid" style="margin-top: 8px;">
<div class="control-group">
<label>Ticker Label BG</label>
<input type="color" id="color-ticker-label-bg" value="#ffff00">
</div>
<div class="control-group">
<label>Ticker Label Font</label>
<input type="color" id="color-ticker-label-text" value="#000000">
</div>
</div>
<div class="color-grid" style="margin-top: 8px;">
<div class="control-group">
<label>Right Panel BG</label>
<input type="color" id="color-panel-bg" value="#ff4605">
</div>
<div class="control-group">
<label> </label>
<div style="height: 30px;"></div>
</div>
</div>
<div class="divider"></div>
<div class="font-row">
<div class="control-group">
<label>Font Family</label>
<select id="font-family-global">
<option value="Montserrat" selected>Montserrat</option>
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Georgia">Georgia</option>
<option value="Verdana">Verdana</option>
<option value="Roboto">Roboto</option>
</select>
</div>
<div class="control-group">
<label>Title Weight</label>
<select id="font-weight-title">
<option value="400">Normal</option>
<option value="600">Semi</option>
<option value="700" selected>Bold</option>
<option value="800">Extra</option>
</select>
</div>
<div class="control-group">
<label>Label Weight</label>
<select id="font-weight-ticker-label">
<option value="400">Normal</option>
<option value="600">Semi</option>
<option value="700" selected>Bold</option>
<option value="800">Extra</option>
</select>
</div>
</div>
</div>
</div>
</div>
<script>
// Canvas size
const CANVAS_W = 1920;
const CANVAS_H = 1080;
// Layout - pixel-perfect coordinates
const VIDEO_W = 1524;
const VIDEO_H = 857;
const PANEL_W = 396;
const PANEL_H = 1021;
const TITLE_W = 406;
const TITLE_H = 164;
const DETAILS_W = 1118; // Ends exactly at PANEL_X
const DETAILS_H = 164;
const TICKER_W = CANVAS_W;
const TICKER_H = 59;
const VIDEO_X = 0, VIDEO_Y = 0;
const PANEL_X = VIDEO_W, PANEL_Y = 0;
const TITLE_X = 0, TITLE_Y = VIDEO_H;
const DETAILS_X = TITLE_W, DETAILS_Y = VIDEO_H;
const TICKER_X = 0, TICKER_Y = TITLE_Y + TITLE_H; // Flush below title/details
// Right panel internal layout
const PANEL_PAD = 20;
const LOGO_BOX_X = PANEL_X + PANEL_PAD;
const LOGO_BOX_W = PANEL_W - (PANEL_PAD * 2);
const LOGO_BOX_H = 120;
// Global state - APPLIED (used for rendering)
let videoElement = null;
let appliedLogoImage = null;
let appliedLogoSize = 100;
let appliedStaticImage = null;
let appliedVehicleImages = [];
let appliedDealerImages = [];
let appliedTitleEntries = [];
let appliedTickerItems = [];
// DRAFT state (edited by user, not rendered until Update)
let draftLogoImage = null;
let draftLogoSize = 100;
let draftStaticImage = null;
let draftVehicleImages = [];
let draftDealerImages = [];
let draftTitleEntries = [];
let draftTickerItems = [];
// Draft colour/font state
let draftColors = {
titleBg: '#ff4605',
titleText: '#ffffff',
titleFontWeight: '700',
detailsBg: '#ffffff',
detailsText: '#000000',
tickerBg: '#1d212c',
tickerLabelBg: '#ffff00',
tickerLabelText: '#000000',
tickerLabelFontWeight: '700',
tickerDescText: '#ffffff',
panelBg: '#ff4605',
fontFamily: 'Montserrat'
};
// Applied colour/font state
let appliedColors = {...draftColors};
let videoZoom = 100;
let videoOffsetX = 50;
let videoOffsetY = 50;
let previewCanvas, previewCtx;
let animationTime = 0;
let lastFrameTime = 0;
let isAnimating = false;
// Animation state
let currentVehicleIndex = 0;
let currentDealerIndex = 0;
let currentTitleIndex = 0;
let vehicleAnimProgress = 0;
let dealerAnimProgress = 0;
let titleAnimProgress = 0;
let detailsAnimProgress = 0;
let tickerOffset = 0;
let vehicleCycleStart = 0;
let dealerCycleStart = 0;
// Easing
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// Initialize
window.addEventListener('DOMContentLoaded', () => {
previewCanvas = document.getElementById('preview-canvas');
previewCtx = previewCanvas.getContext('2d');
previewCanvas.width = CANVAS_W;
previewCanvas.height = CANVAS_H;
setupEventListeners();
loadDemoData();
updateStaticImageHint();
});
function loadDemoData() {
// Demo titles (draft)
draftTitleEntries = [
{ title: 'PREMIUM VEHICLES', details: 'Explore our latest collection of premium vehicles' },
{ title: 'SPECIAL OFFERS', details: 'Limited time deals on selected models' },
{ title: 'TEST DRIVE TODAY', details: 'Book your test drive online or in-store' },
{ title: 'FINANCE OPTIONS', details: 'Flexible payment plans available for all customers' },
{ title: 'TRADE-IN WELCOME', details: 'Get the best value for your current vehicle' }
];
// Demo ticker (draft)
draftTickerItems = [
{ title: 'BREAKING NEWS', desc: 'NEW ARRIVALS THIS WEEK' },
{ title: 'SPECIAL OFFER', desc: '0% APR FINANCING AVAILABLE' },
{ title: 'EVENT', desc: 'OPEN HOUSE SATURDAY 9AM-5PM' },
{ title: 'UPDATE', desc: 'EXTENDED WARRANTY NOW INCLUDED' },
{ title: 'ANNOUNCEMENT', desc: 'FREE SERVICE FOR FIRST YEAR' }
];
updateList('title');
updateList('ticker');
// Apply immediately on load
applyDraftToPreview();
}
function updateStaticImageHint() {
const logoBoxH = 120;
const vehicleBoxH = 280;
const dealerBoxH = 340;
const usedSpace = 20 + logoBoxH + 20 + vehicleBoxH + 20 + dealerBoxH + 20;
const remainingH = PANEL_H - usedSpace - 20;
const remainingW = PANEL_W - 40;
document.getElementById('static-hint').textContent =
`Recommended: ${remainingW}×${remainingH}px @1080p`;
}
function setupEventListeners() {
document.getElementById('update-preview-btn').addEventListener('click', updatePreview);
document.getElementById('video-upload').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
if (videoElement) URL.revokeObjectURL(videoElement.src);
videoElement = document.createElement('video');
videoElement.src = URL.createObjectURL(file);
videoElement.loop = true;
videoElement.volume = document.getElementById('volume-slider').value / 100;
videoElement.muted = document.getElementById('mute-toggle').checked;
videoElement.load();
}
});
document.getElementById('play-btn').addEventListener('click', () => {
if (videoElement) videoElement.play();
});
document.getElementById('pause-btn').addEventListener('click', () => {
if (videoElement) videoElement.pause();
});
document.getElementById('volume-slider').addEventListener('input', (e) => {
document.getElementById('volume-value').textContent = e.target.value;
if (videoElement) videoElement.volume = e.target.value / 100;
});
document.getElementById('mute-toggle').addEventListener('change', (e) => {
if (videoElement) videoElement.muted = e.target.checked;
});
document.getElementById('video-zoom').addEventListener('input', (e) => {
videoZoom = parseInt(e.target.value);
document.getElementById('zoom-value').textContent = videoZoom;
});
document.getElementById('video-offset-x').addEventListener('input', (e) => {
videoOffsetX = parseInt(e.target.value);
document.getElementById('offset-x-value').textContent = videoOffsetX;
});
document.getElementById('video-offset-y').addEventListener('input', (e) => {
videoOffsetY = parseInt(e.target.value);
document.getElementById('offset-y-value').textContent = videoOffsetY;
});
document.getElementById('logo-upload').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => { draftLogoImage = img; };
img.src = evt.target.result;
};
reader.readAsDataURL(file);
}
});
document.getElementById('logo-size').addEventListener('input', (e) => {
draftLogoSize = parseInt(e.target.value);
document.getElementById('logo-size-value').textContent = draftLogoSize;
});
document.getElementById('vehicle-images').addEventListener('change', (e) => {
Array.from(e.target.files).forEach(file => {
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
draftVehicleImages.push(img);
updateList('vehicle');
};
img.src = evt.target.result;
};
reader.readAsDataURL(file);
});
});
document.getElementById('dealer-images').addEventListener('change', (e) => {
Array.from(e.target.files).forEach(file => {
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
draftDealerImages.push(img);
updateList('dealer');
};
img.src = evt.target.result;
};
reader.readAsDataURL(file);
});
});
document.getElementById('static-image').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => { draftStaticImage = img; };
img.src = evt.target.result;
};
reader.readAsDataURL(file);
}
});
document.getElementById('add-title').addEventListener('click', () => {
const title = document.getElementById('title-text').value;
const details = document.getElementById('details-text').value;
if (title && details) {
draftTitleEntries.push({ title, details });
updateList('title');
document.getElementById('title-text').value = '';
document.getElementById('details-text').value = '';
}
});
document.getElementById('add-ticker').addEventListener('click', () => {
const title = document.getElementById('ticker-title').value;
const desc = document.getElementById('ticker-desc').value;
if (title && desc) {
draftTickerItems.push({ title: title.toUpperCase(), desc: desc.toUpperCase() });
updateList('ticker');
document.getElementById('ticker-title').value = '';
document.getElementById('ticker-desc').value = '';
}
});
document.getElementById('export-btn').addEventListener('click', startExport);
}
function updateList(type) {
let list, data;
if (type === 'vehicle') {
list = document.getElementById('vehicle-list');
data = draftVehicleImages;
} else if (type === 'dealer') {
list = document.getElementById('dealer-list');
data = draftDealerImages;
} else if (type === 'title') {
list = document.getElementById('title-list');
data = draftTitleEntries;
} else if (type === 'ticker') {
list = document.getElementById('ticker-list');
data = draftTickerItems;
}
list.innerHTML = '';
data.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'list-item';
if (type === 'title' || type === 'ticker') {
div.innerHTML = `
<div class="list-item-edit">
<input type="text" value="${type === 'title' ? item.title : item.title}" data-idx="${idx}" data-type="${type}" data-field="title">
<input type="text" value="${type === 'title' ? item.details : item.desc}" data-idx="${idx}" data-type="${type}" data-field="${type === 'title' ? 'details' : 'desc'}">
<div class="list-item-buttons">
<button class="btn btn-secondary btn-small" onclick="updateEntry('${type}', ${idx})">Update</button>
<button class="btn btn-secondary btn-small" onclick="removeItem('${type}', ${idx})">Delete</button>
</div>
</div>
`;
} else {
const text = `Image ${idx + 1}`;
div.innerHTML = `
<span class="list-item-content">${text}</span>
<button class="btn btn-secondary btn-small" onclick="removeItem('${type}', ${idx})">Delete</button>
`;
}
list.appendChild(div);
});
}
function updateEntry(type, idx) {
const inputs = document.querySelectorAll(`input[data-idx="${idx}"][data-type="${type}"]`);
if (type === 'title') {
draftTitleEntries[idx].title = inputs[0].value;
draftTitleEntries[idx].details = inputs[1].value;
} else if (type === 'ticker') {
draftTickerItems[idx].title = inputs[0].value.toUpperCase();
draftTickerItems[idx].desc = inputs[1].value.toUpperCase();
}
}
function removeItem(type, idx) {
if (type === 'vehicle') draftVehicleImages.splice(idx, 1);
else if (type === 'dealer') draftDealerImages.splice(idx, 1);
else if (type === 'title') draftTitleEntries.splice(idx, 1);
else if (type === 'ticker') draftTickerItems.splice(idx, 1);
updateList(type);
}
function applyDraftToPreview() {
// Copy draft to applied
appliedLogoImage = draftLogoImage;
appliedLogoSize = draftLogoSize;
appliedStaticImage = draftStaticImage;
appliedVehicleImages = [...draftVehicleImages];
appliedDealerImages = [...draftDealerImages];
appliedTitleEntries = draftTitleEntries.map(e => ({...e}));
appliedTickerItems = draftTickerItems.map(e => ({...e}));
// Copy colour/font settings from form
draftColors.titleBg = document.getElementById('color-title-bg').value;
draftColors.titleText = document.getElementById('color-title-text').value;
draftColors.titleFontWeight = document.getElementById('font-weight-title').value;
draftColors.detailsBg = document.getElementById('color-details-bg').value;
draftColors.detailsText = document.getElementById('color-details-text').value;
draftColors.tickerBg = document.getElementById('color-ticker-bg').value;
draftColors.tickerLabelBg = document.getElementById('color-ticker-label-bg').value;
draftColors.tickerLabelText = document.getElementById('color-ticker-label-text').value;
draftColors.tickerLabelFontWeight = document.getElementById('font-weight-ticker-label').value;
draftColors.tickerDescText = document.getElementById('color-ticker-desc').value;
draftColors.panelBg = document.getElementById('color-panel-bg').value;
draftColors.fontFamily = document.getElementById('font-family-global').value;
appliedColors = {...draftColors};
// Reset animation indices
currentVehicleIndex = 0;
currentDealerIndex = 0;
currentTitleIndex = 0;
}
function updatePreview() {
document.getElementById('preview-overlay').classList.add('active');
document.getElementById('render-progress-fill').style.width = '0%';
document.getElementById('render-progress-text').textContent = '0%';
// Stop current animation
isAnimating = false;
// Simulate render progress
let progress = 0;
const interval = setInterval(() => {
progress += 10;
document.getElementById('render-progress-fill').style.width = progress + '%';
document.getElementById('render-progress-text').textContent = progress + '%';
if (progress >= 100) {
clearInterval(interval);
setTimeout(() => {
document.getElementById('preview-overlay').classList.remove('active');
applyDraftToPreview();
resetAnimation();
startPreview();
}, 200);
}
}, 50);
}
function resetAnimation() {
animationTime = 0;
lastFrameTime = 0;
currentVehicleIndex = 0;
currentDealerIndex = 0;
currentTitleIndex = 0;
vehicleAnimProgress = 0;
dealerAnimProgress = 0;
titleAnimProgress = 0;
detailsAnimProgress = 0;
tickerOffset = 0;
vehicleCycleStart = 0;
dealerCycleStart = 0;
}
function startPreview() {
if (isAnimating) return;
isAnimating = true;
lastFrameTime = performance.now();
function render(timestamp) {
if (!isAnimating) return;
const delta = timestamp - lastFrameTime;
lastFrameTime = timestamp;
animationTime += delta;
updateAnimations(delta);
drawFrame(previewCtx);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
}
function updateAnimations(delta) {
const tickerSpeed = parseInt(document.getElementById('ticker-speed').value);
tickerOffset += delta / tickerSpeed;
// Vehicle animation
if (appliedVehicleImages.length > 0) {
const slideIn = parseInt(document.getElementById('vehicle-slide-in').value);
const hold = parseFloat(document.getElementById('vehicle-hold').value) * 1000;
const fadeOut = parseInt(document.getElementById('vehicle-fade-out').value);
const cycle = slideIn + hold + fadeOut;
const cycleTime = animationTime % cycle;
if (cycleTime < slideIn) {
vehicleAnimProgress = easeInOutCubic(cycleTime / slideIn);
} else if (cycleTime < slideIn + hold) {
vehicleAnimProgress = 1;
} else {
vehicleAnimProgress = 1 - easeInOutCubic((cycleTime - slideIn - hold) / fadeOut);
}
// Change index at cycle boundary
const currentCycle = Math.floor(animationTime / cycle);
currentVehicleIndex = currentCycle % appliedVehicleImages.length;
}
// Dealer animation
if (appliedDealerImages.length > 0) {
const slideIn = parseInt(document.getElementById('dealer-slide-in').value);
const hold = parseFloat(document.getElementById('dealer-hold').value) * 1000;
const slideOut = parseInt(document.getElementById('dealer-slide-out').value);
const cycle = slideIn + hold + slideOut;
const cycleTime = animationTime % cycle;
if (cycleTime < slideIn) {
// Slide in from right (1 = off-screen, 0 = on-screen)
dealerAnimProgress = 1 - easeInOutCubic(cycleTime / slideIn);
} else if (cycleTime < slideIn + hold) {
dealerAnimProgress = 0; // Fully visible
} else {
// Slide out to right (0 = on-screen, 1 = off-screen)
dealerAnimProgress = easeInOutCubic((cycleTime - slideIn - hold) / slideOut);
}
const currentCycle = Math.floor(animationTime / cycle);
currentDealerIndex = currentCycle % appliedDealerImages.length;
}
// Title & Details animation
if (appliedTitleEntries.length > 0) {
const titleWipeIn = parseInt(document.getElementById('title-wipe-in').value);
const titleHold = parseFloat(document.getElementById('title-hold').value) * 1000;
const titleWipeOut = parseInt(document.getElementById('title-wipe-out').value);
const cycle = titleWipeIn + titleHold + titleWipeOut;
const cycleTime = animationTime % cycle;
if (cycleTime < titleWipeIn) {
titleAnimProgress = easeInOutCubic(cycleTime / titleWipeIn);
} else if (cycleTime < titleWipeIn + titleHold) {
titleAnimProgress = 1;
} else {
titleAnimProgress = 1 - easeInOutCubic((cycleTime - titleWipeIn - titleHold) / titleWipeOut);
}
const currentCycle = Math.floor(animationTime / cycle);
currentTitleIndex = currentCycle % appliedTitleEntries.length;
// Details (separate timing)
const detailsWipeIn = parseInt(document.getElementById('details-wipe-in').value);
const detailsHold = parseFloat(document.getElementById('details-hold').value) * 1000;
const detailsWipeOut = parseInt(document.getElementById('details-wipe-out').value);
const detailsCycle = detailsWipeIn + detailsHold + detailsWipeOut;
const detailsCycleTime = animationTime % detailsCycle;
if (detailsCycleTime < detailsWipeIn) {
detailsAnimProgress = easeInOutCubic(detailsCycleTime / detailsWipeIn);
} else if (detailsCycleTime < detailsWipeIn + detailsHold) {
detailsAnimProgress = 1;
} else {
detailsAnimProgress = 1 - easeInOutCubic((detailsCycleTime - detailsWipeIn - detailsHold) / detailsWipeOut);
}
}
}
function drawFrame(ctx) {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
// Video
if (videoElement && videoElement.readyState >= 2) {
drawVideoCover(ctx, videoElement, VIDEO_X, VIDEO_Y, VIDEO_W, VIDEO_H);
} else {
ctx.fillStyle = '#333';
ctx.fillRect(VIDEO_X, VIDEO_Y, VIDEO_W, VIDEO_H);
ctx.fillStyle = 'white';
ctx.font = '40px Montserrat';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Upload Video', VIDEO_X + VIDEO_W / 2, VIDEO_Y + VIDEO_H / 2);
}
// Right panel
ctx.fillStyle = appliedColors.panelBg;
ctx.fillRect(PANEL_X, PANEL_Y, PANEL_W, PANEL_H);
let yPos = PANEL_Y + PANEL_PAD;
// Logo box - aligned edges
ctx.fillStyle = 'white';
roundRect(ctx, LOGO_BOX_X, yPos, LOGO_BOX_W, LOGO_BOX_H, 12);
ctx.fill();
if (appliedLogoImage) {
const scale = appliedLogoSize / 100;
const maxW = (LOGO_BOX_W - 20) * scale;
const maxH = (LOGO_BOX_H - 20) * scale;
const ratio = Math.min(maxW / appliedLogoImage.width, maxH / appliedLogoImage.height);
const w = appliedLogoImage.width * ratio;
const h = appliedLogoImage.height * ratio;
const x = LOGO_BOX_X + LOGO_BOX_W / 2 - w / 2;
const y = yPos + LOGO_BOX_H / 2 - h / 2;
ctx.drawImage(appliedLogoImage, x, y, w, h);
}
yPos += LOGO_BOX_H + PANEL_PAD;
// Vehicle cards - aligned to logo box edges
const vehicleBoxH = 280;
const cardX = LOGO_BOX_X;
const cardW = LOGO_BOX_W;
if (appliedVehicleImages.length > 0 && appliedVehicleImages[currentVehicleIndex]) {
const img = appliedVehicleImages[currentVehicleIndex];
const offsetX = cardW * (1 - vehicleAnimProgress);
const alpha = vehicleAnimProgress;
ctx.save();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.rect(cardX, yPos, cardW, vehicleBoxH);
ctx.clip();
const ratio = Math.min(cardW / img.width, vehicleBoxH / img.height);
const w = img.width * ratio;
const h = img.height * ratio;
const x = cardX + offsetX + (cardW - w) / 2;
const y = yPos + (vehicleBoxH - h) / 2;
ctx.drawImage(img, x, y, w, h);
ctx.restore();
} else {
// Placeholder
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.fillRect(cardX, yPos, cardW, vehicleBoxH);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '20px Montserrat';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Vehicle Images', cardX + cardW / 2, yPos + vehicleBoxH / 2);
}
yPos += vehicleBoxH + PANEL_PAD;
// Dealer cards - aligned to logo box edges
const dealerBoxH = 340;
if (appliedDealerImages.length > 0 && appliedDealerImages[currentDealerIndex]) {
const img = appliedDealerImages[currentDealerIndex];
const offsetX = cardW * dealerAnimProgress;
ctx.save();
ctx.beginPath();
ctx.rect(cardX, yPos, cardW, dealerBoxH);
ctx.clip();
const ratio = Math.min(cardW / img.width, dealerBoxH / img.height);
const w = img.width * ratio;
const h = img.height * ratio;
const x = cardX + offsetX + (cardW - w) / 2;
const y = yPos + (dealerBoxH - h) / 2;
ctx.drawImage(img, x, y, w, h);
ctx.restore();
} else {
// Placeholder
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.fillRect(cardX, yPos, cardW, dealerBoxH);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '20px Montserrat';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Dealer Images', cardX + cardW / 2, yPos + dealerBoxH / 2);
}
yPos += dealerBoxH + PANEL_PAD;
// Static image - aligned to logo box edges
const remainingH = PANEL_Y + PANEL_H - yPos - PANEL_PAD;
if (appliedStaticImage) {
drawContain(ctx, appliedStaticImage, cardX, yPos, cardW, remainingH);
} else {
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.fillRect(cardX, yPos, cardW, remainingH);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '16px Montserrat';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Static Image', cardX + cardW / 2, yPos + remainingH / 2);
}
// Title box - flush with details and ticker
ctx.fillStyle = appliedColors.titleBg;
ctx.fillRect(TITLE_X, TITLE_Y, TITLE_W, TITLE_H);
if (appliedTitleEntries.length > 0 && appliedTitleEntries[currentTitleIndex]) {
const entry = appliedTitleEntries[currentTitleIndex];
const titleText = entry.title.toUpperCase();
ctx.save();
ctx.beginPath();
ctx.rect(TITLE_X, TITLE_Y, TITLE_W, TITLE_H);
ctx.clip();
const offsetX = -TITLE_W * (1 - titleAnimProgress);
ctx.fillStyle = appliedColors.titleText;
ctx.font = `${appliedColors.titleFontWeight} 50px ${appliedColors.fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
wrapText(ctx, titleText, TITLE_X + TITLE_W / 2 + offsetX, TITLE_Y + TITLE_H / 2, TITLE_W - 40, 60);
ctx.restore();
}
// Details box - flush, ends at panel edge
ctx.fillStyle = appliedColors.detailsBg;
ctx.fillRect(DETAILS_X, DETAILS_Y, DETAILS_W, DETAILS_H);
if (appliedTitleEntries.length > 0 && appliedTitleEntries[currentTitleIndex]) {
const entry = appliedTitleEntries[currentTitleIndex];
ctx.save();
ctx.beginPath();
ctx.rect(DETAILS_X, DETAILS_Y, DETAILS_W, DETAILS_H);
ctx.clip();
const offsetX = -DETAILS_W * (1 - detailsAnimProgress);
ctx.fillStyle = appliedColors.detailsText;
ctx.font = `40px ${appliedColors.fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
wrapText(ctx, entry.details, DETAILS_X + 30 + offsetX, DETAILS_Y + DETAILS_H / 2, DETAILS_W - 60, 50);
ctx.restore();
}
// Ticker - flush below title/details
ctx.fillStyle = appliedColors.tickerBg;
ctx.fillRect(TICKER_X, TICKER_Y, TICKER_W, TICKER_H);
if (appliedTickerItems.length > 0) {
ctx.save();
ctx.beginPath();
ctx.rect(TICKER_X, TICKER_Y, TICKER_W, TICKER_H);
ctx.clip();
let totalWidth = 0;
appliedTickerItems.forEach(item => {
ctx.font = `${appliedColors.tickerLabelFontWeight} 30px ${appliedColors.fontFamily}`;
const titleW = ctx.measureText(item.title).width + 40;
ctx.font = `30px ${appliedColors.fontFamily}`;
const descW = ctx.measureText(item.desc).width + 60;
totalWidth += titleW + descW;
});
const x = TICKER_X - (tickerOffset % totalWidth);
for (let i = 0; i < 3; i++) {
let xPos = x + (i * totalWidth);
appliedTickerItems.forEach(item => {
ctx.font = `${appliedColors.tickerLabelFontWeight} 30px ${appliedColors.fontFamily}`;
const titleW = ctx.measureText(item.title).width + 40;
ctx.fillStyle = appliedColors.tickerLabelBg;
ctx.fillRect(xPos, TICKER_Y, titleW, TICKER_H);
ctx.fillStyle = appliedColors.tickerLabelText;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(item.title, xPos + 20, TICKER_Y + TICKER_H / 2);
xPos += titleW + 20;
ctx.fillStyle = appliedColors.tickerDescText;
ctx.font = `30px ${appliedColors.fontFamily}`;
ctx.fillText(item.desc, xPos, TICKER_Y + TICKER_H / 2);
xPos += ctx.measureText(item.desc).width + 40;
});
}
ctx.restore();
}
}
function drawVideoCover(ctx, video, x, y, w, h) {
const scale = videoZoom / 100;
const videoAspect = video.videoWidth / video.videoHeight;
const targetAspect = w / h;
let renderW, renderH;
if (videoAspect > targetAspect) {
renderH = h * scale;
renderW = renderH * videoAspect;
} else {
renderW = w * scale;
renderH = renderW / videoAspect;
}
const renderX = x + (w - renderW) * (videoOffsetX / 100);
const renderY = y + (h - renderH) * (videoOffsetY / 100);
ctx.save();
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.clip();
ctx.drawImage(video, renderX, renderY, renderW, renderH);
ctx.restore();
}
function drawContain(ctx, img, x, y, w, h) {
const ratio = Math.min(w / img.width, h / img.height);
const renderW = img.width * ratio;
const renderH = img.height * ratio;
const renderX = x + (w - renderW) / 2;
const renderY = y + (h - renderH) / 2;
ctx.drawImage(img, renderX, renderY, renderW, renderH);
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
function wrapText(ctx, text, x, y, maxW, lineH) {
const words = text.split(' ');
let lines = [];
let line = '';
words.forEach(word => {
const test = line + word + ' ';
if (ctx.measureText(test).width > maxW && line) {
lines.push(line);
line = word + ' ';
} else {
line = test;
}
});
if (line) lines.push(line);
const startY = y - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => {
ctx.fillText(l.trim(), x, startY + i * lineH);
});
}
// Export
let exportCanvas, exportCtx, exportMediaRecorder, exportChunks, exportStartTime, exportDuration;
async function startExport() {
if (!videoElement) {
alert('Upload a video first!');
return;
}
const userDur = parseInt(document.getElementById('export-duration').value);
exportDuration = userDur > 0 ? userDur : videoElement.duration;
if (!exportDuration || exportDuration === Infinity) {
alert('Set an export duration');
return;
}
exportChunks = [];
exportCanvas = document.createElement('canvas');
exportCanvas.width = CANVAS_W;
exportCanvas.height = CANVAS_H;
exportCtx = exportCanvas.getContext('2d', { alpha: false });
videoElement.currentTime = 0;
videoElement.muted = false;
videoElement.volume = document.getElementById('volume-slider').value / 100;
try {
await videoElement.play();
} catch (err) {
alert('Cannot play video: ' + err.message);
return;
}
const videoStream = exportCanvas.captureStream(60);
const audioStream = videoElement.captureStream();
const audioTrack = audioStream.getAudioTracks()[0];
if (audioTrack) {
videoStream.addTrack(audioTrack);
}
exportMediaRecorder = new MediaRecorder(videoStream, {
mimeType: 'video/webm;codecs=vp9,opus',
videoBitsPerSecond: 8000000,
audioBitsPerSecond: 192000
});
exportMediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) exportChunks.push(e.data);
};
exportMediaRecorder.onstop = finishExport;
exportMediaRecorder.start();
exportStartTime = Date.now();
document.getElementById('export-btn').disabled = true;
document.getElementById('export-progress').classList.add('active');
document.getElementById('export-progress-fill').style.width = '0%';
renderExport();
}
function renderExport() {
const elapsed = (Date.now() - exportStartTime) / 1000;
const progress = Math.min((elapsed / exportDuration) * 100, 100);
document.getElementById('export-progress-fill').style.width = progress + '%';
drawFrame(exportCtx);
if (elapsed < exportDuration && videoElement && !videoElement.paused) {
requestAnimationFrame(renderExport);
} else {
stopExport();
}
}
function stopExport() {
if (videoElement) videoElement.pause();
if (exportMediaRecorder && exportMediaRecorder.state !== 'inactive') {
exportMediaRecorder.stop();
}
}
function finishExport() {
const blob = new Blob(exportChunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nivs-overlay-${Date.now()}.webm`;
a.click();
URL.revokeObjectURL(url);
document.getElementById('export-btn').disabled = false;
setTimeout(() => {
document.getElementById('export-progress').classList.remove('active');
}, 1000);
}
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NIVS Customer Review Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url(https://fonts.gstatic.com/s/montserrat/v26/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Hw5aXp-p7K4KLg.woff2) format('woff2');
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Montserrat', sans-serif;
}
html, body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
color: #f5f5f5;
}
.app {
height: 100vh;
display: flex;
flex-direction: column;
padding: 15px 120px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-shrink: 0;
}
.header-left {
display: flex;
flex-direction: column;
}
h1 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 4px;
}
.title-orange { color: #ff4605; }
.title-white { color: white; }
.subtitle {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0;
}
.btn {
padding: 10px 18px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-update {
background: #ffffff;
color: #1e293b;
font-weight: 700;
font-size: 0.9rem;
padding: 12px 20px;
}
.btn-update:hover {
background: #f0f0f0;
}
.btn-export {
background: #ff4605;
color: white;
font-weight: 700;
font-size: 0.9rem;
padding: 12px 20px;
}
.btn-export:hover {
background: #e03d04;
}
.btn-primary {
background: #ff4605;
color: white;
}
.btn-primary:hover {
background: #e03d04;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.content-wrapper {
display: grid;
grid-template-columns: 280px 1fr 250px;
gap: 12px;
flex: 1;
min-height: 0;
}
.panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 15px;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
}
.left-panel, .right-panel {
overflow-x: hidden;
overflow-y: auto;
}
.left-panel::-webkit-scrollbar,
.right-panel::-webkit-scrollbar {
width: 6px;
}
.left-panel::-webkit-scrollbar-track,
.right-panel::-webkit-scrollbar-track {
background: rgba(255,255,255,0.05);
}
.left-panel::-webkit-scrollbar-thumb,
.right-panel::-webkit-scrollbar-thumb {
background: rgba(255,70,5,0.5);
border-radius: 3px;
}
.middle-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: transparent;
border: none;
}
.section-title {
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 12px;
color: #ff4605;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.control-group {
margin-bottom: 12px;
}
.control-group label {
display: block;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 5px;
color: rgba(255, 255, 255, 0.9);
}
input[type="text"],
input[type="number"],
input[type="tel"],
input[type="url"],
textarea {
width: 100%;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-size: 0.8rem;
}
input::placeholder,
textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
textarea {
resize: vertical;
min-height: 80px;
font-family: 'Montserrat', sans-serif;
}
input[type="file"] {
width: 100%;
padding: 8px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-size: 0.75rem;
cursor: pointer;
}
input[type="color"] {
width: 100%;
height: 32px;
border: none;
border-radius: 6px;
cursor: pointer;
background: transparent;
}
input[type="range"] {
width: 100%;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
height: 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: #ff4605;
border-radius: 50%;
cursor: pointer;
border: 2px solid #ffffff;
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
background: #ff4605;
border-radius: 50%;
cursor: pointer;
border: 2px solid #ffffff;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 8px;
border-radius: 4px;
}
input[type="range"]::-moz-range-track {
height: 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
select {
width: 100%;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-size: 0.8rem;
cursor: pointer;
}
select option {
background: #1e293b;
color: white;
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 12px 0;
}
.hint-text {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.5);
margin-top: 4px;
font-style: italic;
}
.color-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.color-row .control-group {
margin-bottom: 10px;
}
.color-row label {
font-size: 0.7rem;
}
.star-rating {
display: flex;
gap: 5px;
margin-top: 5px;
}
.star-btn {
width: 36px;
height: 36px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
cursor: pointer;
font-size: 1.2rem;
transition: all 0.2s;
}
.star-btn.active {
background: #ff4605;
}
.star-btn:hover {
background: rgba(255, 70, 5, 0.5);
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.checkbox-group input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.checkbox-group label {
font-size: 0.8rem;
margin: 0;
cursor: pointer;
}
#preview-stage {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
#preview-canvas {
max-width: 100%;
max-height: 100%;
aspect-ratio: 1 / 1;
display: block;
border-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.right-panel .section-title {
margin-top: 0;
}
.preset-btn {
width: 100%;
padding: 10px;
margin-bottom: 6px;
text-align: left;
font-size: 0.75rem;
}
.preset-preview {
width: 30px;
height: 30px;
border-radius: 6px;
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.slider-value {
font-size: 0.75rem;
color: rgba(255,255,255,0.7);
margin-left: 8px;
}
.inline-label {
display: flex;
align-items: center;
justify-content: space-between;
}
.guide-steps {
display: flex;
flex-direction: column;
gap: 10px;
}
.guide-step {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.step-number {
width: 28px;
height: 28px;
background: #ff4605;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
flex-shrink: 0;
}
.step-text {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
line-height: 1.4;
}
</style>
</head>
<body>
<div class="app">
<div class="header-row">
<div class="header-left">
<h1>
<span class="title-orange">Customer Review</span>
<span class="title-white">Generator</span>
</h1>
<p class="subtitle">Create stunning review graphics for social media in seconds!</p>
</div>
</div>
<div class="content-wrapper">
<!-- LEFT PANEL - Inputs & Controls -->
<div class="panel left-panel">
<div class="section-title">📝 Review Content</div>
<div class="control-group">
<label>Review Text</label>
<textarea id="review-text" placeholder="Enter the customer review here...">Fantastic experience from start to finish! The team helped me find the perfect car within my budget. No pressure sales, just honest advice. Drove away in my new car the same day. Highly recommend!</textarea>
</div>
<div class="control-group">
<label>Customer Name (optional)</label>
<input type="text" id="customer-name" placeholder="John D." value="Sarah M.">
</div>
<div class="control-group">
<label>Star Rating</label>
<div class="star-rating">
<button class="star-btn active" data-rating="1">★</button>
<button class="star-btn active" data-rating="2">★</button>
<button class="star-btn active" data-rating="3">★</button>
<button class="star-btn active" data-rating="4">★</button>
<button class="star-btn active" data-rating="5">★</button>
</div>
</div>
<div class="control-group">
<label>Date (optional)</label>
<input type="text" id="review-date" placeholder="January 2025" value="March 2026">
</div>
<div class="divider"></div>
<div class="section-title">🖼️ Branding</div>
<div class="control-group">
<label>Header Label</label>
<input type="text" id="header-label" placeholder="Customer Review" value="Customer Review">
</div>
<div class="control-group">
<label>Dealership Logo (top right)</label>
<input type="file" id="logo-upload" accept="image/*">
<div class="hint-text">Recommended: transparent PNG</div>
</div>
<div class="control-group">
<label class="inline-label">Logo Scale <span class="slider-value" id="logo-scale-val">120%</span></label>
<input type="range" id="logo-scale" min="50" max="200" value="120">
</div>
<div class="divider"></div>
<div class="section-title">🔤 Font Sizes</div>
<div class="control-group">
<label class="inline-label">Review Text Size <span class="slider-value" id="review-size-val">48px</span></label>
<input type="range" id="review-font-size" min="28" max="72" value="48">
</div>
<div class="control-group">
<label class="inline-label">Name Size <span class="slider-value" id="name-size-val">32px</span></label>
<input type="range" id="name-font-size" min="20" max="48" value="32">
</div>
<div class="divider"></div>
<div class="section-title">🌄 Background</div>
<div class="control-group">
<label>Background Type</label>
<select id="bg-type">
<option value="solid">Solid Colour</option>
<option value="gradient">Gradient</option>
<option value="image" selected>Image</option>
</select>
</div>
<div class="control-row" id="solid-controls" style="display: none;">
<div class="control-group">
<label>Background Colour</label>
<input type="color" id="bg-color" value="#1e293b">
</div>
</div>
<div class="color-row" id="gradient-controls" style="display: none;">
<div class="control-group">
<label>Gradient Start</label>
<input type="color" id="gradient-start" value="#1e293b">
</div>
<div class="control-group">
<label>Gradient End</label>
<input type="color" id="gradient-end" value="#0f172a">
</div>
</div>
<div id="image-controls">
<div class="control-group">
<label>Background Image</label>
<input type="file" id="bg-image-upload" accept="image/*">
</div>
<div class="control-group">
<label class="inline-label">Image Opacity <span class="slider-value" id="bg-opacity-val">40%</span></label>
<input type="range" id="bg-opacity" min="10" max="100" value="40">
</div>
<div class="checkbox-group">
<input type="checkbox" id="bg-blur">
<label for="bg-blur">Apply blur to background</label>
</div>
<div class="control-group">
<label>Overlay Colour</label>
<input type="color" id="bg-overlay" value="#1e293b">
</div>
</div>
<div class="divider"></div>
<div class="section-title">🌐 Footer Info</div>
<div class="control-group">
<label>Website</label>
<input type="text" id="footer-website" placeholder="www.yoursite.com" value="www.nivehiclesales.com">
</div>
</div>
<!-- MIDDLE PANEL - Preview -->
<div class="panel middle-panel">
<div class="section-title">🖥️ Live Preview</div>
<div id="preview-stage">
<canvas id="preview-canvas" width="1080" height="1080"></canvas>
</div>
</div>
<!-- RIGHT PANEL - Export & Presets -->
<div class="panel right-panel">
<button class="btn btn-export" id="export-btn-side" style="width: 100%; margin-bottom: 20px;">📥 Download</button>
<div class="divider"></div>
<div class="section-title">🎨 Quick Presets</div>
<button class="btn btn-secondary preset-btn" data-preset="dark">
<span class="preset-preview" style="background: linear-gradient(135deg, #1e293b, #0f172a);"></span>
Dark Mode
</button>
<button class="btn btn-secondary preset-btn" data-preset="light">
<span class="preset-preview" style="background: linear-gradient(135deg, #f8fafc, #e2e8f0);"></span>
Light Mode
</button>
<button class="btn btn-secondary preset-btn" data-preset="orange">
<span class="preset-preview" style="background: linear-gradient(135deg, #ff4605, #c2410c);"></span>
NIVS Orange
</button>
<button class="btn btn-secondary preset-btn" data-preset="red">
<span class="preset-preview" style="background: linear-gradient(135deg, #dc2626, #7f1d1d);"></span>
Racing Red
</button>
<button class="btn btn-secondary preset-btn" data-preset="blue">
<span class="preset-preview" style="background: linear-gradient(135deg, #1e40af, #1e3a8a);"></span>
Professional Blue
</button>
<button class="btn btn-secondary preset-btn" data-preset="green">
<span class="preset-preview" style="background: linear-gradient(135deg, #166534, #14532d);"></span>
Trust Green
</button>
<div class="divider"></div>
<div class="section-title">🎨 Colours</div>
<div class="color-row">
<div class="control-group">
<label>Accent</label>
<input type="color" id="color-accent-right" value="#ff4605">
</div>
<div class="control-group">
<label>Text</label>
<input type="color" id="color-text-right" value="#ffffff">
</div>
</div>
<div class="color-row">
<div class="control-group">
<label>Header Text</label>
<input type="color" id="color-header-text-right" value="#ffffff">
</div>
<div class="control-group">
<label>Logo Box</label>
<input type="color" id="logo-box-color" value="#ffffff">
</div>
</div>
<div class="color-row">
<div class="control-group">
<label>Footer BG</label>
<input type="color" id="color-footer-bg-right" value="#ff4605">
</div>
<div class="control-group">
<label>Footer Text</label>
<input type="color" id="color-footer-text-right" value="#ffffff">
</div>
</div>
<div class="divider"></div>
<div class="section-title">🔤 Font</div>
<div class="control-group">
<label>Font Family</label>
<select id="font-family">
<option value="Montserrat" selected>Montserrat</option>
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Georgia">Georgia</option>
<option value="Verdana">Verdana</option>
<option value="Tahoma">Tahoma</option>
<option value="Trebuchet MS">Trebuchet MS</option>
<option value="Impact">Impact</option>
</select>
</div>
</div>
</div>
</div>
<script>
// Canvas setup
const CANVAS_SIZE = 1080;
const canvas = document.getElementById('preview-canvas');
const ctx = canvas.getContext('2d');
// State
let logoImage = null;
let bgImage = null;
let blurredBgCanvas = null;
let currentRating = 5;
// Applied state (rendered)
let applied = {
reviewText: '',
customerName: '',
reviewDate: '',
headerLabel: '',
rating: 5,
colorAccent: '#ff4605',
colorText: '#ffffff',
colorFooterBg: '#ff4605',
colorFooterText: '#ffffff',
colorHeaderText: '#ffffff',
reviewFontSize: 48,
nameFontSize: 32,
logoScale: 120,
logoBoxColor: '#ffffff',
fontFamily: 'Montserrat',
bgType: 'image',
bgColor: '#1e293b',
gradientStart: '#1e293b',
gradientEnd: '#0f172a',
bgOpacity: 40,
bgBlur: false,
bgOverlay: '#1e293b',
footerWebsite: '',
logoImage: null,
bgImage: null
};
// Default background image URL
const DEFAULT_BG_URL = 'https://images.pexels.com/photos/164634/pexels-photo-164634.jpeg';
let defaultBgImage = null;
let defaultBgFailed = false;
// Load default background image
function loadDefaultBgImage() {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
// Test if image can be used without tainting canvas
try {
const testCanvas = document.createElement('canvas');
testCanvas.width = 10;
testCanvas.height = 10;
const testCtx = testCanvas.getContext('2d');
testCtx.drawImage(img, 0, 0, 10, 10);
testCanvas.toDataURL(); // This will throw if tainted
defaultBgImage = img;
bgImage = img;
defaultBgFailed = false;
// Create blurred version for when blur is enabled
createBlurredBgFromImage(img);
resolve(true);
} catch (e) {
// Silently fall back to solid background
defaultBgFailed = true;
document.getElementById('bg-type').value = 'gradient';
applied.bgType = 'gradient';
resolve(false);
}
};
img.onerror = () => {
// Silently fall back to solid background
defaultBgFailed = true;
document.getElementById('bg-type').value = 'gradient';
applied.bgType = 'gradient';
resolve(false);
};
img.src = DEFAULT_BG_URL;
});
}
function createBlurredBgFromImage(img) {
if (!img) return;
blurredBgCanvas = document.createElement('canvas');
blurredBgCanvas.width = CANVAS_SIZE;
blurredBgCanvas.height = CANVAS_SIZE;
const blurCtx = blurredBgCanvas.getContext('2d');
// Draw scaled/cropped image
const scale = Math.max(CANVAS_SIZE / img.width, CANVAS_SIZE / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (CANVAS_SIZE - w) / 2;
const y = (CANVAS_SIZE - h) / 2;
blurCtx.filter = 'blur(20px)';
blurCtx.drawImage(img, x - 40, y - 40, w + 80, h + 80);
blurCtx.filter = 'none';
}
// Font loading state
let fontLoaded = false;
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners();
// Load default background image first
await loadDefaultBgImage();
// Wait for all fonts to be ready
await document.fonts.ready;
// Force load specific weights we use
const fontPromises = [
document.fonts.load('400 16px Montserrat'),
document.fonts.load('500 48px Montserrat'),
document.fonts.load('600 28px Montserrat'),
document.fonts.load('700 26px Montserrat'),
document.fonts.load('700 32px Montserrat'),
document.fonts.load('800 48px Montserrat')
];
await Promise.all(fontPromises);
fontLoaded = true;
// Initial render after fonts are ready
renderLive();
});
function setupEventListeners() {
// Export button
document.getElementById('export-btn-side').addEventListener('click', exportPNG);
// Star rating
document.querySelectorAll('.star-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentRating = parseInt(btn.dataset.rating);
updateStarDisplay();
renderLive();
});
});
// Logo upload
document.getElementById('logo-upload').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
logoImage = img;
renderLive();
};
img.src = evt.target.result;
};
reader.readAsDataURL(file);
}
});
// Background image upload
document.getElementById('bg-image-upload').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
bgImage = img;
createBlurredBg();
// Switch to image mode if user uploads
document.getElementById('bg-type').value = 'image';
updateBgControls();
renderLive();
};
img.src = evt.target.result;
};
reader.readAsDataURL(file);
}
});
// Background type toggle
document.getElementById('bg-type').addEventListener('change', () => {
updateBgControls();
renderLive();
});
// Slider value displays with live update
document.getElementById('review-font-size').addEventListener('input', (e) => {
document.getElementById('review-size-val').textContent = e.target.value + 'px';
renderLive();
});
document.getElementById('name-font-size').addEventListener('input', (e) => {
document.getElementById('name-size-val').textContent = e.target.value + 'px';
renderLive();
});
document.getElementById('bg-opacity').addEventListener('input', (e) => {
document.getElementById('bg-opacity-val').textContent = e.target.value + '%';
renderLive();
});
document.getElementById('logo-scale').addEventListener('input', (e) => {
document.getElementById('logo-scale-val').textContent = e.target.value + '%';
renderLive();
});
// Presets
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => applyPreset(btn.dataset.preset));
});
// Real-time updates for all text inputs
const liveInputs = [
'review-text', 'customer-name', 'review-date', 'header-label',
'color-accent-right', 'color-text-right', 'color-header-text-right',
'color-footer-bg-right', 'color-footer-text-right',
'bg-color', 'gradient-start', 'gradient-end', 'bg-overlay',
'footer-website', 'logo-box-color', 'font-family'
];
liveInputs.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('input', renderLive);
}
});
// Checkbox
document.getElementById('bg-blur').addEventListener('change', renderLive);
updateBgControls();
}
function renderLive() {
// Don't render until fonts are loaded
if (!fontLoaded) return;
// Read all inputs and render immediately
applied.reviewText = document.getElementById('review-text').value;
applied.customerName = document.getElementById('customer-name').value;
applied.reviewDate = document.getElementById('review-date').value;
applied.headerLabel = document.getElementById('header-label').value;
applied.rating = currentRating;
applied.colorAccent = document.getElementById('color-accent-right').value;
applied.colorText = document.getElementById('color-text-right').value;
applied.colorHeaderText = document.getElementById('color-header-text-right').value;
applied.colorFooterBg = document.getElementById('color-footer-bg-right').value;
applied.colorFooterText = document.getElementById('color-footer-text-right').value;
applied.reviewFontSize = parseInt(document.getElementById('review-font-size').value);
applied.nameFontSize = parseInt(document.getElementById('name-font-size').value);
applied.logoScale = parseInt(document.getElementById('logo-scale').value);
applied.logoBoxColor = document.getElementById('logo-box-color').value;
applied.fontFamily = document.getElementById('font-family').value;
applied.bgType = document.getElementById('bg-type').value;
applied.bgColor = document.getElementById('bg-color').value;
applied.gradientStart = document.getElementById('gradient-start').value;
applied.gradientEnd = document.getElementById('gradient-end').value;
applied.bgOpacity = parseInt(document.getElementById('bg-opacity').value);
applied.bgBlur = document.getElementById('bg-blur').checked;
applied.bgOverlay = document.getElementById('bg-overlay').value;
applied.footerWebsite = document.getElementById('footer-website').value;
applied.logoImage = logoImage;
applied.bgImage = bgImage;
render();
}
function updateStarDisplay() {
document.querySelectorAll('.star-btn').forEach(btn => {
const rating = parseInt(btn.dataset.rating);
btn.classList.toggle('active', rating <= currentRating);
});
}
function updateBgControls() {
const type = document.getElementById('bg-type').value;
document.getElementById('solid-controls').style.display = type === 'solid' ? 'block' : 'none';
document.getElementById('gradient-controls').style.display = type === 'gradient' ? 'grid' : 'none';
document.getElementById('image-controls').style.display = type === 'image' ? 'block' : 'none';
}
function createBlurredBg() {
createBlurredBgFromImage(bgImage);
}
function applyPreset(preset) {
const presets = {
dark: {
bgType: 'gradient',
gradientStart: '#1e293b',
gradientEnd: '#0f172a',
colorAccent: '#ff4605',
colorText: '#ffffff',
colorHeaderText: '#ffffff',
colorFooterBg: '#ff4605',
colorFooterText: '#ffffff'
},
light: {
bgType: 'gradient',
gradientStart: '#f8fafc',
gradientEnd: '#e2e8f0',
colorAccent: '#ff4605',
colorText: '#1e293b',
colorHeaderText: '#ffffff',
colorFooterBg: '#1e293b',
colorFooterText: '#ffffff'
},
orange: {
bgType: 'gradient',
gradientStart: '#ff4605',
gradientEnd: '#c2410c',
colorAccent: '#ffffff',
colorText: '#ffffff',
colorHeaderText: '#ff4605',
colorFooterBg: '#ffffff',
colorFooterText: '#ff4605'
},
red: {
bgType: 'gradient',
gradientStart: '#dc2626',
gradientEnd: '#7f1d1d',
colorAccent: '#ffffff',
colorText: '#ffffff',
colorHeaderText: '#dc2626',
colorFooterBg: '#ffffff',
colorFooterText: '#dc2626'
},
blue: {
bgType: 'gradient',
gradientStart: '#1e40af',
gradientEnd: '#1e3a8a',
colorAccent: '#60a5fa',
colorText: '#ffffff',
colorHeaderText: '#ffffff',
colorFooterBg: '#60a5fa',
colorFooterText: '#1e3a8a'
},
green: {
bgType: 'gradient',
gradientStart: '#166534',
gradientEnd: '#14532d',
colorAccent: '#4ade80',
colorText: '#ffffff',
colorHeaderText: '#ffffff',
colorFooterBg: '#4ade80',
colorFooterText: '#14532d'
}
};
const p = presets[preset];
if (!p) return;
document.getElementById('bg-type').value = p.bgType;
document.getElementById('gradient-start').value = p.gradientStart;
document.getElementById('gradient-end').value = p.gradientEnd;
document.getElementById('color-accent-right').value = p.colorAccent;
document.getElementById('color-text-right').value = p.colorText;
document.getElementById('color-header-text-right').value = p.colorHeaderText;
document.getElementById('color-footer-bg-right').value = p.colorFooterBg;
document.getElementById('color-footer-text-right').value = p.colorFooterText;
updateBgControls();
renderLive();
}
function render() {
const c = ctx;
const S = CANVAS_SIZE;
// Clear
c.clearRect(0, 0, S, S);
// Background
drawBackground(c);
// Main content card
drawContentCard(c);
// Logo in top right (drawn last to be on top)
drawLogo(c);
}
function drawBackground(c) {
const S = CANVAS_SIZE;
if (applied.bgType === 'solid') {
c.fillStyle = applied.bgColor;
c.fillRect(0, 0, S, S);
} else if (applied.bgType === 'gradient') {
const grad = c.createLinearGradient(0, 0, S, S);
grad.addColorStop(0, applied.gradientStart);
grad.addColorStop(1, applied.gradientEnd);
c.fillStyle = grad;
c.fillRect(0, 0, S, S);
} else if (applied.bgType === 'image' && applied.bgImage) {
const img = applied.bgImage;
const scale = Math.max(S / img.width, S / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (S - w) / 2;
const y = (S - h) / 2;
if (applied.bgBlur && blurredBgCanvas) {
c.globalAlpha = applied.bgOpacity / 100;
c.drawImage(blurredBgCanvas, 0, 0);
c.globalAlpha = 1;
} else {
c.globalAlpha = applied.bgOpacity / 100;
c.drawImage(img, x, y, w, h);
c.globalAlpha = 1;
}
// Overlay
c.fillStyle = applied.bgOverlay;
c.globalAlpha = 0.7;
c.fillRect(0, 0, S, S);
c.globalAlpha = 1;
} else {
const grad = c.createLinearGradient(0, 0, S, S);
grad.addColorStop(0, applied.gradientStart);
grad.addColorStop(1, applied.gradientEnd);
c.fillStyle = grad;
c.fillRect(0, 0, S, S);
}
}
function drawLogo(c) {
if (!applied.logoImage) return;
const S = CANVAS_SIZE;
const pad = 60;
const img = applied.logoImage;
const scale = applied.logoScale / 100;
// Logo box dimensions
const boxPad = 20;
const logoMaxW = 180 * scale;
const logoMaxH = 100 * scale;
// Calculate logo dimensions maintaining aspect ratio
const ratio = Math.min(logoMaxW / img.width, logoMaxH / img.height);
const logoW = img.width * ratio;
const logoH = img.height * ratio;
// Box dimensions - right edge aligned with content card right edge
const boxW = logoW + boxPad * 2;
const boxH = logoH + boxPad * 2;
const cardRightEdge = S - pad; // Content card right edge
const boxX = cardRightEdge - boxW;
const boxY = 0; // Flush to top edge
// Draw rounded box (with top edge at canvas top)
c.save();
c.fillStyle = applied.logoBoxColor;
c.shadowColor = 'rgba(0, 0, 0, 0.2)';
c.shadowBlur = 20;
c.shadowOffsetY = 5;
// Draw box with only bottom corners rounded
c.beginPath();
c.moveTo(boxX, boxY);
c.lineTo(boxX + boxW, boxY);
c.lineTo(boxX + boxW, boxY + boxH - 16);
c.quadraticCurveTo(boxX + boxW, boxY + boxH, boxX + boxW - 16, boxY + boxH);
c.lineTo(boxX + 16, boxY + boxH);
c.quadraticCurveTo(boxX, boxY + boxH, boxX, boxY + boxH - 16);
c.lineTo(boxX, boxY);
c.closePath();
c.fill();
c.restore();
// Draw logo centered in box
const logoX = boxX + (boxW - logoW) / 2;
const logoY = boxY + boxPad;
c.drawImage(img, logoX, logoY, logoW, logoH);
}
function drawContentCard(c) {
const S = CANVAS_SIZE;
const pad = 60;
const cardPad = 50;
// Semi-transparent content card
const cardX = pad;
const cardY = pad + 60;
const cardW = S - pad * 2;
const cardH = S - pad * 2 - 140;
// Card shadow
c.save();
c.shadowColor = 'rgba(0, 0, 0, 0.3)';
c.shadowBlur = 40;
c.shadowOffsetX = 0;
c.shadowOffsetY = 10;
c.fillStyle = 'rgba(255, 255, 255, 0.08)';
roundRect(c, cardX, cardY, cardW, cardH, 24);
c.fill();
c.restore();
// Card border glow
c.save();
c.strokeStyle = applied.colorAccent;
c.globalAlpha = 0.3;
c.lineWidth = 2;
roundRect(c, cardX, cardY, cardW, cardH, 24);
c.stroke();
c.restore();
// Header label (top-left inside card)
let yPos = cardY + cardPad;
yPos = drawHeader(c, cardX + cardPad, yPos, cardW - cardPad * 2);
// Accent divider line
c.save();
c.strokeStyle = applied.colorAccent;
c.globalAlpha = 0.4;
c.lineWidth = 3;
c.lineCap = 'round';
c.beginPath();
c.moveTo(cardX + cardPad, yPos + 25);
c.lineTo(cardX + cardPad + 80, yPos + 25);
c.stroke();
c.restore();
yPos += 50;
// Quote and review text
yPos = drawReview(c, cardX + cardPad, yPos, cardW - cardPad * 2, cardY + cardH - 120);
// Customer info with icon
drawCustomerInfo(c, cardX + cardPad, yPos + 20, cardW - cardPad * 2);
// Stars in bottom right of card (same padding as header label)
if (applied.rating > 0) {
drawStars(c, cardX + cardPad, cardY + cardH - cardPad - 44, cardW - cardPad * 2);
}
// Footer bar
drawFooter(c);
}
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.lineTo(x + w - r, y);
c.quadraticCurveTo(x + w, y, x + w, y + r);
c.lineTo(x + w, y + h - r);
c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
c.lineTo(x + r, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - r);
c.lineTo(x, y + r);
c.quadraticCurveTo(x, y, x + r, y);
c.closePath();
}
function drawHeader(c, x, y, width) {
// Draw header label with accent background pill (top-left)
if (applied.headerLabel) {
c.font = `700 26px ${applied.fontFamily}`;
const labelText = applied.headerLabel.toUpperCase();
const metrics = c.measureText(labelText);
const pillPadX = 24;
const pillPadY = 12;
const pillH = 48;
// Pill background
c.save();
c.fillStyle = applied.colorAccent;
roundRect(c, x, y, metrics.width + pillPadX * 2, pillH, pillH / 2);
c.fill();
c.restore();
// Label text
c.fillStyle = applied.colorHeaderText;
c.textAlign = 'left';
c.textBaseline = 'middle';
c.fillText(labelText, x + pillPadX, y + pillH / 2);
return y + pillH;
}
return y;
}
function drawStars(c, x, y, width) {
const starSize = 44;
const gap = 12;
const totalStars = 5;
const totalWidth = totalStars * starSize + (totalStars - 1) * gap;
// Position stars at right edge (aligned with cardPad from right)
let startX = x + width - totalWidth;
c.textBaseline = 'top';
for (let i = 0; i < totalStars; i++) {
const isFilled = i < applied.rating;
if (isFilled) {
// Filled star with glow
c.save();
c.shadowColor = applied.colorAccent;
c.shadowBlur = 15;
c.font = `${starSize}px sans-serif`;
c.fillStyle = applied.colorAccent;
c.fillText('★', startX + i * (starSize + gap), y);
c.restore();
} else {
// Empty star outline
c.font = `${starSize}px sans-serif`;
c.fillStyle = 'rgba(255, 255, 255, 0.2)';
c.fillText('★', startX + i * (starSize + gap), y);
}
}
return y + starSize;
}
function drawReview(c, x, y, width, maxY) {
// Opening quote mark - same padding from divider as header label from top
c.save();
c.font = 'bold 80px Georgia';
c.fillStyle = applied.colorAccent;
c.textAlign = 'left';
c.textBaseline = 'top';
c.fillText('"', x, y);
c.restore();
// Review text with elegant styling
c.font = `500 ${applied.reviewFontSize}px ${applied.fontFamily}`;
c.fillStyle = applied.colorText;
c.textAlign = 'left';
c.textBaseline = 'top';
const lineHeight = applied.reviewFontSize * 1.5;
const textX = x;
const textWidth = width;
const textY = y + 70; // Below quote mark
const availableHeight = maxY - textY - 50;
const maxLines = Math.floor(availableHeight / lineHeight);
const lines = wrapText(c, applied.reviewText, textWidth, maxLines);
lines.forEach((line, i) => {
// Subtle text shadow for depth
c.save();
c.shadowColor = 'rgba(0, 0, 0, 0.3)';
c.shadowBlur = 4;
c.shadowOffsetY = 2;
c.fillText(line, textX, textY + i * lineHeight);
c.restore();
});
// Closing quote mark
if (lines.length > 0) {
const lastLineWidth = c.measureText(lines[lines.length - 1]).width;
c.save();
c.font = 'bold 80px Georgia';
c.fillStyle = applied.colorAccent;
c.globalAlpha = 0.6;
c.fillText('"', textX + lastLineWidth + 10, textY + (lines.length - 1) * lineHeight - 20);
c.restore();
}
return textY + lines.length * lineHeight;
}
function wrapText(c, text, maxWidth, maxLines) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? currentLine + ' ' + word : word;
const metrics = c.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
if (lines.length >= maxLines) {
let lastLine = lines[lines.length - 1];
while (c.measureText(lastLine + '...').width > maxWidth && lastLine.length > 0) {
lastLine = lastLine.slice(0, -1);
}
lines[lines.length - 1] = lastLine + '...';
return lines;
}
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
function drawCustomerInfo(c, x, y, width) {
if (!applied.customerName && !applied.reviewDate) return;
// Name with dash prefix
let nameStartX = x;
if (applied.customerName) {
c.font = `700 ${applied.nameFontSize}px ${applied.fontFamily}`;
c.fillStyle = applied.colorText;
c.textAlign = 'left';
c.textBaseline = 'top';
const dashText = '- ';
const dashWidth = c.measureText(dashText).width;
c.fillText(dashText + applied.customerName, x, y);
nameStartX = x + dashWidth; // Position where the actual name starts (after dash)
}
// Date directly below, aligned with first letter of name
if (applied.reviewDate) {
c.font = `500 ${Math.round(applied.nameFontSize * 0.65)}px ${applied.fontFamily}`;
c.fillStyle = applied.colorText;
c.globalAlpha = 0.6;
c.textAlign = 'left';
c.textBaseline = 'top';
const dateY = applied.customerName ? y + applied.nameFontSize + 8 : y;
c.fillText(applied.reviewDate, nameStartX, dateY);
c.globalAlpha = 1;
}
}
function drawFooter(c) {
const S = CANVAS_SIZE;
const footerH = 90;
const footerY = S - footerH;
// Footer gradient background
const grad = c.createLinearGradient(0, footerY, 0, S);
grad.addColorStop(0, applied.colorFooterBg);
grad.addColorStop(1, shadeColor(applied.colorFooterBg, -20));
c.fillStyle = grad;
c.fillRect(0, footerY, S, footerH);
// Accent line at top of footer
c.fillStyle = applied.colorAccent;
c.fillRect(0, footerY, S, 4);
// Footer content - website only
if (applied.footerWebsite) {
c.font = `600 28px ${applied.fontFamily}`;
c.fillStyle = applied.colorFooterText;
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(applied.footerWebsite, S / 2, footerY + footerH / 2 + 2);
}
}
function shadeColor(color, percent) {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.max(0, Math.min(255, (num >> 16) + amt));
const G = Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) + amt));
const B = Math.max(0, Math.min(255, (num & 0x0000FF) + amt));
return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1);
}
function exportPNG() {
// Canvas is already rendered - just export it directly
const link = document.createElement('a');
link.download = `review-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NIVS Dealer Store</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #ffffff;
min-height: 100vh;
padding: 20px;
}
</style>
</head>
<body>
<div class="sr-element sr-products" data-embed="multiple_products">
<script type="application/json" data-config="embed">
{"publishable_key":"sr_live_pk_3390e45e20503328c4b5d97a36f56a4c706a","options":{"products_to_display":"all","categories":[],"image_dimension_value":"fit","image_aspect_ratio":"portrait","button_style":"standard","variation_style":"standard","open_product_in":"popup","button_position":"inline","product_default_sorting_order":"name_asc","product_pagination_limit":"15","desktop":{"items_per_row":"5"},"mobile":{"items_per_row":"3"},"hide_out_of_stock":"0"},"includes":{"show_search_box":"1","show_sort_by":"1","show_per_page":"0","show_category_dropdown":"1","show_currency_dropdown":"1","show_language_dropdown":"0","show_product_filters":"0","show_product_name":"1","show_product_price":"1","show_product_image":"1","show_product_summary":"0","open_modal_on_image_click":"1","show_view_product_button":"0","show_add_to_cart_button":"1","show_min_max_order_quantity":"0","show_sale":"1","show_free_shipping":"0","show_new_product":"0","show_digital_download":"0","show_pwyw":"0","image_swap":"1","show_button_icons":"1","mobile":{"show_search_box":"1","show_sort_by":"1","show_per_page":"1","show_category_dropdown":"1","show_currency_dropdown":"0","show_language_dropdown":"0","show_product_filters":"0"}},"product_popup":{"show_product_name":"1","show_product_price":"1","show_product_summary":"1","show_product_description":"1","show_product_image":"1","show_add_to_cart_button":"1","show_select_quantity":"0","show_image_thumbnails":"1","show_product_reviews":"1","show_product_sku":"1","show_product_categories":"1","show_social_sharing_icons":"0","show_related_products":"0","thumbnail_layout":"horizontal_below","image_dimension_value":"fit","image_aspect_ratio":"portrait","variation_styling":"dropdown","show_min_max_order_quantity":"0","show_sale":"1","show_free_shipping":"0","show_new_product":"0","show_digital_download":"0","show_pwyw":"0","show_product_tabs":"1","image_zoom":"1","lightbox_gallery":"1","show_stock":"0"},"styles":{"align_content":"center","product_title":"#314d5d","product_price":"#2d2d2d","product_summary":"#777777","button_background":"#ff4605","button_color":"#ffffff","view_product_button_background":"#222732","view_product_button_color":"#ffffff","view_cart_button_background":"#233642","view_cart_button_color":"#ffffff","product_background":"#ffffff","modal_background":"#ffffff","button_font_weight":"normal","popup":{"colors":{"product_title":"#333","product_price":"#666666","product_summary":"#666666","button_background":"#ff4605","button_color":"#ffffff","product_active_tab_background":"#f5f5f5"},"modal_image_width":"0.95","button_radius":"48"},"image_radius":0,"button_radius":"33","filters_background":"#ff4605","filters_text":"#ffffff","filters_font_weight":"bolder","filters_font_size":"15"}}
</script>
</div>
<script async src="https://cdn.shoprocket.io/loader.js"></script>
</body>
</html>