From 96ee5efde742b2624ed0a4c8925f94ce6b3152ae Mon Sep 17 00:00:00 2001 From: fezcode Date: Tue, 3 Feb 2026 01:32:24 +0300 Subject: [PATCH 001/149] feat: new quotes app --- public/apps/apps.json | 8 + src/app/QuoteGenerator/QuoteGeneratorApp.jsx | 103 +++++++ .../components/CanvasPreview.jsx | 252 ++++++++++++++++ .../components/ControlPanel.jsx | 274 ++++++++++++++++++ src/components/AnimatedRoutes.jsx | 21 ++ src/pages/apps/QuoteGeneratorPage.jsx | 42 +++ 6 files changed, 700 insertions(+) create mode 100644 src/app/QuoteGenerator/QuoteGeneratorApp.jsx create mode 100644 src/app/QuoteGenerator/components/CanvasPreview.jsx create mode 100644 src/app/QuoteGenerator/components/ControlPanel.jsx create mode 100644 src/pages/apps/QuoteGeneratorPage.jsx diff --git a/public/apps/apps.json b/public/apps/apps.json index 77a96e9b4..304280ada 100644 --- a/public/apps/apps.json +++ b/public/apps/apps.json @@ -432,6 +432,14 @@ "icon": "MagicWandIcon", "order": 3, "apps": [ + { + "slug": "quote-generator", + "to": "/apps/quote-generator", + "title": "Quote Generator", + "description": "Create beautiful quote images with customizable themes, fonts, and colors.", + "icon": "QuotesIcon", + "created_at": "2026-02-03T01:30:00+03:00" + }, { "slug": "github-thumbnail-generator", "to": "/apps/github-thumbnail-generator", diff --git a/src/app/QuoteGenerator/QuoteGeneratorApp.jsx b/src/app/QuoteGenerator/QuoteGeneratorApp.jsx new file mode 100644 index 000000000..a4a332f6d --- /dev/null +++ b/src/app/QuoteGenerator/QuoteGeneratorApp.jsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react'; +import { useToast } from '../../hooks/useToast'; +import CanvasPreview from './components/CanvasPreview'; +import ControlPanel from './components/ControlPanel'; +import { DownloadSimpleIcon } from '@phosphor-icons/react'; + +const QuoteGeneratorApp = () => { + const { addToast } = useToast(); + + // State + const [state, setState] = useState({ + text: "The only way to deal with an unfree world is to become so absolutely free that your very existence is an act of rebellion.", + author: "Albert Camus", + width: 1080, + height: 1080, // Square by default, maybe customizable later + backgroundColor: '#ffffff', + textColor: '#000000', + fontFamily: 'Inter', + fontSize: 48, + fontWeight: 800, + textAlign: 'left', + padding: 80, + lineHeight: 1.2, + backgroundImage: null, + overlayOpacity: 0, + overlayColor: '#000000', + themeType: 'standard', // 'standard', 'wordbox', 'typewriter' + }); + + const [triggerDownload, setTriggerDownload] = useState(false); + + const updateState = (newState) => { + setState((prev) => ({ ...prev, ...newState })); + }; + + // Font Loading + useEffect(() => { + const fonts = [ + 'Inter:wght@400;700;900', + 'Playfair+Display:ital,wght@0,400;0,700;1,400', + 'Cinzel:wght@400;700', + 'Caveat:wght@400;700', + 'Oswald:wght@400;700', + 'Lora:ital,wght@0,400;0,700;1,400', + 'Montserrat:wght@400;700;900', + 'Space+Mono:ital,wght@0,400;0,700;1,400', + 'UnifrakturMaguntia' + ]; + + const link = document.createElement('link'); + link.href = `https://fonts.googleapis.com/css2?family=${fonts.join('&')}&display=swap`; + link.rel = 'stylesheet'; + document.head.appendChild(link); + + return () => { + document.head.removeChild(link); + }; + }, []); + + const handleDownload = (dataUrl) => { + const link = document.createElement('a'); + link.download = `quote-${Date.now()}.png`; + link.href = dataUrl; + link.click(); + setTriggerDownload(false); + + addToast({ + title: 'Quote Downloaded', + message: 'Your quote has been saved successfully.', + type: 'success' + }); + }; + + return ( +
+ {/* Controls */} +
+ +
+ + {/* Preview */} +
+ + +
+ +
+
+
+ ); +}; + +export default QuoteGeneratorApp; \ No newline at end of file diff --git a/src/app/QuoteGenerator/components/CanvasPreview.jsx b/src/app/QuoteGenerator/components/CanvasPreview.jsx new file mode 100644 index 000000000..0031f8f5c --- /dev/null +++ b/src/app/QuoteGenerator/components/CanvasPreview.jsx @@ -0,0 +1,252 @@ +import React, { useRef, useEffect, useCallback } from 'react'; + +const CanvasPreview = ({ + text, + author, + width, + height, + backgroundColor, + textColor, + fontFamily, + fontSize, + fontWeight, + textAlign, + padding, + lineHeight, + backgroundImage, + overlayOpacity, + overlayColor, + themeType, // 'standard', 'wordbox', 'outline', 'newspaper' + onDownload, + triggerDownload // boolean to trigger download effect +}) => { + const canvasRef = useRef(null); + + // Helper to wrap text + const getWrappedLines = (ctx, text, maxWidth) => { + const words = text.split(' '); + const lines = []; + let currentLine = words[0]; + + for (let i = 1; i < words.length; i++) { + const word = words[i]; + const width = ctx.measureText(currentLine + " " + word).width; + if (width < maxWidth) { + currentLine += " " + word; + } else { + lines.push(currentLine); + currentLine = word; + } + } + lines.push(currentLine); + return lines; + }; + + const drawImageCover = useCallback((ctx, img, w, h) => { + const imgRatio = img.width / img.height; + const canvasRatio = w / h; + let renderW, renderH, offsetX, offsetY; + + if (imgRatio > canvasRatio) { + renderH = h; + renderW = h * imgRatio; + offsetX = (w - renderW) / 2; + offsetY = 0; + } else { + renderW = w; + renderH = w / imgRatio; + offsetX = 0; + offsetY = (h - renderH) / 2; + } + ctx.drawImage(img, offsetX, offsetY, renderW, renderH); + }, []); + + const drawNewspaperBg = useCallback((ctx, w, h) => { + // Clear canvas for transparency around torn edges + ctx.clearRect(0, 0, w, h); + + const pad = 60; // Padding from canvas edge for the paper + + ctx.beginPath(); + ctx.moveTo(pad, pad); + + // Top edge (ragged) + for (let x = pad; x <= w - pad; x += 5) { + ctx.lineTo(x, pad + (Math.random() - 0.5) * 8); + } + + // Right edge (ragged) + for (let y = pad; y <= h - pad; y += 5) { + ctx.lineTo(w - pad + (Math.random() - 0.5) * 8, y); + } + + // Bottom edge (ragged) + for (let x = w - pad; x >= pad; x -= 5) { + ctx.lineTo(x, h - pad + (Math.random() - 0.5) * 8); + } + + // Left edge (ragged) + for (let y = h - pad; y >= pad; y -= 5) { + ctx.lineTo(pad + (Math.random() - 0.5) * 8, y); + } + ctx.closePath(); + + // Shadow for depth (Outer glow) + ctx.save(); + ctx.shadowColor = "rgba(0,0,0,0.6)"; + ctx.shadowBlur = 25; + ctx.shadowOffsetX = 10; + ctx.shadowOffsetY = 15; + ctx.fillStyle = backgroundColor; + ctx.fill(); + ctx.restore(); + + // 1. Apply Texture (Noise) to the filled shape + const imageData = ctx.getImageData(0, 0, w, h); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + // Only apply noise where alpha > 0 (inside the filled shape) + if (data[i+3] > 0) { + const noise = (Math.random() - 0.5) * 20; + data[i] = Math.min(255, Math.max(0, data[i] + noise)); + data[i+1] = Math.min(255, Math.max(0, data[i+1] + noise)); + data[i+2] = Math.min(255, Math.max(0, data[i+2] + noise)); + } + } + ctx.putImageData(imageData, 0, 0); + + // 2. Aged Vignette + ctx.save(); + ctx.globalCompositeOperation = 'source-atop'; // Only draw on top of existing paper + const gradient = ctx.createRadialGradient(w/2, h/2, w/3, w/2, h/2, w*0.8); + gradient.addColorStop(0, "rgba(0,0,0,0)"); + gradient.addColorStop(1, "rgba(139, 69, 19, 0.2)"); // Sepia tint + ctx.fillStyle = gradient; + ctx.fillRect(0,0,w,h); + ctx.restore(); + }, [backgroundColor]); + + const drawContent = useCallback((ctx) => { + // 3. Text Configuration + ctx.fillStyle = textColor; + ctx.font = `${fontWeight} ${fontSize}px "${fontFamily}"`; + ctx.textBaseline = 'top'; + + const maxWidth = width - (padding * 2); + const lines = getWrappedLines(ctx, text, maxWidth); + const totalTextHeight = lines.length * (fontSize * lineHeight); + + // Vertical Center Calculation + let startY = (height - totalTextHeight) / 2; + + // Adjust if there is an author + if (author) { + startY -= (fontSize * 0.8); + } + + // Drawing Text + lines.forEach((line, index) => { + const lineWidth = ctx.measureText(line).width; + let x; + if (textAlign === 'center') x = (width - lineWidth) / 2; + else if (textAlign === 'right') x = width - padding - lineWidth; + else x = padding; + + const y = startY + (index * fontSize * lineHeight); + + if (themeType === 'wordbox') { + const bgPadding = fontSize * 0.2; + ctx.save(); + ctx.fillStyle = textColor; + ctx.fillRect(x - bgPadding, y - bgPadding, lineWidth + (bgPadding*2), (fontSize * lineHeight)); + + ctx.fillStyle = backgroundColor; + ctx.fillText(line, x, y); + ctx.restore(); + } else { + ctx.fillText(line, x, y); + } + }); + + // 4. Draw Author + if (author) { + const authorFontSize = fontSize * 0.5; + ctx.font = `italic ${fontWeight} ${authorFontSize}px "${fontFamily}"`; + const authorY = startY + totalTextHeight + (fontSize * 1.5); + const authorWidth = ctx.measureText("- " + author).width; + + let authorX; + if (textAlign === 'center') authorX = (width - authorWidth) / 2; + else if (textAlign === 'right') authorX = width - padding - authorWidth; + else authorX = padding; + + ctx.fillText("- " + author, authorX, authorY); + } + }, [textColor, fontWeight, fontSize, fontFamily, width, padding, text, lineHeight, height, author, textAlign, themeType, backgroundColor]); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + // 1. Setup Canvas + canvas.width = width; + canvas.height = height; + + // 2. Background + if (themeType === 'newspaper') { + drawNewspaperBg(ctx, width, height); + } else { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, width, height); + } + + if (backgroundImage) { + const img = new Image(); + img.src = backgroundImage; + if (img.complete) { + drawImageCover(ctx, img, width, height); + } else { + img.onload = () => { + drawImageCover(ctx, img, width, height); + drawContent(ctx); + } + } + } + + // Overlay + if (overlayOpacity > 0) { + ctx.fillStyle = overlayColor || '#000000'; + ctx.globalAlpha = overlayOpacity; + ctx.fillRect(0, 0, width, height); + ctx.globalAlpha = 1.0; + } + + drawContent(ctx); + }, [width, height, backgroundColor, backgroundImage, overlayOpacity, overlayColor, drawImageCover, drawContent, themeType, drawNewspaperBg]); + useEffect(() => { + draw(); + document.fonts.ready.then(draw); + }, [draw]); + + useEffect(() => { + if (triggerDownload && onDownload && canvasRef.current) { + onDownload(canvasRef.current.toDataURL('image/png')); + } + }, [triggerDownload, onDownload]); + + return ( +
+ +
+ ); +}; + +export default CanvasPreview; \ No newline at end of file diff --git a/src/app/QuoteGenerator/components/ControlPanel.jsx b/src/app/QuoteGenerator/components/ControlPanel.jsx new file mode 100644 index 000000000..712079e33 --- /dev/null +++ b/src/app/QuoteGenerator/components/ControlPanel.jsx @@ -0,0 +1,274 @@ +import React from 'react'; +import { + TextTIcon, + PaletteIcon, + TextAlignLeftIcon, TextAlignCenterIcon, + TextAlignRightIcon, + QuotesIcon +} from '@phosphor-icons/react'; +import CustomSlider from '../../../components/CustomSlider'; +import CustomColorPicker from '../../../components/CustomColorPicker'; +import CustomDropdown from '../../../components/CustomDropdown'; +import CustomToggle from '../../../components/CustomToggle'; + +const FONT_OPTIONS = [ + { value: 'Inter', label: 'Inter (Modern)' }, + { value: 'Space Mono', label: 'Space Mono' }, + { value: 'Playfair Display', label: 'Playfair Display (Serif)' }, + { value: 'Courier New', label: 'Courier New (Typewriter)' }, + { value: 'Cinzel', label: 'Cinzel (Fantasy)' }, + { value: 'Impact', label: 'Impact (Meme)' }, + { value: 'Caveat', label: 'Caveat (Handwritten)' }, + { value: 'Oswald', label: 'Oswald' }, + { value: 'Lora', label: 'Lora' }, + { value: 'Montserrat', label: 'Montserrat' }, + { value: 'UnifrakturMaguntia', label: 'UnifrakturMaguntia (Old English)' }, +]; + +const THEME_PRESETS = [ + { + name: 'Modern', + config: { + fontFamily: 'Inter', + backgroundColor: '#ffffff', + textColor: '#000000', + fontWeight: 800, + themeType: 'standard', + textAlign: 'left', + overlayOpacity: 0 + } + }, + { + name: 'Typewriter', + config: { + fontFamily: 'Courier New', + backgroundColor: '#f4f4f0', + textColor: '#333333', + fontWeight: 400, + themeType: 'typewriter', + textAlign: 'left', + overlayOpacity: 0 + } + }, + { + name: 'Genius', + config: { + fontFamily: 'Inter', + backgroundColor: '#000000', + textColor: '#ffffff', + fontWeight: 900, + themeType: 'standard', + textAlign: 'left', + overlayOpacity: 0 + } + }, + { + name: 'Pastoral', + config: { + fontFamily: 'Playfair Display', + backgroundColor: '#e3dcd2', + textColor: '#2c3e50', + fontWeight: 500, + themeType: 'standard', + textAlign: 'center', + overlayOpacity: 0 + } + }, + { + name: 'Highlighted', + config: { + fontFamily: 'Oswald', + backgroundColor: '#ffffff', // In WordBox mode, this is Text Color (inverted logic in render) + textColor: '#000000', // This is Box Color + fontWeight: 700, + themeType: 'wordbox', + textAlign: 'center', + overlayOpacity: 0 + } + }, + { + name: 'Newspaper', + config: { + fontFamily: 'Playfair Display', // Legible serif for body + backgroundColor: '#fdf6e3', // Aged paper + textColor: '#1a1a1a', // Dark grey ink + fontWeight: 700, + themeType: 'newspaper', + textAlign: 'left', + overlayOpacity: 0 + } + } +]; + +const ControlPanel = ({ state, updateState }) => { + const handleChange = (key, value) => { + updateState({ [key]: value }); + }; + + const applyPreset = (preset) => { + updateState({ ...state, ...preset.config }); + }; + + return ( +
+ + {/* Themes */} +
+

+ + Themes +

+
+ {THEME_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Content */} +
+

+ + Content +

+ +
+ +