Skip to content

Commit 96ee5ef

Browse files
committed
feat: new quotes app
1 parent 711529f commit 96ee5ef

File tree

6 files changed

+700
-0
lines changed

6 files changed

+700
-0
lines changed

public/apps/apps.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,14 @@
432432
"icon": "MagicWandIcon",
433433
"order": 3,
434434
"apps": [
435+
{
436+
"slug": "quote-generator",
437+
"to": "/apps/quote-generator",
438+
"title": "Quote Generator",
439+
"description": "Create beautiful quote images with customizable themes, fonts, and colors.",
440+
"icon": "QuotesIcon",
441+
"created_at": "2026-02-03T01:30:00+03:00"
442+
},
435443
{
436444
"slug": "github-thumbnail-generator",
437445
"to": "/apps/github-thumbnail-generator",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { useToast } from '../../hooks/useToast';
3+
import CanvasPreview from './components/CanvasPreview';
4+
import ControlPanel from './components/ControlPanel';
5+
import { DownloadSimpleIcon } from '@phosphor-icons/react';
6+
7+
const QuoteGeneratorApp = () => {
8+
const { addToast } = useToast();
9+
10+
// State
11+
const [state, setState] = useState({
12+
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.",
13+
author: "Albert Camus",
14+
width: 1080,
15+
height: 1080, // Square by default, maybe customizable later
16+
backgroundColor: '#ffffff',
17+
textColor: '#000000',
18+
fontFamily: 'Inter',
19+
fontSize: 48,
20+
fontWeight: 800,
21+
textAlign: 'left',
22+
padding: 80,
23+
lineHeight: 1.2,
24+
backgroundImage: null,
25+
overlayOpacity: 0,
26+
overlayColor: '#000000',
27+
themeType: 'standard', // 'standard', 'wordbox', 'typewriter'
28+
});
29+
30+
const [triggerDownload, setTriggerDownload] = useState(false);
31+
32+
const updateState = (newState) => {
33+
setState((prev) => ({ ...prev, ...newState }));
34+
};
35+
36+
// Font Loading
37+
useEffect(() => {
38+
const fonts = [
39+
'Inter:wght@400;700;900',
40+
'Playfair+Display:ital,wght@0,400;0,700;1,400',
41+
'Cinzel:wght@400;700',
42+
'Caveat:wght@400;700',
43+
'Oswald:wght@400;700',
44+
'Lora:ital,wght@0,400;0,700;1,400',
45+
'Montserrat:wght@400;700;900',
46+
'Space+Mono:ital,wght@0,400;0,700;1,400',
47+
'UnifrakturMaguntia'
48+
];
49+
50+
const link = document.createElement('link');
51+
link.href = `https://fonts.googleapis.com/css2?family=${fonts.join('&')}&display=swap`;
52+
link.rel = 'stylesheet';
53+
document.head.appendChild(link);
54+
55+
return () => {
56+
document.head.removeChild(link);
57+
};
58+
}, []);
59+
60+
const handleDownload = (dataUrl) => {
61+
const link = document.createElement('a');
62+
link.download = `quote-${Date.now()}.png`;
63+
link.href = dataUrl;
64+
link.click();
65+
setTriggerDownload(false);
66+
67+
addToast({
68+
title: 'Quote Downloaded',
69+
message: 'Your quote has been saved successfully.',
70+
type: 'success'
71+
});
72+
};
73+
74+
return (
75+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
76+
{/* Controls */}
77+
<div className="lg:col-span-4 h-fit lg:sticky lg:top-8">
78+
<ControlPanel state={state} updateState={updateState} />
79+
</div>
80+
81+
{/* Preview */}
82+
<div className="lg:col-span-8 space-y-4">
83+
<CanvasPreview
84+
{...state}
85+
onDownload={handleDownload}
86+
triggerDownload={triggerDownload}
87+
/>
88+
89+
<div className="flex justify-end">
90+
<button
91+
onClick={() => setTriggerDownload(true)}
92+
className="flex items-center gap-2 px-6 py-3 bg-white text-black hover:bg-primary-500 hover:text-white transition-all font-mono uppercase tracking-widest text-xs font-bold rounded-sm"
93+
>
94+
<DownloadSimpleIcon weight="bold" size={18} />
95+
<span>Download PNG</span>
96+
</button>
97+
</div>
98+
</div>
99+
</div>
100+
);
101+
};
102+
103+
export default QuoteGeneratorApp;
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import React, { useRef, useEffect, useCallback } from 'react';
2+
3+
const CanvasPreview = ({
4+
text,
5+
author,
6+
width,
7+
height,
8+
backgroundColor,
9+
textColor,
10+
fontFamily,
11+
fontSize,
12+
fontWeight,
13+
textAlign,
14+
padding,
15+
lineHeight,
16+
backgroundImage,
17+
overlayOpacity,
18+
overlayColor,
19+
themeType, // 'standard', 'wordbox', 'outline', 'newspaper'
20+
onDownload,
21+
triggerDownload // boolean to trigger download effect
22+
}) => {
23+
const canvasRef = useRef(null);
24+
25+
// Helper to wrap text
26+
const getWrappedLines = (ctx, text, maxWidth) => {
27+
const words = text.split(' ');
28+
const lines = [];
29+
let currentLine = words[0];
30+
31+
for (let i = 1; i < words.length; i++) {
32+
const word = words[i];
33+
const width = ctx.measureText(currentLine + " " + word).width;
34+
if (width < maxWidth) {
35+
currentLine += " " + word;
36+
} else {
37+
lines.push(currentLine);
38+
currentLine = word;
39+
}
40+
}
41+
lines.push(currentLine);
42+
return lines;
43+
};
44+
45+
const drawImageCover = useCallback((ctx, img, w, h) => {
46+
const imgRatio = img.width / img.height;
47+
const canvasRatio = w / h;
48+
let renderW, renderH, offsetX, offsetY;
49+
50+
if (imgRatio > canvasRatio) {
51+
renderH = h;
52+
renderW = h * imgRatio;
53+
offsetX = (w - renderW) / 2;
54+
offsetY = 0;
55+
} else {
56+
renderW = w;
57+
renderH = w / imgRatio;
58+
offsetX = 0;
59+
offsetY = (h - renderH) / 2;
60+
}
61+
ctx.drawImage(img, offsetX, offsetY, renderW, renderH);
62+
}, []);
63+
64+
const drawNewspaperBg = useCallback((ctx, w, h) => {
65+
// Clear canvas for transparency around torn edges
66+
ctx.clearRect(0, 0, w, h);
67+
68+
const pad = 60; // Padding from canvas edge for the paper
69+
70+
ctx.beginPath();
71+
ctx.moveTo(pad, pad);
72+
73+
// Top edge (ragged)
74+
for (let x = pad; x <= w - pad; x += 5) {
75+
ctx.lineTo(x, pad + (Math.random() - 0.5) * 8);
76+
}
77+
78+
// Right edge (ragged)
79+
for (let y = pad; y <= h - pad; y += 5) {
80+
ctx.lineTo(w - pad + (Math.random() - 0.5) * 8, y);
81+
}
82+
83+
// Bottom edge (ragged)
84+
for (let x = w - pad; x >= pad; x -= 5) {
85+
ctx.lineTo(x, h - pad + (Math.random() - 0.5) * 8);
86+
}
87+
88+
// Left edge (ragged)
89+
for (let y = h - pad; y >= pad; y -= 5) {
90+
ctx.lineTo(pad + (Math.random() - 0.5) * 8, y);
91+
}
92+
ctx.closePath();
93+
94+
// Shadow for depth (Outer glow)
95+
ctx.save();
96+
ctx.shadowColor = "rgba(0,0,0,0.6)";
97+
ctx.shadowBlur = 25;
98+
ctx.shadowOffsetX = 10;
99+
ctx.shadowOffsetY = 15;
100+
ctx.fillStyle = backgroundColor;
101+
ctx.fill();
102+
ctx.restore();
103+
104+
// 1. Apply Texture (Noise) to the filled shape
105+
const imageData = ctx.getImageData(0, 0, w, h);
106+
const data = imageData.data;
107+
for (let i = 0; i < data.length; i += 4) {
108+
// Only apply noise where alpha > 0 (inside the filled shape)
109+
if (data[i+3] > 0) {
110+
const noise = (Math.random() - 0.5) * 20;
111+
data[i] = Math.min(255, Math.max(0, data[i] + noise));
112+
data[i+1] = Math.min(255, Math.max(0, data[i+1] + noise));
113+
data[i+2] = Math.min(255, Math.max(0, data[i+2] + noise));
114+
}
115+
}
116+
ctx.putImageData(imageData, 0, 0);
117+
118+
// 2. Aged Vignette
119+
ctx.save();
120+
ctx.globalCompositeOperation = 'source-atop'; // Only draw on top of existing paper
121+
const gradient = ctx.createRadialGradient(w/2, h/2, w/3, w/2, h/2, w*0.8);
122+
gradient.addColorStop(0, "rgba(0,0,0,0)");
123+
gradient.addColorStop(1, "rgba(139, 69, 19, 0.2)"); // Sepia tint
124+
ctx.fillStyle = gradient;
125+
ctx.fillRect(0,0,w,h);
126+
ctx.restore();
127+
}, [backgroundColor]);
128+
129+
const drawContent = useCallback((ctx) => {
130+
// 3. Text Configuration
131+
ctx.fillStyle = textColor;
132+
ctx.font = `${fontWeight} ${fontSize}px "${fontFamily}"`;
133+
ctx.textBaseline = 'top';
134+
135+
const maxWidth = width - (padding * 2);
136+
const lines = getWrappedLines(ctx, text, maxWidth);
137+
const totalTextHeight = lines.length * (fontSize * lineHeight);
138+
139+
// Vertical Center Calculation
140+
let startY = (height - totalTextHeight) / 2;
141+
142+
// Adjust if there is an author
143+
if (author) {
144+
startY -= (fontSize * 0.8);
145+
}
146+
147+
// Drawing Text
148+
lines.forEach((line, index) => {
149+
const lineWidth = ctx.measureText(line).width;
150+
let x;
151+
if (textAlign === 'center') x = (width - lineWidth) / 2;
152+
else if (textAlign === 'right') x = width - padding - lineWidth;
153+
else x = padding;
154+
155+
const y = startY + (index * fontSize * lineHeight);
156+
157+
if (themeType === 'wordbox') {
158+
const bgPadding = fontSize * 0.2;
159+
ctx.save();
160+
ctx.fillStyle = textColor;
161+
ctx.fillRect(x - bgPadding, y - bgPadding, lineWidth + (bgPadding*2), (fontSize * lineHeight));
162+
163+
ctx.fillStyle = backgroundColor;
164+
ctx.fillText(line, x, y);
165+
ctx.restore();
166+
} else {
167+
ctx.fillText(line, x, y);
168+
}
169+
});
170+
171+
// 4. Draw Author
172+
if (author) {
173+
const authorFontSize = fontSize * 0.5;
174+
ctx.font = `italic ${fontWeight} ${authorFontSize}px "${fontFamily}"`;
175+
const authorY = startY + totalTextHeight + (fontSize * 1.5);
176+
const authorWidth = ctx.measureText("- " + author).width;
177+
178+
let authorX;
179+
if (textAlign === 'center') authorX = (width - authorWidth) / 2;
180+
else if (textAlign === 'right') authorX = width - padding - authorWidth;
181+
else authorX = padding;
182+
183+
ctx.fillText("- " + author, authorX, authorY);
184+
}
185+
}, [textColor, fontWeight, fontSize, fontFamily, width, padding, text, lineHeight, height, author, textAlign, themeType, backgroundColor]);
186+
187+
const draw = useCallback(() => {
188+
const canvas = canvasRef.current;
189+
if (!canvas) return;
190+
const ctx = canvas.getContext('2d');
191+
192+
// 1. Setup Canvas
193+
canvas.width = width;
194+
canvas.height = height;
195+
196+
// 2. Background
197+
if (themeType === 'newspaper') {
198+
drawNewspaperBg(ctx, width, height);
199+
} else {
200+
ctx.fillStyle = backgroundColor;
201+
ctx.fillRect(0, 0, width, height);
202+
}
203+
204+
if (backgroundImage) {
205+
const img = new Image();
206+
img.src = backgroundImage;
207+
if (img.complete) {
208+
drawImageCover(ctx, img, width, height);
209+
} else {
210+
img.onload = () => {
211+
drawImageCover(ctx, img, width, height);
212+
drawContent(ctx);
213+
}
214+
}
215+
}
216+
217+
// Overlay
218+
if (overlayOpacity > 0) {
219+
ctx.fillStyle = overlayColor || '#000000';
220+
ctx.globalAlpha = overlayOpacity;
221+
ctx.fillRect(0, 0, width, height);
222+
ctx.globalAlpha = 1.0;
223+
}
224+
225+
drawContent(ctx);
226+
}, [width, height, backgroundColor, backgroundImage, overlayOpacity, overlayColor, drawImageCover, drawContent, themeType, drawNewspaperBg]);
227+
useEffect(() => {
228+
draw();
229+
document.fonts.ready.then(draw);
230+
}, [draw]);
231+
232+
useEffect(() => {
233+
if (triggerDownload && onDownload && canvasRef.current) {
234+
onDownload(canvasRef.current.toDataURL('image/png'));
235+
}
236+
}, [triggerDownload, onDownload]);
237+
238+
return (
239+
<div className="w-full flex justify-center items-center overflow-hidden bg-[#111] border border-white/10 rounded-lg p-4">
240+
<canvas
241+
ref={canvasRef}
242+
style={{
243+
maxWidth: '100%',
244+
height: 'auto',
245+
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
246+
}}
247+
/>
248+
</div>
249+
);
250+
};
251+
252+
export default CanvasPreview;

0 commit comments

Comments
 (0)