commit 308a6e0a7de0602407c071476fd10a466cae27d8
parent e8d67ffc96888f4be32f78224f61a54addc2be48
Author: Daniel D’Aquino <daniel@daquino.me>
Date: Thu, 2 Jan 2025 16:12:39 +0900
WIP 2024 review page
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Diffstat:
15 files changed, 790 insertions(+), 6 deletions(-)
diff --git a/archive/css/custom.css b/archive/css/custom.css
@@ -188,4 +188,3 @@ body {
input {
color: #252D3A;
}
-
diff --git a/package-lock.json b/package-lock.json
@@ -8,6 +8,7 @@
"name": "damus-website",
"version": "0.1.0",
"dependencies": {
+ "@number-flow/react": "0.4.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
@@ -3350,6 +3351,20 @@
"node": ">= 8"
}
},
+ "node_modules/@number-flow/react": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.4.0.tgz",
+ "integrity": "sha512-4LC9PzSSl55bjBhFfZwUrysw0EENlzgWfO6RQhRPnIF5PyymeMV8/i/FiS1yQNz5MHhs+o/TVWETNQUDXdKeJg==",
+ "license": "MIT",
+ "dependencies": {
+ "esm-env": "^1.1.4",
+ "number-flow": "0.4.0"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19.0.0-rc-915b914b3a-20240515",
+ "react-dom": "^18"
+ }
+ },
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz",
@@ -10526,6 +10541,12 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/esm-env": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.1.tgz",
+ "integrity": "sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==",
+ "license": "MIT"
+ },
"node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@@ -13496,6 +13517,15 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
+ "node_modules/number-flow": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.4.0.tgz",
+ "integrity": "sha512-sG2ngZe0y3M8DSa9VQfMA5J9Yi1i4RYaaZ/lgawTJpICftFAGaFgTUydaavZmUbOZcBnjDqNizcsyb1MDhrGaw==",
+ "license": "MIT",
+ "dependencies": {
+ "esm-env": "^1.1.4"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
diff --git a/package.json b/package.json
@@ -25,6 +25,7 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.1.0",
"class-variance-authority": "^0.7.0",
+ "@number-flow/react": "0.4.0",
"clsx": "^2.0.0",
"framer-motion": "^10.16.4",
"input-otp": "^1.2.3",
diff --git a/public/2024-in-review/cat-typing.webp b/public/2024-in-review/cat-typing.webp
Binary files differ.
diff --git a/public/2024-in-review/ostrich-dancing.webp b/public/2024-in-review/ostrich-dancing.webp
Binary files differ.
diff --git a/src/components/effects/StarField.tsx b/src/components/effects/StarField.tsx
@@ -16,7 +16,7 @@ import React, { useEffect, useRef, useState } from 'react';
const STAR_COUNT = 300;
const STAR_MIN_SPEED = 0.005;
-const STAR_MAX_SPEED = 0.02;
+const STAR_MAX_SPEED = 0.1;
const MIN_VISION_PERSISTENCE = 0.2;
const MAX_VISION_PERSISTENCE = 0.99;
diff --git a/src/components/note/NostrNoteView.tsx b/src/components/note/NostrNoteView.tsx
@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
-import { useMemo } from "react";
+import { useEffect, useMemo, useState } from "react";
import Image from "next/image";
export interface ParsedNote {
@@ -55,11 +55,15 @@ export function NostrNoteView(props: NostrNoteViewProps) {
const profileContent = useMemo(() => { // JSON parsing is expensive, so we memoize it
return JSON.parse(props.note.profile.content) as ProfileContent;
}, [props.note.profile.content]);
- const timestamp = new Date(props.note.note.created_at * 1000).toLocaleDateString();
+ const [timestamp, setTimestamp] = useState<string | null>(null);
const displayName = profileContent.displayName || profileContent.name;
+ useEffect(() => {
+ setTimestamp(new Date(props.note.note.created_at * 1000).toLocaleDateString());
+ }, [props.note.note.created_at]);
+
return (
- <div className={cn("p-6 rounded-3xl shadow-lg border border-black/20", props.className)} style={props.style}>
+ <div className={cn("p-6 bg-white rounded-3xl shadow-lg border border-black/20 text-left", props.className)} style={props.style}>
<div className="flex flex-col gap-y-3">
<div className="flex items-center gap-x-3 text-xl">
<Image
@@ -70,7 +74,7 @@ export function NostrNoteView(props: NostrNoteViewProps) {
alt={displayName}
/>
<div className="flex flex-col">
- <div className="font-bold text-2xl">
+ <div className="font-bold text-2xl text-gray-700">
{displayName}
</div>
<div className="text-gray-400 text-sm">
diff --git a/src/components/pages/purple-2024-year-in-review.tsx b/src/components/pages/purple-2024-year-in-review.tsx
@@ -0,0 +1,37 @@
+import Head from "next/head";
+import { useIntl } from "react-intl";
+import { Npub2024InReviewStats } from "@/pages/purple/2024-in-review/[npub]";
+import { Year2024Intro } from "../sections/2024-year-in-review/Year2024Intro";
+import { motion, useScroll, useTransform } from "framer-motion";
+import StarField from "@/components/sections/2024-year-in-review/StarField"
+import { MostZappedNote } from "../sections/2024-year-in-review/MostZappedNote";
+import { EndThankYou } from "../sections/2024-year-in-review/EndThankYou";
+import { useRef } from "react";
+import { NumberOfPosts } from "../sections/2024-year-in-review/NumberOfPosts";
+
+
+export function Purple2024YearInReview({ stats }: { stats: Npub2024InReviewStats }) {
+ const ref = useRef<HTMLDivElement>(null)
+ const { scrollYProgress } = useScroll({
+ target: ref,
+ offset: ["start start", "end start"]
+ });
+ const bgOpacity = useTransform(scrollYProgress, [0.7, 0.98], [1.0, 0.0]);
+
+ return (<>
+ <Head>
+ <title>2024 in review</title>
+ </Head>
+ <main style={{ scrollBehavior: "smooth" }} className="bg-black">
+ <div ref={ref} className="relative w-full" style={{ height: 1300 }}>
+ <motion.div className="absolute top-0 z-0 w-full h-full pointer-events-none" style={{ opacity: bgOpacity }}>
+ <StarField scrollYProgress={scrollYProgress} className="z-0 fixed top-0 left-0 object-cover object-center w-full h-full"/>
+ </motion.div>
+ <Year2024Intro />
+ </div>
+ {stats.number_of_posts && <NumberOfPosts numberOfPosts={stats.number_of_posts} style={{ paddingTop: "500px" }} />}
+ <MostZappedNote stats={stats} style={{ paddingTop: "500px" }} />
+ <EndThankYou />
+ </main>
+ </>)
+}
diff --git a/src/components/sections/2024-year-in-review/EndThankYou.tsx b/src/components/sections/2024-year-in-review/EndThankYou.tsx
@@ -0,0 +1,199 @@
+import { MotionValue, circOut, easeInOut, easeOut, motion, useMotionValue, useScroll, useTime, useTransform } from "framer-motion";
+import { PurpleIcon } from "../../icons/PurpleIcon";
+import { ArrowDown } from "lucide-react";import { useRef } from "react";
+
+export function EndThankYou() {
+ const time = useTime()
+ const ref = useRef<HTMLDivElement>(null)
+ const { scrollYProgress } = useScroll({
+ target: ref,
+ offset: ["start start", "end end"]
+ })
+ const starProgress = useTransform(
+ scrollYProgress,
+ [0, 0.9],
+ [0, 1],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ )
+ const headingGradient = useTransform(
+ time,
+ [0, 3000],
+ [
+ "linear-gradient(to right, #000000 0%, #D34CD9 1000%, #F869B6 3000%)",
+ "linear-gradient(to right, #000000 -10%, #D34CD9 0%, #F869B6 100%)"
+ ],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ )
+ const headingOpacity = useTransform(
+ scrollYProgress,
+ [0.5, 0.7],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+ const secondaryContentOpacity = useTransform(
+ scrollYProgress,
+ [0.7, 1.0],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+
+ return <div ref={ref} style={{ height: 3000 }} className="relative">
+ <div className="sticky top-0 container z-30 mx-auto px-6 pt-12 h-auto min-h-screen flex flex-col gap-y-4 justify-center items-center pb-12">
+ <PurpleStarIcon className="w-32 h-32 z-40" progress={starProgress} />
+ <motion.h1
+ className="text-4xl md:text-6xl text-center text-transparent bg-clip-text font-semibold break-keep tracking-tight z-30 pb-6"
+ style={{
+ backgroundImage: headingGradient,
+ opacity: headingOpacity,
+ }}
+ >
+ Thank you for your support in 2024!
+ </motion.h1>
+
+ <motion.div
+ className="text-center text-purple-200/80 text-lg max-w-lg p-6 space-y-4 z-30"
+ style={{ opacity: secondaryContentOpacity }}
+ >
+ <p>
+ We would like to thank you for your continued support, from the bottom of our hearts. You help us make our mission possible. We are a small team, but we will keep working and doing our best to bring you the most innovative Nostr experience, and help build a better, freer future together in 2025!
+ </p>
+ <p className="text-3xl capitalize">
+ Happy New Year!
+ </p>
+ </motion.div>
+ </div>
+ </div>
+}
+
+interface StarIconProps {
+ className?: string;
+ progress: MotionValue<number>;
+}
+
+function PurpleStarIcon(props: StarIconProps) {
+ const { className, progress } = props;
+
+ const purple1Color = "#D34CD9";
+ const purple2Color = "#F869B6";
+
+ const starScale = useTransform(
+ progress,
+ [0, 0.6],
+ [3, 1.0],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ )
+
+ const starOpacity = useTransform(
+ progress,
+ [0, 0.6],
+ [0, 1],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ )
+
+ const starShadowColor = useTransform(
+ progress,
+ [0, 0.8],
+ [
+ "#FFFFFF00",
+ "#FFFFFFFF",
+ ],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ )
+
+ const gradientOffsetKeyframes = [0, 0.2, 1.0]
+
+ const whiteGradientStopOffset = useTransform(
+ progress,
+ gradientOffsetKeyframes,
+ ["0%", "0%", "70%"],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ );
+
+ const purple2GradientStopOffset = useTransform(
+ progress,
+ gradientOffsetKeyframes,
+ ["1%", "1%", "100%"],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ );
+
+ const purple1GradientStopOffset = useTransform(
+ progress,
+ gradientOffsetKeyframes,
+ ["2%", "2%", "125%"],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ );
+
+ const blackGradientStopOffset = useTransform(
+ progress,
+ gradientOffsetKeyframes,
+ ["3%", "3%", "250%"],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ );
+
+ return (
+ // Generated by Pixelmator Pro 3.6.8
+ <motion.svg
+ className={className}
+ width="4267" height="4267" viewBox="0 0 4267 4267" xmlns="http://www.w3.org/2000/svg"
+ style={{
+ scale: starScale,
+ opacity: starOpacity
+ }}
+ >
+ <defs>
+ <radialGradient id="purpleGradient" cx="150%" cy="-50%" r="250%">
+ <motion.stop offset={whiteGradientStopOffset} style={{ stopColor: "#ffffff", stopOpacity: 1 }} />
+ <motion.stop offset={purple2GradientStopOffset} style={{ stopColor: purple2Color, stopOpacity: 1 }} />
+ <motion.stop offset={purple1GradientStopOffset} style={{ stopColor: purple1Color, stopOpacity: 1 }} />
+ <motion.stop offset={blackGradientStopOffset} style={{ stopColor: "#000000", stopOpacity: 1 }} />
+ </radialGradient>
+ <filter id="whiteShadow" x="-50%" y="-50%" width="200%" height="200%">
+ <motion.feDropShadow dx="0" dy="0" stdDeviation="500" floodColor={starShadowColor} />
+ </filter>
+ </defs>
+ <path
+ id="Star"
+ fill="url(#purpleGradient)"
+ stroke="url(#purpleGradient)"
+ fill-rule="evenodd"
+ stroke-width="400"
+ stroke-linejoin="round"
+ filter="url(#whiteShadow)"
+ d="M 2133.5 750 L 1812.484985 1691.660645 L 817.713318 1705.975098 L 1614.086914 2302.267578 L 1320.299072 3252.774902 L 2133.5 2679.643311 L 2946.700928 3252.774902 L 2652.913086 2302.267578 L 3449.286621 1705.975098 L 2454.515137 1691.660645 Z"
+ />
+ </motion.svg>
+ )
+}
diff --git a/src/components/sections/2024-year-in-review/MostZappedNote.tsx b/src/components/sections/2024-year-in-review/MostZappedNote.tsx
@@ -0,0 +1,108 @@
+import { MotionValue, circOut, easeInOut, easeOut, motion, useMotionValue, useScroll, useTime, useTransform } from "framer-motion";
+import { PurpleIcon } from "../../icons/PurpleIcon";
+import { ArrowDown, ZapIcon } from "lucide-react";
+import { NostrNoteView, ParsedNote } from "@/components/note/NostrNoteView";
+import { Npub2024InReviewStats } from "@/pages/purple/2024-in-review/[npub]";
+import { cn } from "@/lib/utils";
+import { useRef } from "react";
+import { Orbitron } from "next/font/google";
+import NumberFlow from '@number-flow/react'
+import { useEffect, useState } from "react";
+import { useInterval } from "usehooks-ts";
+
+const orbitron = Orbitron({ subsets: ['latin'] })
+
+export function MostZappedNote({ stats, className, style }: { stats: Npub2024InReviewStats, className?: string, style?: React.CSSProperties }) {
+ const time = useTime()
+ const ref = useRef<HTMLDivElement>(null)
+ const { scrollYProgress } = useScroll({
+ target: ref
+ })
+ const [animatedZapAmount, setAnimatedZapAmount] = useState(10000);
+ const zapProgress = useTransform(
+ scrollYProgress,
+ [0.4, 0.6],
+ [
+ 0.0,
+ 1.0
+ ],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ )
+ const headingOpacity = useTransform(
+ scrollYProgress,
+ [0, 0.4],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+ const secondaryContentOpacity = useTransform(
+ scrollYProgress,
+ [0.4, 0.6],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+
+ useInterval(() => {
+ if (zapProgress.get() > 0.0001) {
+ setAnimatedZapAmount(stats.most_zapped_post_sats)
+ }
+ else {
+ setAnimatedZapAmount(10000)
+ }
+ }, 500)
+
+ return <div
+ ref={ref}
+ className={cn("container z-30 mx-auto px-4 pt-12 h-full min-h-screen flex flex-col gap-y-4 justify-center items-center", className)}
+ style={style}
+ >
+ <motion.h2
+ className="text-4xl md:text-6xl text-center text-yellow-50 drop-shadow font-semibold break-keep tracking-tight z-30"
+ style={{
+ textShadow: "0 0 60px #facc15",
+ opacity: headingOpacity,
+ }}
+ >
+ Your most zapped note received
+ </motion.h2>
+ <motion.h3
+ className="flex items-center gap-4 text-3xl md:text-8xl text-center text-white font-semibold break-keep tracking-tight z-30"
+ style={{
+ opacity: secondaryContentOpacity,
+ }}
+ >
+ <motion.span
+ style={{ opacity: secondaryContentOpacity }}
+ >
+ <ZapIcon className="w-8 h-8 md:w-16 md:h-16 text-amber-300" />
+ </motion.span>
+ <NumberFlow
+ value={animatedZapAmount}
+ continuous={true}
+ transformTiming={{ duration: 1500, easing: "ease-out" }}
+ opacityTiming={{ duration: 1500, easing: "ease-out" }}
+ trend={1}
+ />
+ <motion.span
+ className="text-amber-300"
+ style={{ opacity: secondaryContentOpacity }}
+ >
+ sats
+ </motion.span>
+ </motion.h3>
+ <motion.div
+ className="space-y-4 z-30 w-full max-w-lg"
+ style={{ opacity: secondaryContentOpacity }}
+ >
+ <NostrNoteView note={stats.most_zapped_post} className="w-full max-w-lg" />
+ </motion.div>
+ </div>
+}
diff --git a/src/components/sections/2024-year-in-review/NumberOfPosts.tsx b/src/components/sections/2024-year-in-review/NumberOfPosts.tsx
@@ -0,0 +1,123 @@
+import { MotionValue, circOut, easeInOut, easeOut, motion, useMotionValue, useScroll, useTime, useTransform } from "framer-motion";
+import { ArrowDown, ZapIcon, StickyNote } from "lucide-react";
+import { NostrNoteView, ParsedNote } from "@/components/note/NostrNoteView";
+import { Npub2024InReviewStats } from "@/pages/purple/2024-in-review/[npub]";
+import { cn } from "@/lib/utils";
+import { useRef } from "react";
+import { Orbitron, Permanent_Marker } from "next/font/google";
+import NumberFlow from '@number-flow/react'
+import { useEffect, useState } from "react";
+import { useInterval } from "usehooks-ts";
+import Image from "next/image";
+
+// const permanentMarker = Permanent_Marker({ weight: "400", subsets: ['latin'] });
+
+export function NumberOfPosts({ numberOfPosts, className, style }: { numberOfPosts: number, className?: string, style?: React.CSSProperties }) {
+ const time = useTime()
+ const ref = useRef<HTMLDivElement>(null)
+ const { scrollYProgress } = useScroll({
+ target: ref
+ })
+ const [animatedPostAmount, setAnimatedPostAmount] = useState(10);
+ const postAmountProgress = useTransform(
+ scrollYProgress,
+ [0.4, 0.6],
+ [
+ 0.0,
+ 1.0
+ ],
+ {
+ clamp: true,
+ ease: circOut
+ }
+ )
+ const headingOpacity = useTransform(
+ scrollYProgress,
+ [0, 0.4],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+ const secondaryContentOpacity = useTransform(
+ scrollYProgress,
+ [0.4, 0.6],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+ const tertiaryContentOpacity = useTransform(
+ scrollYProgress,
+ [0.8, 1.0],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+
+ useInterval(() => {
+ if (postAmountProgress.get() > 0.0001) {
+ setAnimatedPostAmount(numberOfPosts)
+ }
+ else {
+ setAnimatedPostAmount(10)
+ }
+ }, 500)
+
+ return <div
+ ref={ref}
+ className={cn("container z-30 mx-auto px-4 pt-12 h-full min-h-screen flex flex-col gap-y-4 justify-center items-center", className)}
+ style={style}
+ >
+ <motion.h2
+ className={cn(
+ "text-4xl md:text-6xl text-center text-white font-semibold break-keep tracking-tight z-30",
+ // permanentMarker.className
+ )}
+ style={{
+ opacity: headingOpacity,
+ }}
+ >
+ You posted
+ </motion.h2>
+ <motion.h3
+ className="flex items-center gap-4 text-3xl md:text-8xl text-center text-white font-semibold break-keep tracking-tight z-30"
+ style={{
+ opacity: secondaryContentOpacity,
+ }}
+ >
+ <motion.span
+ style={{ opacity: secondaryContentOpacity }}
+ >
+ <StickyNote className="w-8 h-8 md:w-16 md:h-16 text-purple-400" />
+ </motion.span>
+ <NumberFlow
+ value={animatedPostAmount}
+ continuous={true}
+ transformTiming={{ duration: 1500, easing: "ease-out" }}
+ opacityTiming={{ duration: 1500, easing: "ease-out" }}
+ trend={1}
+ />
+ <motion.span
+ className="text-purple-400"
+ style={{ opacity: secondaryContentOpacity }}
+ >
+ notes
+ </motion.span>
+ </motion.h3>
+ <motion.div
+ className="space-y-4 z-30"
+ style={{ opacity: tertiaryContentOpacity }}
+ >
+ {numberOfPosts > 200 ?
+ <Image src="/2024-in-review/cat-typing.webp" alt="Cat typing" width={500} height={500} />
+ :
+ <Image src="/2024-in-review/ostrich-dancing.webp" alt="Cat typing" width={500} height={500} />
+ }
+ </motion.div>
+ </div>
+}
diff --git a/src/components/sections/2024-year-in-review/StarField.tsx b/src/components/sections/2024-year-in-review/StarField.tsx
@@ -0,0 +1,124 @@
+/*
+The code below was taken from https://codepen.io/ChuckWines/pen/OJPEzqN and licensed under the MIT License.
+
+The code was further modified to be used in a React component with TypeScript.
+---------
+Copyright (c) 2024 by Charles Wines (https://codepen.io/ChuckWines/pen/OJPEzqN)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+import { MotionValue, useScroll, useTransform } from 'framer-motion';
+import React, { useEffect, useRef, useState } from 'react';
+
+const STAR_COUNT = 300;
+const STAR_MIN_SPEED = 0.005;
+const STAR_MAX_SPEED = 0.02;
+const MIN_VISION_PERSISTENCE = 0.2;
+const MAX_VISION_PERSISTENCE = 0.99;
+
+export function StarField({ className, scrollYProgress }: { className?: string, scrollYProgress: MotionValue<number> }) {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const contextRef = useRef<CanvasRenderingContext2D | null>(null);
+ const speed = useTransform(scrollYProgress, [0, 0.3], [STAR_MIN_SPEED, STAR_MAX_SPEED]);
+ const visionPersistence = useTransform(scrollYProgress, [0, 0.3], [MIN_VISION_PERSISTENCE, MAX_VISION_PERSISTENCE]);
+
+ class Star {
+ x: number;
+ y: number;
+ prevX: number;
+ prevY: number;
+ z: number;
+
+ constructor(canvasWidth: number, canvasHeight: number) {
+ this.x = Math.random() * canvasWidth - canvasWidth / 2;
+ this.y = Math.random() * canvasHeight - canvasHeight / 2;
+ this.prevX = this.x;
+ this.prevY = this.y;
+ this.z = Math.random() * 4;
+ }
+
+ update(speed: number, canvasWidth: number, canvasHeight: number) {
+ this.prevX = this.x;
+ this.prevY = this.y;
+ this.z += speed;
+ this.x += this.x * (speed * 0.2) * this.z;
+ this.y += this.y * (speed * 0.2) * this.z;
+ if (
+ this.x > canvasWidth / 2 + 50 ||
+ this.x < -canvasWidth / 2 - 50 ||
+ this.y > canvasHeight / 2 + 50 ||
+ this.y < -canvasHeight / 2 - 50
+ ) {
+ this.x = Math.random() * canvasWidth - canvasWidth / 2;
+ this.y = Math.random() * canvasHeight - canvasHeight / 2;
+ this.prevX = this.x;
+ this.prevY = this.y;
+ this.z = 0;
+ }
+ }
+
+ show(context: CanvasRenderingContext2D) {
+ context.lineWidth = this.z;
+ context.beginPath();
+ context.moveTo(this.x, this.y);
+ context.lineTo(this.prevX, this.prevY);
+ context.stroke();
+ }
+ }
+
+ const getContext = () => {
+ if (!contextRef.current && canvasRef.current) {
+ let context = canvasRef.current.getContext('2d');
+ if (!context) throw new Error('Failed to get canvas context');
+ contextRef.current = context;
+ return context;
+ }
+ else {
+ if (!contextRef.current) throw new Error('Canvas context is not available');
+ return contextRef.current;
+ }
+ }
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const context = getContext();
+
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+
+ let stars: Star[] = [];
+ for (let i = 0; i < STAR_COUNT; i++) {
+ stars.push(new Star(canvas.width, canvas.height));
+ }
+
+ context.fillStyle = `rgba(0, 0, 0, ${1 - visionPersistence.get()})`;
+ context.strokeStyle = 'rgba(235, 215, 255)';
+ context.translate(canvas.width / 2, canvas.height / 2);
+
+ function draw() {
+ if (!context) return;
+ if (!canvas) return;
+ context.fillStyle = `rgba(0, 0, 0, ${1 - visionPersistence.get()})`;
+ context.fillRect(-canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height);
+ for (let star of stars) {
+ star.update(speed.get(), canvas.width, canvas.height);
+ star.show(context);
+ }
+ requestAnimationFrame(draw);
+ }
+
+ draw();
+ }, []);
+
+ return <canvas ref={canvasRef} className={className} style={{
+ zIndex: -1,
+ }} />;
+};
+
+export default StarField;
diff --git a/src/components/sections/2024-year-in-review/Year2024Intro.tsx b/src/components/sections/2024-year-in-review/Year2024Intro.tsx
@@ -0,0 +1,78 @@
+import { MotionValue, circOut, easeInOut, easeOut, motion, useMotionValue, useScroll, useTime, useTransform } from "framer-motion";
+import { PurpleIcon } from "../../icons/PurpleIcon";
+import { ArrowDown } from "lucide-react";
+import { Orbitron } from "next/font/google";
+import { cn } from "@/lib/utils";
+import NumberFlow from '@number-flow/react'
+import { MutableRefObject, RefObject, useEffect, useRef, useState } from "react";
+
+const orbitron = Orbitron({ subsets: ['latin'] })
+
+export function Year2024Intro() {
+ const time = useTime()
+ const [year, setYear] = useState<number>(1971)
+ const headingOpacity = useTransform(
+ time,
+ [0, 3000],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+ const secondaryContentOpacity = useTransform(
+ time,
+ [2000, 4000],
+ [0, 1],
+ {
+ clamp: true,
+ ease: easeInOut
+ }
+ )
+
+ useEffect(() => {
+ setTimeout(() => {
+ setYear(2024)
+ }, 500)
+ }, [])
+
+ return <div className="container z-30 mx-auto px-6 pt-12 h-auto min-h-screen flex flex-col gap-y-4 justify-center items-center">
+ <motion.div
+ style={{ opacity: secondaryContentOpacity }}
+ >
+ <PurpleIcon className="w-16 h-16" />
+ </motion.div>
+ <motion.h1
+ className={cn("text-6xl md:text-9xl text-center text-white font-bold break-keep tracking-tight z-30", orbitron.className)}
+ style={{
+ opacity: headingOpacity,
+ }}
+ >
+ <NumberFlow
+ value={year}
+ format={{ useGrouping: false }}
+ continuous={true}
+ transformTiming={{ duration: 1000, easing: "ease-out" }}
+ opacityTiming={{ duration: 1000, easing: "ease-out" }}
+ trend={1}
+ />
+ </motion.h1>
+ <motion.div
+ className="text-center text-purple-200/80 text-lg max-w-lg p-6 space-y-4 z-30"
+ style={{ opacity: secondaryContentOpacity }}
+ >
+ <p>
+ 2024 was a great year! We continue to fight for a freer, healthier, and more open social network, and as a Purple member you are a key part of that mission!
+ </p>
+ <p>
+ So we would like to extend and share our gratitude with you — and share some nice memories from the past year.
+ </p>
+ <p className="text-3xl">
+ Let’s rewind!!
+ </p>
+ <div className="pt-8 flex gap-3 w-full justify-center">
+ <ArrowDown className="w-12 h-12 animate-bounce" />
+ </div>
+ </motion.div>
+ </div>
+}
diff --git a/src/pages/purple/2024-in-review/[npub].tsx b/src/pages/purple/2024-in-review/[npub].tsx
@@ -0,0 +1,80 @@
+import { ParsedNote } from "@/components/note/NostrNoteView";
+import { useEffect, useState } from "react";
+import English from "@/../content/compiled-locales/en.json";
+import { IntlProvider, useIntl } from 'react-intl'
+import { Purple2024YearInReview } from "@/components/pages/purple-2024-year-in-review";
+
+export default function User2024InReviewPage({ stats }: { stats: Npub2024InReviewStats }) {
+ // Automatically detect the user's locale based on their browser settings
+ const [language, setLanguage] = useState("en");
+ const [messages, setMessages] = useState(English);
+
+ useEffect(() => {
+ setLanguage(navigator.language);
+ }, []);
+
+ useEffect(() => {
+ switch (language) {
+ case "en":
+ setMessages(English);
+ break;
+ case "ja":
+ // TODO: Add Japanese translations and then switch to "Japanese" below
+ setMessages(English);
+ break;
+ default:
+ setMessages(English);
+ break;
+ }
+ }, [language]);
+
+ return (<>
+ <IntlProvider
+ locale={language}
+ messages={messages}
+ onError={() => null}>
+ <Purple2024YearInReview stats={stats} />
+ </IntlProvider>
+ </>)
+}
+
+
+type Purple2024InReviewStats = Record<npub, Npub2024InReviewStats>;
+type npub = string;
+export type Npub2024InReviewStats = {
+ most_zapped_post: ParsedNote
+ most_zapped_post_sats: number
+ number_of_posts?: number
+};
+
+const PLACEHOLDER_STATS: Purple2024InReviewStats = {
+ "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s": {
+ most_zapped_post: { "note": { "id": "28e9dcfe183c785596b78c93ff1f73d08d05bf13d44e9b911c61e905661d33fc", "pubkey": "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91", "created_at": 1735794797, "kind": 1, "tags": [["imeta", "url https://image.nostr.build/6b17010ed666adc02b09d6d4b25e22afe3c0dc1d509dd8c991dcd8813a250ff3.jpg", "blurhash eBDu-,~A57S5IV4;xugMxZo0Xlox}@%2%LJ8={={xtae^*%LsSNHof", "dim 4284x5712"], ["t", "nostr"], ["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"], ["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"], ["r", "https://image.nostr.build/6b17010ed666adc02b09d6d4b25e22afe3c0dc1d509dd8c991dcd8813a250ff3.jpg"]], "content": "#nostr FTW in 2025. Ya? Lol we’re grinding. We believe in this so much. I don’t always do this but an appreciation note for nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s is needed. He’s busting his ass every day for our family and for freedom. Ya he’s sometimes a bit grouchy but it’s usually because he’s overextended himself. And/or it’s something he feels really strongly about. I love you nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s. Thanks for caring. About everything that’s important. The damus team is pumped for 2025! https://image.nostr.build/6b17010ed666adc02b09d6d4b25e22afe3c0dc1d509dd8c991dcd8813a250ff3.jpg ", "sig": "f0fa73314a3c02e6be1741f654e4c0f66d39e50a64129e98617cbf07fdcc9f57200adc731257a60beea62e241f04b314ac3d3d16d7bfb099b21e63d37d975bb5" }, "parsed_content": [{ "hashtag": "nostr" }, { "text": " FTW in 2025. Ya? Lol we’re grinding. We believe in this so much. I don’t always do this but an appreciation note for " }, { "mention": "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s" }, { "text": " is needed. He’s busting his ass every day for our family and for freedom. Ya he’s sometimes a bit grouchy but it’s usually because he’s overextended himself. And/or it’s something he feels really strongly about. I love you " }, { "mention": "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s" }, { "text": ". Thanks for caring. About everything that’s important. The damus team is pumped for 2025! " }, { "url": "https://image.nostr.build/6b17010ed666adc02b09d6d4b25e22afe3c0dc1d509dd8c991dcd8813a250ff3.jpg" }, { "text": " " }], "profile": { "id": "190e9d5daca0de68d4577ce9e72c6ac1028d6b65800c72e9e6cc1904068cf4e0", "pubkey": "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91", "created_at": 1689715382, "kind": 0, "tags": [], "content": "{\"banner\":\"https://cdn.nostr.build/i/090628cf9bcc539d47ce26238e6fee6e868f9ac5dab06a681e74788f1c97182e.jpg\",\"website\":\"\",\"damus_donation_v2\":56,\"reactions\":true,\"nip05\":\"vrod@damus.io\",\"picture\":\"https://cdn.nostr.build/i/b70f3d55a5f29a8f9178145cb3c05e2e6a77a62d2149bfb3da3121399106dd10.jpg\",\"damus_donation\":56,\"lud16\":\"vanessagray31@getalby.com\",\"display_name\":\"Vanessa\",\"about\":\"Marketer, mom, living at @damus headquarters\",\"name\":\"vrod\"}", "sig": "4921cd0e975571568b39221ccdff13007d8dad01e9fd7574197c73ac479a1447ff296fd5d1ea4453150d0e5f9aeb45244aee309fa95beb0cc373134eac349b58" } },
+ most_zapped_post_sats: 1524622,
+ number_of_posts: 321,
+ },
+ "npub1gz7uczyg3kvdf8grlwfmllguc3kehrcc05yvnlypkjklptgql5kqa0zkqj": {
+ most_zapped_post: { "note": { "id": "8c149bb0b500b41c8a1d8ac6e95e913cc495c141cb0427b8c87b9249551ea51c", "pubkey": "40bdcc08888d98d49d03fb93bffd1cc46d9b8f187d08c9fc81b4adf0ad00fd2c", "created_at": 1735749161, "kind": 1, "tags": [["p", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", "", "mention"], ["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "", "mention"], ["p", "d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a", "", "mention"], ["p", "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", "", "mention"], ["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "", "mention"], ["p", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6", "", "mention"], ["p", "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", "", "mention"], ["p", "ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49", "", "mention"], ["p", "e4f695f05bb05b231255ccce3d471b8d79c64a65bccc014662d27f0f7e921092", "", "mention"], ["p", "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1", "", "mention"], ["p", "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c", "", "mention"], ["p", "a80455732d5bfa792f279011a8c871853182971994752b9cf1169611ff91a578", "", "mention"], ["p", "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb", "", "mention"], ["p", "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", "", "mention"]], "content": "Two years ago, the world felt chaotic, heading in a dangerous direction. I remember seeing a conversation on Stacker News about Nostr, and something clicked—I knew it was our best shot at course-correcting the mess the internet was becoming. I knew I had to at least have a front row seat. \n\nMy first note was at block 769296 (12/28/22). Within weeks, I was essentially Nostr-only, leaving Xitter and Instagram behind. Since then, I’ve watched Nostr innovate at a pace that still feels magical.\n\nI want to thank all the builders—nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:npub16c0nh3dnadzqpm76uctf5hqhe2lny344zsmpm6feee9p5rdxaa9q586nvr nostr:npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 nostr:npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2ucnostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6 nostr:npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn nostr:npub1acg6thl5psv62405rljzkj8spesceyfz2c32udakc2ak0dmvfeyse9p35c nostr:npub1unmftuzmkpdjxyj4en8r63cm34uuvjn9hnxqz3nz6fls7l5jzzfqtvd0j2 nostr:npub1yaul8k059377u9lsu67de7y637w4jtgeuwcmh5n7788l6xnlnrgs3tvjmf nostr:npub137c5pd8gmhhe0njtsgwjgunc5xjr2vmzvglkgqs5sjeh972gqqxqjak37w nostr:npub14qz92uedt0a8jte8jqg63jr3s5cc99cej36jh883z6tprlu354uqqe2q26 nostr:npub1utx00neqgqln72j22kej3ux7803c2k986henvvha4thuwfkper4s7r50e8 nostr:npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m (I am absolutely missing obvious people sorry this is from memory and it was a long night 😆). You’re all incredible.\n\n happy new year and heres to 2025 ☕", "sig": "b1e0a05003884267f21c054d24e0486a3878fe9d645914080bb72bfefeb693f9c321c3f683c236cff0a6a756aa20f92fa94b88e86bdced96a2dc574a0fdc0aec" }, "parsed_content": [{ "text": "Two years ago, the world felt chaotic, heading in a dangerous direction. I remember seeing a conversation on Stacker News about Nostr, and something clicked—I knew it was our best shot at course-correcting the mess the internet was becoming. I knew I had to at least have a front row seat. \n\nMy first note was at block 769296 (12/28/22). Within weeks, I was essentially Nostr-only, leaving Xitter and Instagram behind. Since then, I’ve watched Nostr innovate at a pace that still feels magical.\n\nI want to thank all the builders—" }, { "mention": "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" }, { "text": " " }, { "mention": "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s" }, { "text": " " }, { "mention": "npub16c0nh3dnadzqpm76uctf5hqhe2lny344zsmpm6feee9p5rdxaa9q586nvr" }, { "text": " " }, { "mention": "npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft" }, { "text": " " }, { "mention": "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6" }, { "text": " " }, { "text": "npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2ucnostr" }, { "text": ":" }, { "mention": "npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5" }, { "text": " " }, { "mention": "npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6" }, { "text": " " }, { "mention": "npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn" }, { "text": " " }, { "mention": "npub1acg6thl5psv62405rljzkj8spesceyfz2c32udakc2ak0dmvfeyse9p35c" }, { "text": " " }, { "mention": "npub1unmftuzmkpdjxyj4en8r63cm34uuvjn9hnxqz3nz6fls7l5jzzfqtvd0j2" }, { "text": " " }, { "mention": "npub1yaul8k059377u9lsu67de7y637w4jtgeuwcmh5n7788l6xnlnrgs3tvjmf" }, { "text": " " }, { "mention": "npub137c5pd8gmhhe0njtsgwjgunc5xjr2vmzvglkgqs5sjeh972gqqxqjak37w" }, { "text": " " }, { "mention": "npub14qz92uedt0a8jte8jqg63jr3s5cc99cej36jh883z6tprlu354uqqe2q26" }, { "text": " " }, { "mention": "npub1utx00neqgqln72j22kej3ux7803c2k986henvvha4thuwfkper4s7r50e8" }, { "text": " " }, { "mention": "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m" }, { "text": " (I am absolutely missing obvious people sorry this is from memory and it was a long night 😆). You’re all incredible.\n\n happy new year and heres to 2025 ☕" }], "profile": { "id": "053db89c0278a14ff63fcb1b2084d428844b7b7b8e31db6d8619159b151ec68d", "pubkey": "40bdcc08888d98d49d03fb93bffd1cc46d9b8f187d08c9fc81b4adf0ad00fd2c", "created_at": 1735660989, "kind": 0, "tags": [], "content": "{\"name\":\"Dan\",\"about\":\"Est. 769,296\",\"deleted\":true,\"display_name\":\"Dan\",\"picture\":\"https://m.primal.net/NLaY.jpg\",\"banner\":\"https://i.nostr.build/ExYm.jpg\",\"nip05\":\"TheFirstDan@primal.net\",\"lud16\":\"djs@getalby.com\",\"displayName\":\"Dan\",\"pubkey\":\"40bdcc08888d98d49d03fb93bffd1cc46d9b8f187d08c9fc81b4adf0ad00fd2c\",\"npub\":\"npub1gz7uczyg3kvdf8grlwfmllguc3kehrcc05yvnlypkjklptgql5kqa0zkqj\",\"created_at\":1735568766,\"userStats\":{\"pubkey\":\"40bdcc08888d98d49d03fb93bffd1cc46d9b8f187d08c9fc81b4adf0ad00fd2c\",\"follows_count\":1239,\"followers_count\":2158,\"note_count\":1725,\"long_form_note_count\":0,\"reply_count\":2738,\"time_joined\":1672240426,\"relay_count\":7,\"total_zap_count\":740,\"total_satszapped\":120853,\"media_count\":579,\"content_zap_count\":216}}", "sig": "f84cf0a3aa33a3710e47adc9c95c5c1b4f0b33b1580b09441a273ec03a43394ffd7b94e7c79600856fd6053f57f2b964b24babc488122be8f479f55cab729281" } },
+ most_zapped_post_sats: 837297,
+ number_of_posts: 178,
+ }
+};
+
+export async function getStaticPaths() {
+ let stats: Purple2024InReviewStats = PLACEHOLDER_STATS; // TODO: Fetch from API
+ const paths = Object.keys(stats).map((npub) => ({ params: { npub } }));
+ return { paths, fallback: false };
+}
+
+type Params = {
+ params: {
+ npub: string;
+ };
+};
+
+// This also gets called at build time
+export async function getStaticProps({ params }: Params) {
+ const stats: Purple2024InReviewStats = PLACEHOLDER_STATS; // TODO: Fetch from API
+ const npub = params.npub as string;
+ return { props: { stats: stats[npub] } };
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
@@ -1,3 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&display=swap');