Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
ea04d3c89b Inicial 2026-06-04 14:00:33 +00:00
8 changed files with 902 additions and 155 deletions

View File

@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@400;600;700;800&family=Inter:wght@400;600;700;800;900&family=Montserrat:wght@400;600;700;800;900&family=Playfair+Display:wght@400;700;800&family=Space+Grotesk:wght@400;600;700&display=swap');
@import "tailwind/_base.css";
@import "tailwind/_components.css";
@import "tailwind/_utilities.css";

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/carousel-studio',
icon: icon.mdiViewCarouselOutline,
label: 'Carousel Studio',
},
{
href: '/users/users-list',

View File

@ -0,0 +1,755 @@
import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import {
mdiCheckCircleOutline,
mdiCloudDownloadOutline,
mdiContentSaveOutline,
mdiImagePlus,
mdiLogin,
mdiPackageVariantClosed,
mdiPaletteOutline,
mdiViewCarouselOutline,
} from '@mdi/js';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import BaseButtons from '../components/BaseButtons';
import CardBox from '../components/CardBox';
import LayoutGuest from '../layouts/Guest';
import NotificationBar from '../components/NotificationBar';
import { getPageTitle } from '../config';
type CarouselSlide = {
eyebrow: string;
title: string;
body: string;
};
type SavedProject = {
id: string;
title: string;
hook: string;
font: string;
brandColor: string;
accentColor: string;
template: TemplateKey;
imageDataUrl: string;
slides: CarouselSlide[];
customCss: string;
updatedAt: string;
};
type TemplateKey = 'bold' | 'editorial' | 'minimal';
const storageKey = 'carousel-studio-projects-v1';
const fontOptions = [
'Inter',
'Montserrat',
'Playfair Display',
'Space Grotesk',
'DM Sans',
'Bebas Neue',
];
const templateOptions: { key: TemplateKey; label: string; description: string }[] = [
{
key: 'bold',
label: 'Bold launch',
description: 'High contrast, punchy cover, strong CTA rhythm.',
},
{
key: 'editorial',
label: 'Editorial story',
description: 'Premium magazine feel for thought leadership.',
},
{
key: 'minimal',
label: 'Clean checklist',
description: 'Simple, airy slides for education and tips.',
},
];
const defaultSlides: CarouselSlide[] = [
{
eyebrow: '01 / Hook',
title: 'Stop designing carousels from scratch',
body: 'Turn a rough idea, brand color, font, and image into a polished swipe-ready sequence.',
},
{
eyebrow: '02 / Problem',
title: 'Design tools slow creators down',
body: 'Reusable HTML/CSS slide systems keep every post consistent without starting over each time.',
},
{
eyebrow: '03 / Process',
title: 'Choose a template, tune the story, export',
body: 'Pick a look, upload your visual, adjust copy and colors, then download each slide or the full bundle.',
},
{
eyebrow: '04 / Offer',
title: 'Built for agencies and solo creators',
body: 'Save project concepts, preview slides instantly, and keep downloads behind a member account.',
},
{
eyebrow: '05 / CTA',
title: 'Ship your next Instagram carousel today',
body: 'Save this draft, log in when ready, and export production-ready PNG files.',
},
];
const templateClassNames: Record<TemplateKey, string> = {
bold: 'bg-slate-950 text-white',
editorial: 'bg-[#fff7ed] text-slate-950',
minimal: 'bg-white text-slate-950',
};
const safeFileName = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || 'carousel-project';
const dataUrlToUint8Array = (dataUrl: string) => {
const base64 = dataUrl.split(',')[1] || '';
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
};
const makeCrcTable = () => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let crc = index;
for (let bit = 0; bit < 8; bit += 1) {
crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
table[index] = crc >>> 0;
}
return table;
};
const crcTable = makeCrcTable();
const crc32 = (data: Uint8Array) => {
let crc = 0xffffffff;
for (let index = 0; index < data.length; index += 1) {
crc = crcTable[(crc ^ data[index]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
};
const createZipBlob = (files: { name: string; data: Uint8Array }[]) => {
const encoder = new TextEncoder();
const localParts: Uint8Array[] = [];
const centralParts: Uint8Array[] = [];
let offset = 0;
files.forEach((file) => {
const nameBytes = encoder.encode(file.name);
const crc = crc32(file.data);
const localHeader = new Uint8Array(30 + nameBytes.length);
const localView = new DataView(localHeader.buffer);
localView.setUint32(0, 0x04034b50, true);
localView.setUint16(4, 20, true);
localView.setUint16(6, 0, true);
localView.setUint16(8, 0, true);
localView.setUint16(10, 0, true);
localView.setUint16(12, 0, true);
localView.setUint32(14, crc, true);
localView.setUint32(18, file.data.length, true);
localView.setUint32(22, file.data.length, true);
localView.setUint16(26, nameBytes.length, true);
localHeader.set(nameBytes, 30);
localParts.push(localHeader, file.data);
const centralHeader = new Uint8Array(46 + nameBytes.length);
const centralView = new DataView(centralHeader.buffer);
centralView.setUint32(0, 0x02014b50, true);
centralView.setUint16(4, 20, true);
centralView.setUint16(6, 20, true);
centralView.setUint16(8, 0, true);
centralView.setUint16(10, 0, true);
centralView.setUint16(12, 0, true);
centralView.setUint16(14, 0, true);
centralView.setUint32(16, crc, true);
centralView.setUint32(20, file.data.length, true);
centralView.setUint32(24, file.data.length, true);
centralView.setUint16(28, nameBytes.length, true);
centralView.setUint32(42, offset, true);
centralHeader.set(nameBytes, 46);
centralParts.push(centralHeader);
offset += localHeader.length + file.data.length;
});
const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
const endHeader = new Uint8Array(22);
const endView = new DataView(endHeader.buffer);
endView.setUint32(0, 0x06054b50, true);
endView.setUint16(8, files.length, true);
endView.setUint16(10, files.length, true);
endView.setUint32(12, centralSize, true);
endView.setUint32(16, offset, true);
return new Blob([...localParts, ...centralParts, endHeader], { type: 'application/zip' });
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
const CarouselStudio = () => {
const [title, setTitle] = useState('Creator Launch Carousel');
const [hook, setHook] = useState('A faster way to create Instagram carousels');
const [font, setFont] = useState('Inter');
const [brandColor, setBrandColor] = useState('#7c3aed');
const [accentColor, setAccentColor] = useState('#f97316');
const [template, setTemplate] = useState<TemplateKey>('bold');
const [slides, setSlides] = useState<CarouselSlide[]>(defaultSlides);
const [imageDataUrl, setImageDataUrl] = useState('');
const [customCss, setCustomCss] = useState('.slide-card { letter-spacing: -0.02em; }');
const [savedProjects, setSavedProjects] = useState<SavedProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [saveMessage, setSaveMessage] = useState('');
const [exportMessage, setExportMessage] = useState('');
const [isExporting, setIsExporting] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const slideRefs = useRef<Array<HTMLDivElement | null>>([]);
useEffect(() => {
const rawProjects = localStorage.getItem(storageKey);
const token = localStorage.getItem('token');
setIsLoggedIn(Boolean(token));
if (!rawProjects) return;
try {
const parsedProjects = JSON.parse(rawProjects) as SavedProject[];
setSavedProjects(parsedProjects);
setSelectedProjectId(parsedProjects[0]?.id || '');
} catch (error) {
console.error('Unable to read saved carousel projects', error);
}
}, []);
const selectedProject = useMemo(
() => savedProjects.find((project) => project.id === selectedProjectId),
[savedProjects, selectedProjectId],
);
const updateSlide = (index: number, key: keyof CarouselSlide, value: string) => {
setSlides((currentSlides) =>
currentSlides.map((slide, slideIndex) =>
slideIndex === index ? { ...slide, [key]: value } : slide,
),
);
};
const buildProjectPayload = (): SavedProject => ({
id: selectedProjectId || `carousel-${Date.now()}`,
title,
hook,
font,
brandColor,
accentColor,
template,
imageDataUrl,
slides,
customCss,
updatedAt: new Date().toISOString(),
});
const saveProject = () => {
if (!title.trim()) {
setSaveMessage('Add a project name before saving.');
return;
}
const payload = buildProjectPayload();
const nextProjects = [payload, ...savedProjects.filter((project) => project.id !== payload.id)];
localStorage.setItem(storageKey, JSON.stringify(nextProjects));
setSavedProjects(nextProjects);
setSelectedProjectId(payload.id);
setSaveMessage('Draft saved in this browser. Log in when you are ready to export.');
};
const loadProject = (project: SavedProject) => {
setTitle(project.title);
setHook(project.hook);
setFont(project.font);
setBrandColor(project.brandColor);
setAccentColor(project.accentColor);
setTemplate(project.template);
setImageDataUrl(project.imageDataUrl);
setSlides(project.slides);
setCustomCss(project.customCss);
setSelectedProjectId(project.id);
setSaveMessage(`Loaded ${project.title}.`);
};
const duplicateProject = () => {
const duplicatedProject: SavedProject = {
...buildProjectPayload(),
id: `carousel-${Date.now()}`,
title: `${title} remix`,
updatedAt: new Date().toISOString(),
};
const nextProjects = [duplicatedProject, ...savedProjects];
localStorage.setItem(storageKey, JSON.stringify(nextProjects));
setSavedProjects(nextProjects);
setSelectedProjectId(duplicatedProject.id);
setTitle(duplicatedProject.title);
setSaveMessage('Created a remix draft.');
};
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
setSaveMessage('Please upload a JPG, PNG, GIF, or WebP image.');
return;
}
const reader = new FileReader();
reader.onload = () => {
setImageDataUrl(String(reader.result || ''));
setSaveMessage('Image added to the carousel preview.');
};
reader.onerror = () => {
console.error('Image upload failed', reader.error);
setSaveMessage('Image upload failed. Please try another file.');
};
reader.readAsDataURL(file);
};
const ensureLoggedInForExport = () => {
if (isLoggedIn) return true;
setExportMessage('Download is gated. Save your draft, then log in to export the PNG or ZIP files.');
return false;
};
const captureSlide = async (index: number) => {
const element = slideRefs.current[index];
if (!element) throw new Error(`Slide ${index + 1} is not available for export.`);
const html2canvas = (await import('html2canvas')).default;
const canvas = await html2canvas(element, {
backgroundColor: null,
scale: 3,
useCORS: true,
logging: false,
});
return canvas.toDataURL('image/png');
};
const downloadSlide = async (index: number) => {
if (!ensureLoggedInForExport()) return;
try {
setIsExporting(true);
setExportMessage(`Rendering slide ${index + 1}...`);
const dataUrl = await captureSlide(index);
downloadBlob(
new Blob([dataUrlToUint8Array(dataUrl)], { type: 'image/png' }),
`${safeFileName(title)}-slide-${index + 1}.png`,
);
setExportMessage(`Slide ${index + 1} downloaded.`);
} catch (error) {
console.error('Slide export failed', error);
setExportMessage('Export failed. Please try again after the preview finishes rendering.');
} finally {
setIsExporting(false);
}
};
const downloadZip = async () => {
if (!ensureLoggedInForExport()) return;
try {
setIsExporting(true);
setExportMessage('Rendering all slides into a ZIP bundle...');
const files = await Promise.all(
slides.map(async (_slide, index) => {
const dataUrl = await captureSlide(index);
return {
name: `${safeFileName(title)}-slide-${index + 1}.png`,
data: dataUrlToUint8Array(dataUrl),
};
}),
);
downloadBlob(createZipBlob(files), `${safeFileName(title)}-slides.zip`);
setExportMessage('ZIP bundle downloaded.');
} catch (error) {
console.error('ZIP export failed', error);
setExportMessage('ZIP export failed. Please try again after the previews finish rendering.');
} finally {
setIsExporting(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Carousel Studio')}</title>
</Head>
<style jsx global>{`
${customCss}
`}</style>
<main className="min-h-screen bg-[#f8f5ff] text-slate-950">
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-5">
<Link href="/" className="flex items-center gap-3 font-black tracking-tight text-slate-950">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#7c3aed] text-white shadow-lg shadow-violet-300/60">
<BaseIcon path={mdiViewCarouselOutline} size={22} />
</span>
Carousel Studio
</Link>
<div className="flex items-center gap-3 text-sm font-semibold">
<Link href="/projects/projects-list" className="hidden text-slate-600 hover:text-violet-700 sm:inline">
Admin interface
</Link>
<BaseButton href="/login" label={isLoggedIn ? 'Account' : 'Login to export'} color="info" small icon={mdiLogin} />
</div>
</nav>
<section className="mx-auto grid max-w-7xl gap-6 px-6 pb-12 lg:grid-cols-[0.95fr_1.35fr]">
<div className="space-y-6">
<div className="rounded-[2rem] border border-violet-100 bg-white p-6 shadow-xl shadow-violet-100/70">
<p className="mb-3 inline-flex rounded-full bg-violet-100 px-3 py-1 text-xs font-bold uppercase tracking-[0.24em] text-violet-700">
HTML/CSS carousel builder
</p>
<h1 className="text-4xl font-black tracking-tight text-slate-950 md:text-5xl">
Build a branded Instagram carousel in minutes.
</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
Start without logging in, save your draft locally, upload a hero visual, choose a Google Font, then log in when you are ready to download PNG slides or a ZIP bundle.
</p>
<div className="mt-5 grid grid-cols-3 gap-3 text-center text-xs font-bold uppercase tracking-wide text-slate-500">
<div className="rounded-2xl bg-violet-50 p-3">1080 x 1350</div>
<div className="rounded-2xl bg-orange-50 p-3">PNG export</div>
<div className="rounded-2xl bg-emerald-50 p-3">ZIP bundle</div>
</div>
</div>
<CardBox className="border-0 bg-white shadow-xl shadow-violet-100/70">
<div className="mb-5 flex items-center gap-3">
<span className="rounded-2xl bg-violet-100 p-3 text-violet-700">
<BaseIcon path={mdiPaletteOutline} size={22} />
</span>
<div>
<h2 className="text-xl font-black text-slate-950">Project setup</h2>
<p className="text-sm text-slate-500">Template, font, colors, and image.</p>
</div>
</div>
<div className="space-y-4">
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-700">Project name</span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 outline-none ring-violet-200 transition focus:ring-4"
placeholder="Product launch carousel"
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-700">Main hook</span>
<textarea
value={hook}
onChange={(event) => setHook(event.target.value)}
className="h-24 w-full rounded-2xl border border-slate-200 px-4 py-3 outline-none ring-violet-200 transition focus:ring-4"
placeholder="What promise should the cover slide make?"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-700">Google Font</span>
<select
value={font}
onChange={(event) => setFont(event.target.value)}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 outline-none ring-violet-200 transition focus:ring-4"
>
{fontOptions.map((fontName) => (
<option key={fontName} value={fontName}>
{fontName}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-700">Template</span>
<select
value={template}
onChange={(event) => setTemplate(event.target.value as TemplateKey)}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 outline-none ring-violet-200 transition focus:ring-4"
>
{templateOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-700">Brand color</span>
<input
type="color"
value={brandColor}
onChange={(event) => setBrandColor(event.target.value)}
className="h-12 w-full cursor-pointer rounded-2xl border border-slate-200 bg-white p-1"
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-700">Accent color</span>
<input
type="color"
value={accentColor}
onChange={(event) => setAccentColor(event.target.value)}
className="h-12 w-full cursor-pointer rounded-2xl border border-slate-200 bg-white p-1"
/>
</label>
</div>
<label className="block rounded-3xl border border-dashed border-violet-300 bg-violet-50/70 p-5 text-center">
<input type="file" accept="image/*" className="sr-only" onChange={handleImageUpload} />
<span className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-violet-700 shadow-sm">
<BaseIcon path={mdiImagePlus} size={24} />
</span>
<span className="block text-sm font-black text-slate-800">Upload a product, portrait, or brand image</span>
<span className="text-xs text-slate-500">Used as the visual texture across the generated slides.</span>
</label>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-700">Optional CSS override</span>
<textarea
value={customCss}
onChange={(event) => setCustomCss(event.target.value)}
className="h-20 w-full rounded-2xl border border-slate-200 bg-slate-950 px-4 py-3 font-mono text-sm text-violet-100 outline-none ring-violet-200 transition focus:ring-4"
placeholder=".slide-card { letter-spacing: -0.02em; }"
/>
</label>
</div>
</CardBox>
<CardBox className="border-0 bg-white shadow-xl shadow-violet-100/70">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-black text-slate-950">Saved drafts</h2>
<p className="text-sm text-slate-500">Local project list for anonymous creation.</p>
</div>
<BaseButton label="Save draft" color="success" icon={mdiContentSaveOutline} onClick={saveProject} />
</div>
{saveMessage && (
<NotificationBar color="info" icon={mdiCheckCircleOutline}>
{saveMessage}
</NotificationBar>
)}
{savedProjects.length === 0 ? (
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-6 text-center text-sm text-slate-500">
No saved drafts yet. Create your first carousel above, then save it here.
</div>
) : (
<div className="space-y-3">
{savedProjects.map((project) => (
<button
key={project.id}
type="button"
onClick={() => loadProject(project)}
className={`w-full rounded-3xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${
selectedProjectId === project.id ? 'border-violet-300 bg-violet-50' : 'border-slate-200 bg-white'
}`}
>
<span className="block font-black text-slate-900">{project.title}</span>
<span className="mt-1 block text-xs text-slate-500">
{project.slides.length} slides {project.font} updated {new Date(project.updatedAt).toLocaleDateString()}
</span>
</button>
))}
</div>
)}
{selectedProject && (
<div className="mt-4 rounded-3xl bg-slate-950 p-4 text-white">
<p className="text-xs font-bold uppercase tracking-[0.2em] text-violet-200">Detail</p>
<p className="mt-1 font-black">{selectedProject.title}</p>
<p className="text-sm text-slate-300">{selectedProject.hook}</p>
<BaseButton className="mt-4" label="Duplicate detail" color="warning" small onClick={duplicateProject} />
</div>
)}
</CardBox>
</div>
<div className="space-y-6">
<CardBox className="border-0 bg-white shadow-xl shadow-violet-100/70">
<div className="mb-5 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-black text-slate-950">Live slide preview</h2>
<p className="text-sm text-slate-500">HTML/CSS previews rendered client-side for export.</p>
</div>
<BaseButtons mb="mb-0" className="justify-start md:justify-end">
<BaseButton
label={isExporting ? 'Exporting...' : 'Download ZIP'}
color="info"
icon={mdiPackageVariantClosed}
disabled={isExporting}
onClick={downloadZip}
/>
</BaseButtons>
</div>
{exportMessage && (
<NotificationBar color={isLoggedIn ? 'success' : 'warning'} icon={isLoggedIn ? mdiCloudDownloadOutline : mdiLogin}>
<span>
{exportMessage}{' '}
{!isLoggedIn && (
<Link href="/login" className="font-black underline">
Log in now
</Link>
)}
</span>
</NotificationBar>
)}
<div className="grid gap-5 xl:grid-cols-2">
{slides.map((slide, index) => (
<div key={`${slide.eyebrow}-${index}`} className="space-y-3">
<div
ref={(element) => {
slideRefs.current[index] = element;
}}
className={`slide-card relative aspect-[4/5] overflow-hidden rounded-[2rem] border border-slate-200 shadow-2xl shadow-slate-200 ${templateClassNames[template]}`}
style={{ fontFamily: `'${font}', system-ui, sans-serif` }}
>
<div
className="absolute inset-0 opacity-20"
style={{
background: `radial-gradient(circle at 20% 15%, ${accentColor}, transparent 30%), radial-gradient(circle at 90% 80%, ${brandColor}, transparent 36%)`,
}}
/>
{imageDataUrl ? (
<div
className="absolute inset-x-6 top-6 h-36 rounded-[1.5rem] bg-cover bg-center opacity-90 mix-blend-luminosity"
style={{ backgroundImage: `url(${imageDataUrl})` }}
/>
) : (
<div className="absolute inset-x-6 top-6 flex h-36 items-center justify-center rounded-[1.5rem] border border-dashed border-current/20 text-xs font-bold uppercase tracking-[0.22em] opacity-60">
Image zone
</div>
)}
<div className="relative flex h-full flex-col justify-between p-7 pt-48">
<div>
<div className="mb-4 inline-flex rounded-full px-3 py-1 text-xs font-black uppercase tracking-[0.2em] text-white" style={{ backgroundColor: brandColor }}>
{slide.eyebrow}
</div>
<h3 className="text-4xl font-black leading-[0.95] tracking-tight">{index === 0 ? hook : slide.title}</h3>
<p className="mt-4 text-base font-semibold leading-6 opacity-75">{slide.body}</p>
</div>
<div className="flex items-end justify-between gap-4 border-t border-current/10 pt-4">
<span className="text-xs font-black uppercase tracking-[0.25em] opacity-60">{title}</span>
<span className="h-10 w-10 rounded-full" style={{ backgroundColor: accentColor }} />
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-bold text-slate-500">Slide {index + 1}</span>
<BaseButton
label="Download PNG"
color="info"
outline
small
disabled={isExporting}
onClick={() => downloadSlide(index)}
/>
</div>
</div>
))}
</div>
</CardBox>
<CardBox className="border-0 bg-white shadow-xl shadow-violet-100/70">
<h2 className="mb-4 text-xl font-black text-slate-950">Slide copy editor</h2>
<div className="space-y-4">
{slides.map((slide, index) => (
<div key={`editor-${index}`} className="rounded-3xl border border-slate-200 p-4">
<p className="mb-3 text-sm font-black text-violet-700">Slide {index + 1}</p>
<div className="grid gap-3 md:grid-cols-3">
<input
value={slide.eyebrow}
onChange={(event) => updateSlide(index, 'eyebrow', event.target.value)}
className="rounded-2xl border border-slate-200 px-3 py-2 text-sm outline-none ring-violet-200 focus:ring-4"
placeholder="Eyebrow"
/>
<input
value={slide.title}
onChange={(event) => updateSlide(index, 'title', event.target.value)}
className="rounded-2xl border border-slate-200 px-3 py-2 text-sm outline-none ring-violet-200 focus:ring-4 md:col-span-2"
placeholder="Title"
/>
</div>
<textarea
value={slide.body}
onChange={(event) => updateSlide(index, 'body', event.target.value)}
className="mt-3 h-20 w-full rounded-2xl border border-slate-200 px-3 py-2 text-sm outline-none ring-violet-200 focus:ring-4"
placeholder="Supporting body copy"
/>
</div>
))}
</div>
</CardBox>
</div>
</section>
</main>
</>
);
};
CarouselStudio.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
export default CarouselStudio;

View File

@ -1,166 +1,157 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import React, { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import {
mdiArrowRight,
mdiCloudDownloadOutline,
mdiCodeTags,
mdiGiftOutline,
mdiLogin,
mdiPaletteOutline,
mdiViewCarouselOutline,
} from '@mdi/js';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const features = [
{
icon: mdiCodeTags,
title: 'HTML/CSS slides',
text: 'Compose carousel templates as real DOM blocks so every slide is styleable and consistent.',
},
{
icon: mdiPaletteOutline,
title: 'Brand controls',
text: 'Pick Google Fonts, brand colors, accents, and a hero image before exporting.',
},
{
icon: mdiCloudDownloadOutline,
title: 'Gated exports',
text: 'Visitors can draft freely, while downloads require a logged-in member account.',
},
{
icon: mdiGiftOutline,
title: 'Coupon-ready SaaS',
text: 'Admin CRUD already includes coupons, credits, templates, projects, slides, and assets.',
},
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Carousel Creator SaaS'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
const Landing = () => {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Instagram Carousel Creator')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Carousel Creator SaaS app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
<main className="min-h-screen overflow-hidden bg-[#f8f5ff] text-slate-950" style={{ fontFamily: 'Inter, system-ui, sans-serif' }}>
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-5">
<Link href="/" className="flex items-center gap-3 font-black tracking-tight text-slate-950">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#7c3aed] text-white shadow-lg shadow-violet-300/60">
<BaseIcon path={mdiViewCarouselOutline} size={24} />
</span>
Carousel Creator SaaS
</Link>
<div className="flex items-center gap-3 text-sm font-semibold">
<Link href="/projects/projects-list" className="hidden text-slate-600 hover:text-violet-700 md:inline">
Admin interface
</Link>
<BaseButton href="/login" label="Login" color="info" small icon={mdiLogin} />
</div>
</nav>
<section className="relative mx-auto grid max-w-7xl items-center gap-10 px-6 py-12 lg:grid-cols-[1.05fr_0.95fr] lg:py-20">
<div className="absolute -left-32 top-10 h-72 w-72 rounded-full bg-orange-300/30 blur-3xl" />
<div className="absolute -right-32 top-20 h-96 w-96 rounded-full bg-violet-400/30 blur-3xl" />
<div className="relative z-10">
<p className="mb-5 inline-flex rounded-full border border-violet-200 bg-white/70 px-4 py-2 text-xs font-black uppercase tracking-[0.24em] text-violet-700 shadow-sm">
Design-tool-free carousel production
</p>
<h1 className="max-w-4xl text-5xl font-black leading-[0.94] tracking-tight text-slate-950 md:text-7xl">
Create Instagram carousels with templates, fonts, images, and gated exports.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
A fast SaaS workflow for creators, agencies, and social media managers: start a project anonymously, preview real HTML/CSS slides, then log in to download individual PNGs or the full ZIP.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<BaseButton href="/carousel-studio" label="Open Carousel Studio" color="info" icon={mdiArrowRight} className="text-base" />
<BaseButton href="/login" label="Login / Admin" color="whiteDark" outline icon={mdiLogin} className="text-base" />
</div>
<div className="mt-8 grid max-w-2xl grid-cols-3 gap-3 text-center">
<div className="rounded-3xl bg-white/80 p-4 shadow-sm">
<p className="text-2xl font-black text-slate-950">5</p>
<p className="text-xs font-bold uppercase tracking-wide text-slate-500">starter slides</p>
</div>
<div className="rounded-3xl bg-white/80 p-4 shadow-sm">
<p className="text-2xl font-black text-slate-950">ZIP</p>
<p className="text-xs font-bold uppercase tracking-wide text-slate-500">bundle export</p>
</div>
<div className="rounded-3xl bg-white/80 p-4 shadow-sm">
<p className="text-2xl font-black text-slate-950">1080</p>
<p className="text-xs font-bold uppercase tracking-wide text-slate-500">post-ready ratio</p>
</div>
</div>
</div>
<div className="relative z-10">
<div className="relative mx-auto max-w-md rounded-[2.25rem] bg-slate-950 p-4 shadow-2xl shadow-violet-300/60">
<div className="absolute -right-6 -top-6 rounded-3xl bg-orange-400 px-5 py-4 text-sm font-black text-white shadow-xl rotate-6">
Export gated
</div>
<div className="aspect-[4/5] overflow-hidden rounded-[1.75rem] bg-white p-7 text-slate-950">
<div className="h-36 rounded-[1.5rem] bg-[radial-gradient(circle_at_20%_10%,#f97316,transparent_30%),radial-gradient(circle_at_80%_80%,#7c3aed,transparent_34%),linear-gradient(135deg,#ede9fe,#ffffff)]" />
<div className="mt-8 inline-flex rounded-full bg-violet-600 px-3 py-1 text-xs font-black uppercase tracking-[0.2em] text-white">
01 / Hook
</div>
<h2 className="mt-4 text-5xl font-black leading-[0.92] tracking-tight">
Stop designing from scratch.
</h2>
<p className="mt-5 text-base font-semibold leading-7 text-slate-600">
Use a brand kit, uploaded image, and editable slide copy to generate a consistent carousel.
</p>
<div className="mt-8 flex items-center justify-between border-t border-slate-200 pt-5">
<span className="text-xs font-black uppercase tracking-[0.25em] text-slate-400">Carousel Studio</span>
<span className="h-10 w-10 rounded-full bg-orange-400" />
</div>
</div>
</div>
</div>
</section>
<section className="mx-auto max-w-7xl px-6 pb-16">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{features.map((feature) => (
<CardBox key={feature.title} className="border-0 bg-white/85 shadow-xl shadow-violet-100/70">
<span className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-violet-100 text-violet-700">
<BaseIcon path={feature.icon} size={24} />
</span>
<h3 className="text-lg font-black text-slate-950">{feature.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">{feature.text}</p>
</CardBox>
))}
</div>
</section>
<footer className="border-t border-violet-100 bg-white/70 px-6 py-8">
<div className="mx-auto flex max-w-7xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
<p>© 2026 Carousel Creator SaaS. All rights reserved.</p>
<div className="flex gap-4 font-semibold">
<Link href="/privacy-policy" className="hover:text-violet-700">Privacy Policy</Link>
<Link href="/terms-of-use" className="hover:text-violet-700">Terms</Link>
<Link href="/login" className="hover:text-violet-700">Login</Link>
</div>
</div>
</footer>
</main>
</>
);
}
};
Starter.getLayout = function getLayout(page: ReactElement) {
Landing.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
export default Landing;

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated';