├── .eslintrc.json ├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── stickerimage.iml └── vcs.xml ├── README.md ├── app ├── [id] │ ├── og │ │ └── [image] │ │ │ ├── assets │ │ │ └── Inter-SemiBold.ttf │ │ │ └── route.tsx │ └── page.tsx ├── favicon.ico ├── globals.css ├── imprint │ └── page.tsx ├── latest │ └── page.tsx ├── layout.tsx ├── not-found.tsx └── page.tsx ├── components ├── Banner.tsx ├── Buttons.tsx ├── Confetti.tsx ├── Dialog.tsx ├── Icons.tsx ├── NewsletterDialog.tsx ├── Notification.tsx ├── SideInfo.tsx └── StickerPlacer.tsx ├── illustrations ├── Laptop.tsx └── Pattern.tsx ├── lib └── database.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── animations.riv ├── images │ ├── default-memoji.png │ ├── how-to-screenshot.jpg │ ├── laptop │ │ └── laptop.png │ ├── stickerimage-favicon.png │ ├── stickerimage-latest.jpg │ └── stickerimage-og.jpg └── sticker.json ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/stickerimage.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Sticker Image](public/images/stickerimage-og.jpg) 2 | 3 | # Open Source Laptop Sticker Maker 4 | 5 | Hi, this is Flo. This was a little project I shared on X, initially just as a design post. Many people wanted it, so I've built it. Hope you have fun using it. It took way longer than expected and I'm super exhausted the time I'm writing this. 6 | 7 | I would be incredibly happy if you share your custom stickers with me on Twitter [@flornkm](https://twitter.com/flornkm). It would also be great to get a Star here on GitHub. 8 | 9 | ## Tech Stack / Features 10 | 11 | - [x] TypeScript 12 | - [x] NextJS App Router 13 | - [x] React Draggable 14 | - [x] Serverless Functions 15 | - [x] Dynamic Routing and OG Image Generation 16 | 17 | > [!NOTE] 18 | > Any misusage of the app is not my responsibility. I'm not liable for any damages caused by the app. Please use it responsibly. 19 | 20 | If you have questions, encounter a bug or want to report misuse, please open an issue here on GitHub or write me an email to [hello@floriankiem.com](mailto:hello@floriankiem.com). 21 | -------------------------------------------------------------------------------- /app/[id]/og/[image]/assets/Inter-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flornkm/stickerimage/9a95b10095fba1763a55b7b48c3165538254d8f7/app/[id]/og/[image]/assets/Inter-SemiBold.ttf -------------------------------------------------------------------------------- /app/[id]/og/[image]/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og" 2 | import { NextRequest } from "next/server" 3 | 4 | export const runtime = "edge" 5 | 6 | export async function GET( 7 | _req: NextRequest, 8 | req: { params: { id: string; image: string } } 9 | ) { 10 | // Load font 11 | const interSemiBold = fetch( 12 | new URL("./assets/Inter-SemiBold.ttf", import.meta.url) 13 | ).then((res) => res.arrayBuffer()) 14 | 15 | return new ImageResponse( 16 | ( 17 |
31 |

Laptop Sticker Image

32 |

33 | ID: 34 | {req.params.id} 35 |

36 |
52 | {/* eslint-disable-next-line @next/next/no-img-element */} 53 | default 60 |
61 |

70 | Create your own at stickerimage.com 71 |

72 |
73 | ), 74 | { 75 | width: 1200, 76 | height: 600, 77 | fonts: [ 78 | { 79 | data: await interSemiBold, 80 | name: "Inter", 81 | style: "normal", 82 | weight: 600, 83 | }, 84 | ], 85 | } 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /app/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Buttons from "@/components/Buttons" 2 | import Confetti from "@/components/Confetti" 3 | import { ArrowLeft, Smiley } from "@/components/Icons" 4 | import { app } from "@/lib/database" 5 | import { getStorage, ref, getDownloadURL } from "firebase/storage" 6 | import Image from "next/image" 7 | import Link from "next/link" 8 | import { notFound } from "next/navigation" 9 | import type { Metadata } from "next" 10 | import NewsletterDialog from "@/components/NewsletterDialog" 11 | 12 | type Props = { 13 | params: { id: string } 14 | searchParams: { [key: string]: string | string[] | undefined } 15 | } 16 | 17 | export async function generateMetadata({ params }: Props): Promise { 18 | const id = params.id 19 | 20 | const image = await getImage(id).catch(() => { 21 | console.error("Image not found") 22 | }) 23 | 24 | return { 25 | title: id + " | Memoji Laptop Sticker - StickerImage", 26 | description: "Generated Memoji Laptop Sticker Image with id " + id, 27 | metadataBase: new URL("https://stickerimage.com"), 28 | openGraph: { 29 | images: `/${id}/og/${encodeURIComponent(image as string)}`, 30 | }, 31 | } 32 | } 33 | 34 | export default async function Page({ params, searchParams }: Props) { 35 | const created = searchParams.created 36 | const stickerImage = await getImage(params.id).catch(() => { 37 | console.error("Image not found") 38 | }) 39 | 40 | if (!stickerImage) return notFound() 41 | 42 | return ( 43 |
44 |
45 | {created && } 46 |
47 |

48 | Generated Memoji Laptop Sticker Image: 49 |

50 |
51 | Memoji Laptop Sticker 59 |
60 |

61 | ID: {params.id} 62 |

63 |
64 |
65 | {created ? ( 66 | { 68 | "use server" 69 | 70 | const response = await fetch( 71 | process.env.NEWSLETTER_HOOK as string, 72 | { 73 | method: "POST", 74 | headers: { 75 | "Content-Type": "application/json", 76 | }, 77 | body: JSON.stringify({ 78 | email: email, 79 | origin: "stickerimage", 80 | }), 81 | } 82 | ) 83 | 84 | return response.status 85 | }} 86 | /> 87 | ) : ( 88 | <> 89 | )} 90 |
91 | 92 |
93 | 97 | {created ? ( 98 | 99 | ) : ( 100 | 101 | )} 102 | {created ? "Generate another" : "Create your own"} 103 | 104 |
105 | ) 106 | } 107 | 108 | const getImage = async (id: string) => { 109 | const storage = getStorage(app) 110 | const storageRef = ref(storage, "images/" + id) 111 | 112 | const url = await getDownloadURL(storageRef) 113 | 114 | return url.toString() 115 | } 116 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flornkm/stickerimage/9a95b10095fba1763a55b7b48c3165538254d8f7/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | -------------------------------------------------------------------------------- /app/imprint/page.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeft } from "@/components/Icons" 2 | import { Metadata } from "next" 3 | import Link from "next/link" 4 | 5 | export const metadata: Metadata = { 6 | metadataBase: new URL("https://stickerimage.com"), 7 | title: "Imprint - StickerImage", 8 | description: "Imprint for stickerimage.com", 9 | openGraph: { 10 | images: "/images/stickerimage-og.jpg", 11 | }, 12 | robots: "noindex", 13 | } 14 | 15 | export default function Imprint() { 16 | return ( 17 |
18 |
19 | 23 | 24 | Back to Home 25 | 26 |

27 | Information according to German §5 of TMG 28 |

29 |

30 | Florian Kiem
31 | Fischerinsel 13 32 |
33 | 10179 Berlin
34 | Germany
35 |

36 |

37 | Please only contact me via{" "} 38 | 42 | hello@floriankiem.com 43 | 44 | . 45 |

46 |

Copyright

47 |

48 | This website and its contents are subject to German copyright law. 49 | Unless expressly permitted by law (§ 44a et seq. of the copyright 50 | law), every form of utilizing, reproducing or processing works subject 51 | to copyright protection on this website requires my prior consents. 52 | Individual reproductions of a work are allowed only for private use, 53 | so must not serve either directly or indirectly for earnings. 54 | Unauthorized utilization of copyrighted works is punishable (§ 106 of 55 | the copyright law). 56 |

57 |

Credit

58 |

59 | All rights belong to their respective owners. This is a fan-made 60 | project and is not affiliated with Apple or any other company.
{" "} 61 |
62 | I'm not responsible for any misuse of this service. I really like 63 | all the brands and companies I'm using in this project. 64 |

65 | If you want your Memoji removed, please contact me on X:{" "} 66 | 71 | @flornkm 72 | 73 |

74 |

Disclaimer

75 |

76 | The contents of this website have been created with my best knowledge 77 | and utmost care. I cannot guarantee the contents’ accuracy, 78 | completeness, or topicality. According to statutory provisions, I’m 79 | furthermore responsible for my own content. In this context, please 80 | note that I’m accordingly not obliged to monitor merely the 81 | transmitted or saved information of third parties, or investigate 82 | circumstances pointing to illegal activity. My obligation to remove or 83 | block the use of information under generally applic­able laws remain 84 | unaffected by this as per §§ 8 to 10 of the Telemedia Act (TMG). 85 |

86 |

87 | Participation in resolution procedure 88 |

89 |

90 | I am neither obliged nor willing to participate in a dispute 91 | resolution procedure before a consumer arbitration board. 92 |

93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /app/latest/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | getDownloadURL, 3 | getMetadata, 4 | getStorage, 5 | listAll, 6 | ref, 7 | } from "firebase/storage" 8 | import { Metadata } from "next" 9 | import { app } from "@/lib/database" 10 | import Link from "next/link" 11 | import Image from "next/image" 12 | 13 | export const metadata: Metadata = { 14 | metadataBase: new URL("https://stickerimage.com"), 15 | title: "Latest Memoji Laptop Stickers - StickerImage", 16 | description: 17 | "Find a collection of the latest Memoji Laptop Stickers created with StickerImage.", 18 | openGraph: { 19 | images: "/images/stickerimage-latest.jpg", 20 | }, 21 | } 22 | 23 | export default async function Imprint() { 24 | const images = await getImages() 25 | 26 | return ( 27 |
28 |
29 |

30 | Latest Memoji Laptop Stickers: 31 |

32 | {images.map((image) => { 33 | return ( 34 |
38 | 39 | {image.name} 46 |

47 | {image.name} 48 |

49 | 50 |
51 | ) 52 | })} 53 |
54 | 58 | Create your own 59 | 60 |
61 | ) 62 | } 63 | 64 | const getImages = async () => { 65 | const storage = getStorage(app) 66 | const storageRef = ref(storage, "images/") 67 | const results = await listAll(storageRef) 68 | 69 | const images = (await Promise.all( 70 | results.items.map(async (item) => { 71 | const metadata = await getMetadata(item) 72 | const created = metadata.timeCreated 73 | const name = metadata.name 74 | const url = await getDownloadURL(item) 75 | return { url, created, name } 76 | }) 77 | )) as { url: string; created: string; name: string }[] 78 | 79 | images.sort((a, b) => { 80 | return new Date(b.created).getTime() - new Date(a.created).getTime() 81 | }) 82 | 83 | return images.slice(0, 20) 84 | } 85 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next" 2 | import { Inter, Zeyada } from "next/font/google" 3 | import "./globals.css" 4 | import Pattern from "@/illustrations/Pattern" 5 | import SideInfo from "@/components/SideInfo" 6 | import { Analytics } from "@vercel/analytics/react" 7 | 8 | const inter = Inter({ subsets: ["latin"] }) 9 | 10 | export const metadata: Metadata = { 11 | title: "Create Next App", 12 | description: "Generated by create next app", 13 | } 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: { 18 | children: React.ReactNode 19 | }) { 20 | return ( 21 | 22 | 23 |
24 | {children} 25 | 26 |
27 |
28 | 29 |
30 |
31 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | export default function NotFound() { 4 | return ( 5 |
6 |
7 |

8 | Couldn't find your page 9 |

10 |

11 | We searched everywhere but couldn't find the page you were 12 | looking for. 13 |
14 |
15 | If you encountered this page by a bug, please report it to me, the 16 | creator of it, on X:{" "} 17 | 22 | @flornkm 23 | 24 |

25 | 29 | Return Home 30 | 31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import StickerPlacer from "@/components/StickerPlacer" 2 | import { app } from "@/lib/database" 3 | import { getStorage, ref, uploadString } from "firebase/storage" 4 | import type { Metadata } from "next" 5 | 6 | export const metadata: Metadata = { 7 | metadataBase: new URL("https://stickerimage.com"), 8 | title: "Memoji Laptop Sticker Maker - StickerImage", 9 | description: 10 | "Make your own Laptop full of Stickers: Create your own Memoji Laptop Sticker image and share it with your friends.", 11 | openGraph: { 12 | images: "/images/stickerimage-og.jpg", 13 | }, 14 | } 15 | 16 | export default function Home() { 17 | return ( 18 |
19 |
20 | => { 22 | "use server" 23 | 24 | const storage = getStorage(app) 25 | const uniqueID = Math.random().toString(36).substring(2, 15) 26 | const storageRef = ref(storage, "images/" + uniqueID) 27 | 28 | await uploadString(storageRef, dataUrl, "data_url", { 29 | contentType: "image/png", 30 | }).then(() => { 31 | return uniqueID 32 | }) 33 | 34 | return uniqueID 35 | }} 36 | /> 37 |
38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/Banner.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | /* FOR TEMPORARY USAGE */ 4 | 5 | export default function Banner() { 6 | return ( 7 | 12 |

13 | This project is currently live on Product Hunt! I would love your 14 | support. 15 | 16 | Click here to check it out 17 | 18 |

19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/Buttons.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Copy, Save, X } from "./Icons" 4 | import { showNotification } from "./Notification" 5 | 6 | export default function Buttons(props: { image: string }) { 7 | return ( 8 | <> 9 | 29 | 41 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /components/Confetti.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useRef, useCallback } from "react" 4 | import * as rive from "@rive-app/canvas" 5 | 6 | export default function Confetti() { 7 | const confettiAnimation = useRef(null) 8 | 9 | useEffect(() => { 10 | new rive.Rive({ 11 | src: "/animations.riv", 12 | artboard: "confetti", 13 | canvas: confettiAnimation.current as HTMLCanvasElement, 14 | autoplay: true, 15 | }) 16 | }, []) 17 | 18 | return ( 19 |
20 | 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/Dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useState } from "react" 4 | import { createRoot } from "react-dom/client" 5 | 6 | export default function Dialog(props: { children: React.ReactNode }) { 7 | const [visible, setVisible] = useState(true) 8 | const [animateIn, setAnimateIn] = useState(true) 9 | 10 | const hideDialog = () => { 11 | setAnimateIn(false) 12 | setTimeout(() => { 13 | setVisible(false) 14 | }, 300) 15 | } 16 | 17 | return ( 18 | visible && ( 19 |
23 |
{ 25 | e.stopPropagation() 26 | }} 27 | className={ 28 | "px-6 py-4 bg-white border border-zinc-300 rounded-2xl pointer-events-auto w-full max-w-lg max-h-[70vh] overflow-y-scroll min-h-64 " + 29 | (animateIn ? "animate-scale-in" : "animate-scale-out") 30 | } 31 | > 32 | {props.children} 33 |
34 |
35 | ) 36 | ) 37 | } 38 | 39 | export const showDialog = (children: string | React.ReactNode) => { 40 | const dialogContainer = document.createElement("div") 41 | dialogContainer.id = "dialog-container" 42 | document.body.appendChild(dialogContainer) 43 | document.body.style.overflow = "hidden" 44 | 45 | const dialog = ( 46 | 47 | {typeof children === "string" ? ( 48 |

{children}

49 | ) : ( 50 | children 51 | )} 52 |
53 | ) 54 | 55 | const root = createRoot(dialogContainer) 56 | 57 | root.render(dialog) 58 | } 59 | 60 | export const hideDialog = () => { 61 | const dialogContainer = document.getElementById("dialog-container") 62 | if (dialogContainer) { 63 | const dialog = document.querySelector("#dialog-container > *") 64 | 65 | // Update the visibility state directly on the existing dialog component 66 | dialog && dialog.dispatchEvent(new CustomEvent("hideDialog")) 67 | 68 | document.body.removeChild(dialogContainer) 69 | document.body.style.overflow = "auto" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /components/Icons.tsx: -------------------------------------------------------------------------------- 1 | export function Plus(props: { size?: number }) { 2 | return ( 3 | 10 | 16 | 17 | ) 18 | } 19 | 20 | export function Smiley(props: { size?: number; className?: string }) { 21 | return ( 22 | 30 | 34 | 38 | 42 | 48 | 49 | ) 50 | } 51 | 52 | export function Save(props: { size?: number; className?: string }) { 53 | return ( 54 | 62 | 68 | 69 | ) 70 | } 71 | 72 | export function Copy(props: { size?: number; className?: string }) { 73 | return ( 74 | 82 | 88 | 89 | ) 90 | } 91 | 92 | export function X(props: { size?: number; className?: string }) { 93 | return ( 94 | 102 | 108 | 109 | ) 110 | } 111 | 112 | export function Information(props: { size?: number; className?: string }) { 113 | return ( 114 | 121 | 127 | 133 | 137 | 138 | ) 139 | } 140 | 141 | export function ChevronDown(props: { size?: number; className?: string }) { 142 | return ( 143 | 151 | 157 | 158 | ) 159 | } 160 | 161 | export function Mail(props: { size?: number; className?: string }) { 162 | return ( 163 | 171 | 177 | 178 | ) 179 | } 180 | 181 | export function ArrowLeft(props: { size?: number; className?: string }) { 182 | return ( 183 | 191 | 197 | 198 | ) 199 | } 200 | 201 | export function File(props: { size?: number; className?: string }) { 202 | return ( 203 | 211 | 217 | 218 | ) 219 | } 220 | 221 | export function RotateLeft(props: { size?: number }) { 222 | return ( 223 | 230 | 236 | 237 | ) 238 | } 239 | 240 | export function RotateRight(props: { size?: number }) { 241 | return ( 242 | 249 | 255 | 256 | ) 257 | } 258 | -------------------------------------------------------------------------------- /components/NewsletterDialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useEffect, useState } from "react" 4 | import { showNotification } from "./Notification" 5 | import { Mail } from "./Icons" 6 | 7 | export default function NewsletterDialog(props: { 8 | subscribe: (email: string) => Promise 9 | }) { 10 | const [loading, setLoading] = useState(false) 11 | const [email, setEmail] = useState("") 12 | const [visible, setVisible] = useState(false) 13 | const [animateIn, setAnimateIn] = useState(false) 14 | 15 | const hideDialog = () => { 16 | setAnimateIn(false) 17 | setTimeout(() => { 18 | setVisible(false) 19 | }, 300) 20 | } 21 | 22 | useEffect(() => { 23 | setTimeout(() => { 24 | setVisible(true) 25 | setAnimateIn(true) 26 | }, 1500) 27 | }, []) 28 | 29 | return ( 30 | visible && ( 31 |
35 |
{ 37 | e.stopPropagation() 38 | }} 39 | className={ 40 | "px-6 py-4 bg-white border border-zinc-300 rounded-2xl pointer-events-auto w-full max-w-lg max-h-[70vh] overflow-y-scroll min-h-64 " + 41 | (animateIn ? "animate-scale-in" : "animate-scale-out") 42 | } 43 | > 44 |
45 |

46 | Hi, I'm Flo. I've built this tool…
47 |

48 |
49 |

50 | Because I have a passion for stickers and Memoji. As you can 51 | see, this is just a small side project I completed in 48 hours. 52 |

53 |

54 | As I plan to create more products that are not just side 55 | projects for fun, but also incredibly useful, I would like to 56 | keep you informed about them. Feel free to subscribe to my 57 | personal waitlist. I promise not to spam you and will only 58 | notify you about new products as soon as they are released. :) 59 |

60 | 68 | 74 | 75 |
{ 78 | setLoading(true) 79 | e.preventDefault() 80 | const status = await props.subscribe(email) 81 | 82 | if (status === 200) { 83 | showNotification("Thanks for subscribing!") 84 | } else if (status === 409) { 85 | showNotification("You are already subscribed.") 86 | } else { 87 | showNotification("Something went wrong. Please try again.") 88 | } 89 | 90 | setEmail("") 91 | setLoading(false) 92 | hideDialog() 93 | }} 94 | > 95 | 110 | 121 |
122 |
123 |
124 |
125 |
126 | ) 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /components/Notification.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useState, useEffect } from "react" 4 | import { createRoot } from "react-dom/client" 5 | import { Information } from "./Icons" 6 | 7 | export default function Notification(props: { 8 | timer: number 9 | children: React.ReactNode 10 | }) { 11 | const [hide, setHide] = useState(false) 12 | const [animateIn, setAnimateIn] = useState(true) 13 | 14 | useEffect(() => { 15 | const timeoutId = setTimeout(() => { 16 | setAnimateIn(false) 17 | 18 | setTimeout(() => { 19 | setHide(true) 20 | }, 300) 21 | }, props.timer) 22 | 23 | return () => clearTimeout(timeoutId) 24 | }, [props.timer]) 25 | 26 | return ( 27 |
32 | {!hide && ( 33 |
34 | {props.children} 35 |
36 | )} 37 |
38 | ) 39 | } 40 | 41 | export const showNotification = (message: string, timer: number = 5000) => { 42 | const notificationContainer = document.createElement("div") 43 | document.body.appendChild(notificationContainer) 44 | 45 | const removeNotificationContainer = () => { 46 | document.body.removeChild(notificationContainer) 47 | } 48 | 49 | const notification = ( 50 | 51 | 52 |

{message}

53 |
54 | ) 55 | 56 | const root = createRoot(notificationContainer) 57 | 58 | root.render(notification) 59 | 60 | setTimeout(() => { 61 | root.unmount() 62 | removeNotificationContainer() 63 | }, timer + 300) // Adding a small buffer time for the fade-out animation 64 | } 65 | -------------------------------------------------------------------------------- /components/SideInfo.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { useState } from "react" 3 | import Link from "next/link" 4 | import { ChevronDown } from "./Icons" 5 | 6 | export default function SideInfo() { 7 | const [isInformationOpen, setInformationOpen] = useState(false) 8 | 9 | const toggleInformation = () => { 10 | setInformationOpen(!isInformationOpen) 11 | } 12 | 13 | return ( 14 |
15 |

16 | Information{" "} 17 |

25 |
32 |

33 | Created by{" "} 34 | 39 | @flornkm 40 | 41 |

42 |

Built with

43 |

44 | 49 | Next.js 50 | 51 | ,{" "} 52 | 57 | Firebase 58 | {" "} 59 | and more, check out the{" "} 60 | 65 | Repo on GitHub 66 | 67 | .

68 | The icons are from the beautiful{" "} 69 | 74 | Saman Icons Pack 75 | 76 | . 77 |

78 |

Credit

79 |

80 | All rights belong to their respective owners. This is a fan-made 81 | project and is not affiliated with Apple or any other company.
{" "} 82 |
83 | I'm not responsible for any misuse of this service. I really like 84 | all the brands and companies I'm using in this project. 85 |

86 | If you want your Memoji removed, please contact me on X:{" "} 87 | 92 | @flornkm 93 | 94 |

95 |
96 | 101 | Imprint 102 | 103 |
104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /components/StickerPlacer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SetStateAction, useRef, useState } from "react" 4 | import Draggable, { DraggableData, DraggableEvent } from "react-draggable" 5 | import stickerData from "@/public/sticker.json" 6 | import { 7 | Plus, 8 | RotateRight, 9 | RotateLeft, 10 | Save, 11 | Smiley, 12 | File, 13 | } from "@/components/Icons" 14 | import Laptop from "@/illustrations/Laptop" 15 | import * as rive from "@rive-app/canvas" 16 | import { showNotification } from "./Notification" 17 | import NextImage from "next/image" 18 | import * as htmlToImage from "html-to-image" 19 | import { hideDialog, showDialog } from "./Dialog" 20 | 21 | export default function StickerPlacer({ 22 | uploadImage, 23 | }: { 24 | uploadImage: (dataUrl: string) => Promise 25 | }) { 26 | const [stickerState, setStickerState] = useState( 27 | stickerData.map((sticker, index) => ({ 28 | ...sticker, 29 | custom: false, 30 | position: { x: 0, y: 0, rotation: 0, zIndex: index }, 31 | })) 32 | ) 33 | const [draggedSticker, setDraggedSticker] = useState(null) 34 | const [memoji, setMemoji] = useState("/images/default-memoji.png") 35 | const [loadImage, setLoadImage] = useState(false) 36 | 37 | const laptopRef = useRef(null) 38 | const toolbarRef = useRef(null) 39 | const screenRef = useRef(null) 40 | const dissolveAnimation = useRef(null) 41 | 42 | const handleDragStart = ( 43 | e: DraggableEvent, 44 | index: SetStateAction 45 | ) => { 46 | setDraggedSticker(index) 47 | setStickerState((prev) => { 48 | const next = [...prev] 49 | next[index as number] = { 50 | ...next[index as number], 51 | position: { 52 | ...next[index as number].position, 53 | zIndex: 54 | Math.max(...next.map((sticker) => sticker.position.zIndex)) + 1, 55 | }, 56 | } 57 | return next 58 | }) 59 | } 60 | 61 | const handleDragStop = (e: DraggableEvent, data: DraggableData) => { 62 | const laptop = laptopRef.current?.getBoundingClientRect() 63 | if (laptop) { 64 | const stickerIndex = draggedSticker as number 65 | const currentSticker = stickerState[stickerIndex] 66 | 67 | if (data.node instanceof HTMLElement) { 68 | const stickerRect = data.node.getBoundingClientRect() 69 | if ( 70 | stickerRect.left >= laptop.left && 71 | stickerRect.right <= laptop.right && 72 | stickerRect.top >= laptop.top && 73 | stickerRect.bottom <= laptop.bottom 74 | ) { 75 | setStickerState((prev) => { 76 | const next = [...prev] 77 | next[stickerIndex] = { 78 | ...currentSticker, 79 | position: { 80 | x: data.x, 81 | y: data.y, 82 | rotation: currentSticker.position.rotation, 83 | zIndex: currentSticker.position.zIndex, 84 | }, 85 | } 86 | return next 87 | }) 88 | } else if (!currentSticker.custom) { 89 | setStickerState((prev) => { 90 | const next = [...prev] 91 | next[stickerIndex] = { 92 | ...currentSticker, 93 | position: { 94 | ...currentSticker.position, 95 | x: 0, 96 | y: 0, 97 | rotation: 0, 98 | }, 99 | } 100 | return next 101 | }) 102 | 103 | data.node.style.transition = "opacity 0s" 104 | data.node.style.opacity = "0" 105 | data.node.style.transform = "rotate(0deg)" 106 | 107 | dissolveAnimation.current!.style.left = stickerRect.left + "px" 108 | dissolveAnimation.current!.style.top = stickerRect.top + "px" 109 | 110 | new rive.Rive({ 111 | src: "/animations.riv", 112 | artboard: "dissolve", 113 | canvas: dissolveAnimation.current as HTMLCanvasElement, 114 | autoplay: true, 115 | }) 116 | 117 | setTimeout(() => { 118 | setStickerState((prev) => { 119 | const next = [...prev] 120 | next[stickerIndex] = { 121 | ...currentSticker, 122 | position: { 123 | ...currentSticker.position, 124 | x: 0, 125 | y: 0, 126 | rotation: 0, 127 | }, 128 | } 129 | return next 130 | }) 131 | data.node.style.transition = "opacity 0.3s" 132 | data.node.style.opacity = "1" 133 | }, 300) 134 | } else { 135 | setStickerState((prev) => { 136 | const next = [...prev] 137 | next.splice(stickerIndex, 1) 138 | return next 139 | }) 140 | 141 | console.log(stickerRect) 142 | 143 | dissolveAnimation.current!.style.left = stickerRect.left + "px" 144 | dissolveAnimation.current!.style.top = stickerRect.top + "px" 145 | 146 | new rive.Rive({ 147 | src: "/animations.riv", 148 | artboard: "dissolve", 149 | canvas: dissolveAnimation.current as HTMLCanvasElement, 150 | autoplay: true, 151 | }) 152 | } 153 | } 154 | setDraggedSticker(null) 155 | } 156 | } 157 | 158 | const stickerIsOnLaptop = (x: number, y: number) => { 159 | const laptop = laptopRef.current?.getBoundingClientRect() 160 | 161 | if (x === 0 && y === 0) return false 162 | 163 | if (laptop) { 164 | const mouse = { 165 | x: (window.event as MouseEvent).clientX, 166 | y: (window.event as MouseEvent).clientY, 167 | } 168 | 169 | if ( 170 | mouse.x < laptop.right && 171 | mouse.x > laptop.left && 172 | mouse.y < laptop.bottom && 173 | mouse.y > laptop.top 174 | ) { 175 | return true 176 | } 177 | } 178 | return false 179 | } 180 | 181 | const loadSvg = async (url: string) => { 182 | return fetch(url) 183 | .then(function (response) { 184 | return response.text() 185 | }) 186 | .then(function (raw) { 187 | return new window.DOMParser().parseFromString(raw, "image/svg+xml") 188 | }) 189 | } 190 | 191 | const handleStickerUpload = (event: React.ChangeEvent) => { 192 | const file = event.target.files?.[0] 193 | 194 | if (file) { 195 | if (file?.type == "image/svg+xml") { 196 | const reader = new FileReader() 197 | reader.readAsText(file) 198 | 199 | loadSvg(URL.createObjectURL(file)).then((svg) => { 200 | if (svg.documentElement.firstChild?.nodeName === "?xml") { 201 | svg.documentElement.removeChild(svg.documentElement.firstChild) 202 | } 203 | 204 | if (!svg.documentElement || svg.documentElement.tagName !== "svg") { 205 | return showNotification( 206 | "Error: Please upload a valid SVG file (max. 48 x 48 px)." 207 | ) 208 | } 209 | 210 | const image = svg.documentElement.querySelector("image") 211 | if (image) { 212 | image.removeAttribute("transform") 213 | } 214 | 215 | const svgProps = { 216 | width: Number( 217 | svg.documentElement.getAttribute("width")?.replace("px", "") 218 | ), 219 | height: Number( 220 | svg.documentElement.getAttribute("height")?.replace("px", "") 221 | ), 222 | } 223 | 224 | if ( 225 | svgProps.width > 48 || 226 | svgProps.height > 48 || 227 | !svgProps.width || 228 | !svgProps.height 229 | ) { 230 | svg.documentElement.setAttribute("width", "40") 231 | svg.documentElement.setAttribute("height", "40") 232 | } 233 | 234 | if ( 235 | stickerState.find( 236 | (sticker) => 237 | sticker.position.x === 0 && 238 | sticker.position.y === 0 && 239 | sticker.custom === true 240 | ) 241 | ) 242 | return showNotification( 243 | "Error: You need to move the existing custom sticker first." 244 | ) 245 | 246 | const svgString = svg.documentElement.outerHTML 247 | 248 | setStickerState((prev) => [ 249 | ...prev, 250 | { 251 | data: svgString, 252 | name: file.name, 253 | custom: true, 254 | position: { 255 | x: 0, 256 | y: 0, 257 | rotation: 0, 258 | zIndex: 259 | Math.max(...prev.map((sticker) => sticker.position.zIndex)) + 260 | 1, 261 | }, 262 | }, 263 | ]) 264 | }) 265 | } else if (file?.type == "image/png" || file?.type == "image/jpeg") { 266 | const reader = new FileReader() 267 | reader.readAsDataURL(file) 268 | 269 | reader.onloadend = () => { 270 | const img = new Image() 271 | 272 | img.src = reader.result as string 273 | img.onload = () => { 274 | const canvas = document.createElement("canvas") 275 | canvas.width = img.width 276 | canvas.height = img.height 277 | 278 | const context = canvas.getContext("2d") 279 | context!.drawImage(img, 0, 0) 280 | 281 | if ( 282 | stickerState.find( 283 | (sticker) => 284 | sticker.position.x === 0 && 285 | sticker.position.y === 0 && 286 | sticker.custom === true 287 | ) 288 | ) 289 | return showNotification( 290 | "Error: You need to move the existing custom sticker first." 291 | ) 292 | 293 | const imgString = `` 294 | 295 | setStickerState((prev) => [ 296 | ...prev, 297 | { 298 | data: imgString, 299 | name: file.name, 300 | custom: true, 301 | position: { 302 | x: 0, 303 | y: 0, 304 | rotation: 0, 305 | zIndex: 306 | Math.max( 307 | ...prev.map((sticker) => sticker.position.zIndex) 308 | ) + 1, 309 | }, 310 | }, 311 | ]) 312 | } 313 | } 314 | } else { 315 | showNotification( 316 | "Error: Please upload a valid SVG, PNG or JPEG file (max. 48 x 48 px)." 317 | ) 318 | } 319 | } 320 | } 321 | 322 | const replaceMemoji = (file: File) => { 323 | const reader = new FileReader() 324 | reader.readAsDataURL(file) 325 | 326 | reader.onloadend = () => { 327 | setMemoji(reader.result as string) 328 | } 329 | } 330 | 331 | const handleMemojiUpload = async ( 332 | event: React.ChangeEvent 333 | ) => { 334 | const file = event.target.files?.[0] 335 | if (file) { 336 | if (file.type !== "image/png" && file.type !== "image/jpeg") { 337 | return showNotification( 338 | "Error: Please upload a valid PNG or JPEG file for the memoji." 339 | ) 340 | } 341 | 342 | const img = new Image() 343 | 344 | img.src = URL.createObjectURL(file) 345 | img.onload = () => { 346 | const canvas = document.createElement("canvas") 347 | canvas.width = img.width 348 | canvas.height = img.height 349 | 350 | const context = canvas.getContext("2d") 351 | context!.drawImage(img, 0, 0) 352 | 353 | const data = context!.getImageData(0, 0, img.width, img.height) 354 | .data as Uint8ClampedArray 355 | 356 | const r = data[0] 357 | const g = data[1] 358 | const b = data[2] 359 | 360 | const rgb = `rgb(${r}, ${g}, ${b})` 361 | 362 | if (r === 0 && g === 0 && b === 0) 363 | return (screenRef.current!.style.backgroundColor = "transparent") 364 | 365 | screenRef.current!.style.backgroundColor = rgb 366 | } 367 | 368 | replaceMemoji(file) 369 | } else { 370 | showNotification( 371 | "Error: Please upload a valid PNG or JPEG file for the memoji." 372 | ) 373 | } 374 | } 375 | 376 | const saveImage = async () => { 377 | if ( 378 | stickerState.every( 379 | (sticker) => 380 | sticker.position.x === 0 && 381 | sticker.position.y === 0 && 382 | sticker.custom === false 383 | ) && 384 | memoji === "/default-memoji.png" 385 | ) 386 | return showNotification( 387 | "Error: Please add at least one sticker to the laptop." 388 | ) 389 | 390 | setLoadImage(true) 391 | 392 | const safari = 393 | typeof window !== "undefined" && 394 | /^((?!chrome|android).)*safari/i.test(navigator.userAgent) 395 | 396 | if (safari) { 397 | await htmlToImage.toPng(screenRef.current as HTMLDivElement) 398 | await htmlToImage.toPng(screenRef.current as HTMLDivElement) 399 | await htmlToImage.toPng(screenRef.current as HTMLDivElement) 400 | } 401 | 402 | htmlToImage 403 | .toPng(screenRef.current as HTMLDivElement) 404 | .then(async function (dataUrl) { 405 | uploadImage(dataUrl).then((id) => { 406 | window.location.href = `/${id}?created=true` 407 | }) 408 | }) 409 | .catch(function (error) { 410 | console.error("Error capturing the image:", error) 411 | }) 412 | } 413 | 414 | return ( 415 |
416 |
417 | 425 |
426 |
427 | 428 | {stickerState.length > 9 && 429 | stickerState.map((sticker, index) => { 430 | if (index >= 9) 431 | return ( 432 |
436 | 445 |
446 | ) 447 | })} 448 |
449 | 457 |
458 |
459 |
463 |
464 |
465 |
466 | {stickerState.map((sticker, index) => { 467 | if (index < 9) 468 | return ( 469 | 479 | ) 480 | })} 481 |
482 | 489 | 497 |
498 |
499 |
500 | 548 | 561 |
562 |
563 |
564 |
565 | ) 566 | } 567 | 568 | function Sticker({ 569 | sticker, 570 | index, 571 | handleDragStart, 572 | handleDragStop, 573 | draggedSticker, 574 | setStickerState, 575 | stickerIsOnLaptop, 576 | }: { 577 | sticker: { 578 | data: string 579 | name: string 580 | custom: boolean 581 | position: { 582 | x: number 583 | y: number 584 | rotation: number 585 | zIndex: number 586 | } 587 | } 588 | index: number 589 | handleDragStart: ( 590 | e: DraggableEvent, 591 | index: SetStateAction 592 | ) => void 593 | handleDragStop: (e: DraggableEvent, data: DraggableData) => void 594 | draggedSticker: number | null 595 | setStickerState: React.Dispatch< 596 | React.SetStateAction< 597 | { 598 | data: string 599 | name: string 600 | custom: boolean 601 | position: { 602 | x: number 603 | y: number 604 | rotation: number 605 | zIndex: number 606 | } 607 | }[] 608 | > 609 | > 610 | stickerIsOnLaptop: (x: number, y: number) => boolean 611 | }) { 612 | const rotateWithTwoFingers = (e: React.TouchEvent) => { 613 | const firstTouch = e.touches[0] 614 | const secondTouch = e.touches[1] 615 | 616 | const firstTouchX = firstTouch.clientX 617 | const firstTouchY = firstTouch.clientY 618 | const secondTouchX = secondTouch.clientX 619 | const secondTouchY = secondTouch.clientY 620 | 621 | const x = firstTouchX - secondTouchX 622 | const y = firstTouchY - secondTouchY 623 | 624 | const angle = Math.atan2(y, x) * (180 / Math.PI) 625 | 626 | setStickerState((prev) => { 627 | const next = [...prev] 628 | next[index] = { 629 | ...next[index], 630 | position: { 631 | ...next[index].position, 632 | rotation: angle, 633 | }, 634 | } 635 | return next 636 | }) 637 | } 638 | 639 | return ( 640 | handleDragStart(e, index)} 646 | onStop={handleDragStop} 647 | disabled={draggedSticker !== null && draggedSticker !== index} 648 | > 649 |
{ 651 | if (e.touches.length === 2) { 652 | rotateWithTwoFingers(e) 653 | } 654 | }} 655 | onTouchMove={(e) => { 656 | if (e.touches.length === 2) { 657 | rotateWithTwoFingers(e) 658 | } 659 | }} 660 | style={{ 661 | zIndex: sticker.position?.zIndex || 0, 662 | touchAction: "none", 663 | userSelect: "none", 664 | pointerEvents: "auto", 665 | }} 666 | className={ 667 | "cursor-grab pointer-events-auto active:cursor-grabbing flex items-center justify-center transition-opacity w-14 flex-shrink-0 aspect-square rounded-md relative group " + 668 | (sticker.position?.x === 0 && 669 | sticker.position?.y === 0 && 670 | draggedSticker !== index && 671 | !sticker.custom 672 | ? "hover:bg-zinc-200" 673 | : "") 674 | } 675 | > 676 | {(stickerIsOnLaptop(sticker.position.x || 0, sticker.position.y || 0) || 677 | sticker.custom) && ( 678 |