Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea04d3c89b |
@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
|||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -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/_base.css";
|
||||||
@import "tailwind/_components.css";
|
@import "tailwind/_components.css";
|
||||||
@import "tailwind/_utilities.css";
|
@import "tailwind/_utilities.css";
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/carousel-studio',
|
||||||
|
icon: icon.mdiViewCarouselOutline,
|
||||||
|
label: 'Carousel Studio',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
|
|||||||
755
frontend/src/pages/carousel-studio.tsx
Normal file
755
frontend/src/pages/carousel-studio.tsx
Normal 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;
|
||||||
@ -1,166 +1,157 @@
|
|||||||
|
import React, { ReactElement } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
mdiArrowRight,
|
||||||
|
mdiCloudDownloadOutline,
|
||||||
|
mdiCodeTags,
|
||||||
|
mdiGiftOutline,
|
||||||
|
mdiLogin,
|
||||||
|
mdiPaletteOutline,
|
||||||
|
mdiViewCarouselOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
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 Landing = () => {
|
||||||
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) {
|
|
||||||
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 (
|
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>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('Instagram Carousel Creator')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<main className="min-h-screen overflow-hidden bg-[#f8f5ff] text-slate-950" style={{ fontFamily: 'Inter, system-ui, sans-serif' }}>
|
||||||
<div
|
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-5">
|
||||||
className={`flex ${
|
<Link href="/" className="flex items-center gap-3 font-black tracking-tight text-slate-950">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#7c3aed] text-white shadow-lg shadow-violet-300/60">
|
||||||
} min-h-screen w-full`}
|
<BaseIcon path={mdiViewCarouselOutline} size={24} />
|
||||||
>
|
</span>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
Carousel Creator SaaS
|
||||||
? imageBlock(illustrationImage)
|
</Link>
|
||||||
: null}
|
<div className="flex items-center gap-3 text-sm font-semibold">
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<Link href="/projects/projects-list" className="hidden text-slate-600 hover:text-violet-700 md:inline">
|
||||||
? videoBlock(illustrationVideo)
|
Admin interface
|
||||||
: null}
|
</Link>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<BaseButton href="/login" label="Login" color="info" small icon={mdiLogin} />
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
</div>
|
||||||
<CardBoxComponentTitle title="Welcome to your Carousel Creator SaaS app!"/>
|
</nav>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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">
|
||||||
<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>
|
<div className="absolute -left-32 top-10 h-72 w-72 rounded-full bg-orange-300/30 blur-3xl" />
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
<div className="absolute -right-32 top-20 h-96 w-96 rounded-full bg-violet-400/30 blur-3xl" />
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<BaseButtons>
|
<div className="relative z-10">
|
||||||
<BaseButton
|
<div className="relative mx-auto max-w-md rounded-[2.25rem] bg-slate-950 p-4 shadow-2xl shadow-violet-300/60">
|
||||||
href='/login'
|
<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">
|
||||||
label='Login'
|
Export gated
|
||||||
color='info'
|
</div>
|
||||||
className='w-full'
|
<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>
|
||||||
|
|
||||||
</BaseButtons>
|
<section className="mx-auto max-w-7xl px-6 pb-16">
|
||||||
</CardBox>
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
</div>
|
{features.map((feature) => (
|
||||||
</div>
|
<CardBox key={feature.title} className="border-0 bg-white/85 shadow-xl shadow-violet-100/70">
|
||||||
</SectionFullScreen>
|
<span className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<BaseIcon path={feature.icon} size={24} />
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
</span>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<h3 className="text-lg font-black text-slate-950">{feature.title}</h3>
|
||||||
Privacy Policy
|
<p className="mt-2 text-sm leading-6 text-slate-600">{feature.text}</p>
|
||||||
</Link>
|
</CardBox>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
<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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default Landing;
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user