From 72b4cb0851802fac50bc953191fb61019a30ca83 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 7 Sep 2025 17:43:17 +0200 Subject: [PATCH] feat: add confetti animation on thought submission and update dependencies --- thoughts-frontend/app/layout.tsx | 1 - thoughts-frontend/bun.lock | 7 + thoughts-frontend/components/confetti.tsx | 127 ++++++++++++++++++ .../components/post-thought-form.tsx | 117 ++++++++-------- thoughts-frontend/components/reply-form.tsx | 79 ++++++----- thoughts-frontend/package.json | 1 + 6 files changed, 240 insertions(+), 92 deletions(-) create mode 100644 thoughts-frontend/components/confetti.tsx diff --git a/thoughts-frontend/app/layout.tsx b/thoughts-frontend/app/layout.tsx index cf75af0..6ed15a2 100644 --- a/thoughts-frontend/app/layout.tsx +++ b/thoughts-frontend/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { AuthProvider } from "@/hooks/use-auth"; import { Toaster } from "@/components/ui/sonner"; diff --git a/thoughts-frontend/bun.lock b/thoughts-frontend/bun.lock index bafd279..ac6edd9 100644 --- a/thoughts-frontend/bun.lock +++ b/thoughts-frontend/bun.lock @@ -50,6 +50,7 @@ "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "tone": "^15.1.22", "vaul": "^1.1.2", "zod": "^4.1.5", }, @@ -474,6 +475,8 @@ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "automation-events": ["automation-events@7.1.12", "", { "dependencies": { "@babel/runtime": "^7.28.3", "tslib": "^2.8.1" } }, "sha512-JDdPQoV58WPm15/L3ABtIEiqyxLoW+yTYIEqYtrKZ7VizLSRXhMKRZbQ8CYc2mFq/lMRKUvqOj0OcT3zANFiXA=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], @@ -992,6 +995,8 @@ "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + "standardized-audio-context": ["standardized-audio-context@25.3.77", "", { "dependencies": { "@babel/runtime": "^7.25.6", "automation-events": "^7.0.9", "tslib": "^2.7.0" } }, "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], @@ -1030,6 +1035,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tone": ["tone@15.1.22", "", { "dependencies": { "standardized-audio-context": "^25.3.70", "tslib": "^2.3.1" } }, "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag=="], + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], diff --git a/thoughts-frontend/components/confetti.tsx b/thoughts-frontend/components/confetti.tsx new file mode 100644 index 0000000..39fe71b --- /dev/null +++ b/thoughts-frontend/components/confetti.tsx @@ -0,0 +1,127 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as Tone from "tone"; + +interface ConfettiProps { + fire: boolean; + onComplete: () => void; +} + +const colors = ["#26ccff", "#a25afd", "#ff5e7e", "#88ff5a", "#fcff42"]; + +export function Confetti({ fire, onComplete }: ConfettiProps) { + const canvasRef = useRef(null); + + useEffect(() => { + if (fire) { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const synth = new Tone.PolySynth(Tone.Synth, { + oscillator: { type: "sine" }, + envelope: { attack: 0.005, decay: 0.1, sustain: 0.3, release: 1 }, + }).toDestination(); + + const notes = ["C4", "E4", "G4", "A4"]; + + let animationFrameId: number; + const confetti: { + x: number; + y: number; + r: number; + d: number; + color: string; + tilt: number; + }[] = []; + const numConfetti = 100; + + const resizeCanvas = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + }; + window.addEventListener("resize", resizeCanvas); + resizeCanvas(); + + for (let i = 0; i < numConfetti; i++) { + confetti.push({ + x: Math.random() * canvas.width, + y: -20, + r: Math.random() * 6 + 1, + d: Math.random() * numConfetti, + color: colors[Math.floor(Math.random() * colors.length)], + tilt: Math.floor(Math.random() * 10) - 10, + }); + } + + let animationFinished = false; + + const draw = () => { + if (animationFinished) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + let allOffScreen = true; + + for (let i = 0; i < numConfetti; i++) { + const c = confetti[i]; + ctx.beginPath(); + ctx.lineWidth = c.r / 2; + ctx.strokeStyle = c.color; + ctx.moveTo(c.x + c.tilt, c.y); + ctx.lineTo(c.x, c.y + c.tilt + c.r); + ctx.stroke(); + + c.y += Math.cos(c.d + i + 1.2) + 1.5 + c.r / 2; + c.x += Math.sin(i) * 1.5; + + if (c.y <= canvas.height) { + allOffScreen = false; + } + } + + if (allOffScreen) { + animationFinished = true; + onComplete(); + } else { + animationFrameId = requestAnimationFrame(draw); + } + }; + + try { + Tone.start(); + const now = Tone.now(); + notes.forEach((note, i) => { + synth.triggerAttackRelease(note, "8n", now + i * 0.1); + }); + draw(); + } catch (error) { + console.error("Audio could not be started", error); + draw(); + } + + return () => { + window.removeEventListener("resize", resizeCanvas); + cancelAnimationFrame(animationFrameId); + }; + } + }, [fire, onComplete]); + + if (!fire) return null; + + return ( + + ); +} diff --git a/thoughts-frontend/components/post-thought-form.tsx b/thoughts-frontend/components/post-thought-form.tsx index b103faf..ae32f71 100644 --- a/thoughts-frontend/components/post-thought-form.tsx +++ b/thoughts-frontend/components/post-thought-form.tsx @@ -25,10 +25,13 @@ import { CreateThoughtSchema, createThought } from "@/lib/api"; import { useAuth } from "@/hooks/use-auth"; import { toast } from "sonner"; import { Globe, Lock, Users } from "lucide-react"; +import { useState } from "react"; +import { Confetti } from "./confetti"; export function PostThoughtForm() { const router = useRouter(); const { token } = useAuth(); + const [showConfetti, setShowConfetti] = useState(false); const form = useForm>({ resolver: zodResolver(CreateThoughtSchema), @@ -44,6 +47,7 @@ export function PostThoughtForm() { try { await createThought(values, token); toast.success("Your thought has been posted!"); + setShowConfetti(true); form.reset(); router.refresh(); // This is the key to updating the feed } catch { @@ -52,67 +56,70 @@ export function PostThoughtForm() { } return ( - - -
- - ( - - -