From ea04d3c89b56f15eb33793f92583f1eade0e8383 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 4 Jun 2026 14:00:33 +0000 Subject: [PATCH] Inicial --- frontend/src/components/AsideMenuLayer.tsx | 3 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/css/main.css | 1 + frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 5 + frontend/src/pages/carousel-studio.tsx | 755 +++++++++++++++++++++ frontend/src/pages/index.tsx | 283 ++++---- frontend/src/pages/search.tsx | 4 +- 8 files changed, 902 insertions(+), 155 deletions(-) create mode 100644 frontend/src/pages/carousel-studio.tsx diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 961cf79..919e751 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -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'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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' diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index f061e28..534f6a8 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -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"; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 6289a06..07eebea 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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', diff --git a/frontend/src/pages/carousel-studio.tsx b/frontend/src/pages/carousel-studio.tsx new file mode 100644 index 0000000..92fb7f7 --- /dev/null +++ b/frontend/src/pages/carousel-studio.tsx @@ -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 = { + 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('bold'); + const [slides, setSlides] = useState(defaultSlides); + const [imageDataUrl, setImageDataUrl] = useState(''); + const [customCss, setCustomCss] = useState('.slide-card { letter-spacing: -0.02em; }'); + const [savedProjects, setSavedProjects] = useState([]); + 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>([]); + + 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) => { + 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 ( + <> + + {getPageTitle('Carousel Studio')} + + + + +
+ + +
+
+
+

+ HTML/CSS carousel builder +

+

+ Build a branded Instagram carousel in minutes. +

+

+ 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. +

+
+
1080 x 1350
+
PNG export
+
ZIP bundle
+
+
+ + +
+ + + +
+

Project setup

+

Template, font, colors, and image.

+
+
+ +
+ + +