diff --git a/.gitea/workflows/deploy-website.yml b/.gitea/workflows/deploy-website.yml index a3d38df..88b7874 100644 --- a/.gitea/workflows/deploy-website.yml +++ b/.gitea/workflows/deploy-website.yml @@ -9,6 +9,7 @@ on: - projects/website/** - projects/common/** - .gitea/workflows/deploy-website.yml + - bun.lockb jobs: docker: diff --git a/bun.lockb b/bun.lockb old mode 100755 new mode 100644 index 49524e8..b4b20e1 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/projects/website/package.json b/projects/website/package.json index e08d955..bbbabb1 100644 --- a/projects/website/package.json +++ b/projects/website/package.json @@ -36,7 +36,7 @@ "cross-env": "^7.0.3", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", - "framer-motion": "^11.5.4", + "framer-motion": "^11.11.10", "js-cookie": "^3.0.5", "ky": "^1.7.2", "lucide-react": "^0.453.0", diff --git a/projects/website/src/app/(pages)/page.tsx b/projects/website/src/app/(pages)/page.tsx index 5b99ff6..f2e0ccb 100644 --- a/projects/website/src/app/(pages)/page.tsx +++ b/projects/website/src/app/(pages)/page.tsx @@ -6,9 +6,9 @@ import RealtimeScores from "@/components/home/realtime-scores"; export default async function HomePage() { return ( -
+
-
+
diff --git a/projects/website/src/app/layout.tsx b/projects/website/src/app/layout.tsx index b6f1b5b..cf9f2f9 100644 --- a/projects/website/src/app/layout.tsx +++ b/projects/website/src/app/layout.tsx @@ -1,5 +1,4 @@ import "./globals.css"; -import Footer from "@/components/footer"; import { PreloadResources } from "@/components/preload-resources"; import { QueryProvider } from "@/components/providers/query-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; @@ -14,6 +13,8 @@ import { Colors } from "@/common/colors"; import OfflineNetwork from "@/components/offline-network"; import Script from "next/script"; import { ApiHealth } from "@/components/api/api-health"; +import Footer from "@/components/footer"; +import { getBuildInformation } from "@/common/website-utils"; const siteFont = localFont({ src: "./fonts/JetBrainsMono.ttf", @@ -66,6 +67,7 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const { buildId, buildTimeShort } = getBuildInformation(); return ( @@ -84,7 +86,8 @@ export default function RootLayout({
{children}
-
+ {/*
*/} +
diff --git a/projects/website/src/common/song-utils.ts b/projects/website/src/common/song-utils.ts index da5d607..8bbd965 100644 --- a/projects/website/src/common/song-utils.ts +++ b/projects/website/src/common/song-utils.ts @@ -1,6 +1,6 @@ import { MapDifficulty } from "@ssr/common/score/map-difficulty"; -type Difficulty = { +export type Difficulty = { /** * The name of the difficulty */ @@ -63,14 +63,21 @@ export function getScoreBadgeFromAccuracy(acc: number): ScoreBadge { return scoreBadges[scoreBadges.length - 1]; } +/** + * Get a random difficulty, except ExpertPlus. + */ +export function getRandomDifficulty(): Difficulty { + return difficulties[Math.floor(Math.random() * (difficulties.length - 1))]; +} + /** * Gets a {@link Difficulty} from its name * * @param diff the name of the difficulty * @returns the difficulty */ -export function getDifficulty(diff: MapDifficulty) { - const difficulty = difficulties.find(d => d.name === diff); +export function getDifficulty(diff: Difficulty | MapDifficulty) { + const difficulty = difficulties.find(d => d.name === (typeof diff === "string" ? diff : diff.name)); if (!difficulty) { throw new Error(`Unknown difficulty: ${diff}`); } diff --git a/projects/website/src/common/utils.ts b/projects/website/src/common/utils.ts index e6e733e..5b003f0 100644 --- a/projects/website/src/common/utils.ts +++ b/projects/website/src/common/utils.ts @@ -19,3 +19,10 @@ export function validateUrl(url: string) { return false; } } + +export function getRandomInteger(min: number, max: number): number { + min = Math.ceil(min); + max = Math.floor(max); + + return Math.floor(Math.random() * (max - min)) + min; +} diff --git a/projects/website/src/components/footer.tsx b/projects/website/src/components/footer.tsx index 53a09bb..11c538a 100644 --- a/projects/website/src/components/footer.tsx +++ b/projects/website/src/components/footer.tsx @@ -1,78 +1,176 @@ -import { getBuildInformation } from "@/common/website-utils"; -import Link from "next/link"; +"use client"; -type NavbarItem = { +import Link from "next/link"; +import { ExternalLink } from "lucide-react"; +import { cn } from "@/common/utils"; +import { ReactElement } from "react"; +import { SiGithub, SiX } from "react-icons/si"; +import { usePathname } from "next/navigation"; + +type FooterLink = { + /** + * The name of this link. + */ name: string; - link: string; - openInNewTab?: boolean; + + /** + * The href for this link. + */ + href: string; + + /** + * The optional name to show + * when the screen size is small. + */ + shortName?: string; }; -const items: NavbarItem[] = [ - { - name: "Home", - link: "/", - }, - { - name: "Source", - link: "https://git.fascinated.cc/Fascinated/scoresaber-reloadedv3", - openInNewTab: true, - }, +type SocialLinkType = { + /** + * The name of this social link. + */ + name: string; + + /** + * The logo for this social link. + */ + logo: ReactElement; + + /** + * The href for this social link. + */ + href: string; +}; + +const links: { + [category: string]: FooterLink[]; +} = { + Resources: [ + { + name: "Swagger Docs", + shortName: "Swagger", + href: "/swagger", + }, + { + name: "Source Code", + shortName: "Source", + href: "https://git.fascinated.cc/Fascinated/scoresaber-reloadedv3", + }, + { + name: "System Status", + shortName: "Status", + href: "https://status.fascinated.cc/status/scoresaber-reloaded", + }, + ], + App: [ + { + name: "Score Feed", + href: "/scores/live", + }, + { + name: "Top Scores", + href: "/scores/top/weekly", + }, + ], +}; + +const socialLinks: SocialLinkType[] = [ { name: "Twitter", - link: "https://x.com/ssr_reloaded", - openInNewTab: true, + logo: , + href: "https://x.com/ssr_reloaded", }, { name: "Discord", - link: "https://discord.gg/kmNfWGA4A8", - openInNewTab: true, + logo: , + href: "https://discord.gg/kmNfWGA4A8", }, { - name: "Status", - link: "https://status.fascinated.cc/status/scoresaber-reloaded", - openInNewTab: true, - }, - { - name: "Swagger", - link: "/swagger", - openInNewTab: true, - }, - { - name: "Score Feed", - link: "/scores/live", - openInNewTab: false, - }, - { - name: "Top Scores", - link: "/scores/top/weekly", - openInNewTab: false, + name: "GitHub", + logo: , + href: "https://git.fascinated.cc/Fascinated/scoresaber-reloadedv3", }, ]; -export default function Footer() { - const { buildId, buildTime, buildTimeShort } = getBuildInformation(); - +export default function Footer({ buildId, buildTimeShort }: { buildId: string; buildTimeShort: string | undefined }) { + const isHome: boolean = usePathname() === "/"; return ( -
-
-

Build: {buildId}

-

({buildTime})

-

({buildTimeShort})

+
+ {/* Top Section */} +
+ {/* Branding & Social Links */} +
+
+ {/* Branding */} +
+ + Scoresaber Logo +

ScoreSaber Reloaded

+ +

+ ScoreSaber Reloaded is a new way to view your scores and get more stats about you and your plays +

+
+ + {/* Social Links */} +
+ {socialLinks.map(link => ( + + {link.logo} + + ))} +
+
+ + {/* Links */} +
+ {Object.entries(links).map(([title, links]) => ( +
+

{title}

+ {links.map(link => { + const external: boolean = !link.href.startsWith("/"); + return ( + + {link.name} + {link.shortName && {link.shortName}} + {external && } + + ); + })} +
+ ))} +
+
-
- {items.map((item, index) => { - return ( - - {item.name} - - ); - })} + + {/* Bottom Section */} +
+ {/* Build Info */} +

+ Build {buildId} ({buildTimeShort}) +

-
+
); } diff --git a/projects/website/src/components/home/data-collection.tsx b/projects/website/src/components/home/data-collection.tsx index 5c8f5bd..bd75f11 100644 --- a/projects/website/src/components/home/data-collection.tsx +++ b/projects/website/src/components/home/data-collection.tsx @@ -2,20 +2,22 @@ import { Database } from "lucide-react"; export default function DataCollection() { return ( -
+
{/* Header */}
-
- -

Data Collection

+
+ +

Data Collection

-

posidonium novum ancillae ius conclusionemque splendide vel.

+

+ posidonium novum ancillae ius conclusionemque splendide vel. +

{/* Content */}
Data Collection {/* Header */} -
-
- -

Friends

+
+
+ +

Friends

-

posidonium novum ancillae ius conclusionemque splendide vel.

+

+ posidonium novum ancillae ius conclusionemque splendide vel. +

{/* Content */} -
- Friends +
+
+ Friends +
); diff --git a/projects/website/src/components/home/hero.tsx b/projects/website/src/components/home/hero.tsx index 68ff029..98d0fc3 100644 --- a/projects/website/src/components/home/hero.tsx +++ b/projects/website/src/components/home/hero.tsx @@ -1,3 +1,5 @@ +"use client"; + import AnimatedShinyText from "@/components/ui/animated-shiny-text"; import { ArrowRight, UserSearch } from "lucide-react"; import Link from "next/link"; @@ -5,15 +7,23 @@ import { Button } from "@/components/ui/button"; import { SiGithub } from "react-icons/si"; import { BorderBeam } from "@/components/ui/border-beam"; import { Separator } from "@/components/ui/separator"; +import { motion } from "framer-motion"; export default function HeroSection() { return (
- - + <motion.div + className="flex flex-col gap-3.5 text-center items-center" + initial={{ opacity: 0, y: -40 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.5, ease: "easeOut" }} + > + <Alert /> + <Title /> + </motion.div> <Buttons /> <AppPreview /> - <Separator className="my-14 w-screen" /> + <Separator className="my-12 w-screen" /> </div> ); } @@ -42,7 +52,7 @@ function Title() { ScoreSaber Reloaded </h1> <p className="max-w-sm md:max-w-xl md:text-lg opacity-85"> - Scoresaber Reloaded is a new way to view your scores and get more stats about your and your plays + ScoreSaber Reloaded is a new way to view your scores and get more stats about you and your plays </p> </> ); @@ -50,8 +60,13 @@ function Title() { function Buttons() { return ( - <div className="mt-4 flex gap-4"> - <Link href="https://discord.gg/kmNfWGA4A8" target="_blank"> + <motion.div + className="mt-4 flex flex-col xs:flex-row gap-4 items-center" + initial={{ opacity: 0, y: -20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.35, duration: 0.7, ease: "easeOut" }} + > + <Link href="/search" target="_blank"> <Button className="max-w-52 flex gap-2.5 bg-pp hover:bg-pp/85 text-white text-base"> <UserSearch className="size-6" /> <span>Player Search</span> @@ -64,19 +79,24 @@ function Buttons() { <span>Join our Discord</span> </Button> </Link> - </div> + </motion.div> ); } function AppPreview() { return ( - <div className="mx-5 my-24 relative max-w-[1280px] shadow-[0_3rem_20rem_-15px_rgba(15,15,15,0.6)] shadow-pp/50 rounded-xl overflow-hidden"> + <motion.div + className="mx-5 my-20 relative max-w-[1280px] shadow-[0_3rem_20rem_-15px_rgba(15,15,15,0.6)] shadow-pp/50 rounded-2xl overflow-hidden" + initial={{ opacity: 0, y: -35 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.45, duration: 0.7, ease: "easeOut" }} + > <BorderBeam colorFrom="#6773ff" colorTo="#4858ff" /> <img - className="w-full h-full border-4 border-pp/20 rounded-xl" + className="w-full h-full border-4 border-pp/20 rounded-2xl" src="/assets/home/app-preview.png" draggable={false} /> - </div> + </motion.div> ); } diff --git a/projects/website/src/components/home/realtime-scores.tsx b/projects/website/src/components/home/realtime-scores.tsx index 76dd43b..4a12d3a 100644 --- a/projects/website/src/components/home/realtime-scores.tsx +++ b/projects/website/src/components/home/realtime-scores.tsx @@ -1,26 +1,122 @@ -import { Database } from "lucide-react"; +import { ChartNoAxesCombined, Database, Flame } from "lucide-react"; +import { cn, getRandomInteger } from "@/common/utils"; +import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; +import { Difficulty, getDifficulty, getRandomDifficulty } from "@/common/song-utils"; +import { AnimatedList } from "@/components/ui/animated-list"; + +type ScoreProps = { + songArt: string; + songName: string; + songAuthor: string; + setBy: string; +}; + +let scores: ScoreProps[] = [ + { + songArt: "https://cdn.scoresaber.com/covers/B1D3FA6D5305837DF59B5E629A412DEBC68BBB46.png", + songName: "LORELEI", + songAuthor: "Camellia", + setBy: "ImFascinated", + }, + { + songArt: "https://cdn.scoresaber.com/covers/7C44CDC1E33E2F5F929867B29CEB3860C3716DDC.png", + songName: "Time files", + songAuthor: "xi", + setBy: "Minion", + }, + { + songArt: "https://cdn.scoresaber.com/covers/8E4B7917C01E5987A5B3FF13FAA3CA8F27D21D34.png", + songName: "RATATA", + songAuthor: "Skrillex, Missy Elliot & Mr. Oizo", + setBy: "Rainnny", + }, + { + songArt: "https://cdn.scoresaber.com/covers/98F73BD330852EAAEBDC695140EAC8F2027AEEC8.png", + songName: "Invasion of Amorphous Trepidation", + songAuthor: "Diabolic Phantasma", + setBy: "Bello", + }, + { + songArt: "https://cdn.scoresaber.com/covers/666EEAC0F3EEE2278DCB971AC1D27421A0335801.png", + songName: "Yotsuya-san ni Yoroshiku", + songAuthor: "Eight", + setBy: "ACC | NoneTaken", + }, +]; +scores = Array.from({ length: 32 }, () => scores).flat(); export default function RealtimeScores() { return ( - <div className="px-5 -mt-20 flex flex-col gap-10 items-end select-none"> + <div + className={cn( + "relative px-5 -mt-20 flex flex-col lg:flex-row-reverse gap-10 select-none", + "before:absolute before:-left-40 before:-bottom-36 before:size-[28rem] before:bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] before:from-yellow-600 before:rounded-full before:blur-3xl before:opacity-30" + )} + > {/* Header */} - <div className="flex flex-col gap-2.5 items-end"> - <div className="flex gap-3.5 items-center"> - <Database className="size-7 text-pp" /> - <h1 className="text-4xl font-bold text-ssr">Realtime Scores</h1> + <div className="flex flex-col gap-2.5 text-right items-end"> + <div className="flex flex-row-reverse gap-3 items-center text-yellow-400"> + <Flame className="p-2 size-11 bg-yellow-800/15 rounded-lg" /> + <h1 className="text-3xl sm:text-4xl font-bold">Realtime Scores</h1> </div> - <p className="opacity-85">posidonium novum ancillae ius conclusionemque splendide vel.</p> + <p className="max-w-2xl lg:max-w-5xl text-sm sm:text-base opacity-85"> + <span className="text-lg font-semibold text-yellow-500">Nec detracto voluptatibus!</span> Vulputate duis + dolorum iuvaret disputationi ceteros te noluisse himenaeos bibendum dolores molestiae lorem elaboraret porro + brute tation simul laudem netus odio has in tibique. + </p> </div> {/* Content */} - <div className="max-w-[900px]"> - <img - className="w-full h-full rounded-xl border border-ssr/20" - src="/assets/home/realtime-scores.png" - alt="Realtime Scores" - draggable={false} - /> + <div className="w-full flex flex-col justify-center items-center overflow-hidden"> + <AnimatedList className="w-full max-w-[32rem] h-96 divide-y divide-muted" delay={1500}> + {scores.map((score, index) => ( + <Score key={index} {...score} /> + ))} + </AnimatedList> </div> </div> ); } + +function Score({ songArt, songName, songAuthor, setBy }: ScoreProps) { + const difficulty: Difficulty = getRandomDifficulty(); + return ( + <figure className="py-2 flex flex-col text-sm"> + {/* Set By */} + <span> + Set by <span className="text-ssr">{setBy}</span> + </span> + + {/* Score */} + <div className="py-3 flex gap-5 items-center"> + {/* Position & Time */} + <div className="w-24 flex flex-col gap-1 text-center items-center"> + <div className="flex gap-2 items-center"> + <GlobeAmericasIcon className="size-5" /> + <span className="text-ssr">#{getRandomInteger(1, 900)}</span> + </div> + <span>just now</span> + </div> + + {/* Song Art & Difficulty */} + <div className="relative"> + <img className="size-16 rounded-md" src={songArt} alt={`Song art for ${songName} by ${songAuthor}`} /> + <div + className="absolute inset-x-0 bottom-0 py-px flex justify-center text-xs rounded-t-lg" + style={{ + backgroundColor: getDifficulty(difficulty).color + "f0", // Transparency value (in hex 0-255) + }} + > + {difficulty.name} + </div> + </div> + + {/* Song Name & Author */} + <div className="flex flex-col gap-1"> + <h1 className="text-ssr">{songName}</h1> + <p className="opacity-75">{songAuthor}</p> + </div> + </div> + </figure> + ); +} diff --git a/projects/website/src/components/home/site-stats.tsx b/projects/website/src/components/home/site-stats.tsx index 3dc17e3..fa76b6c 100644 --- a/projects/website/src/components/home/site-stats.tsx +++ b/projects/website/src/components/home/site-stats.tsx @@ -1,4 +1,4 @@ -import { Database } from "lucide-react"; +import { ChartNoAxesCombined, Database } from "lucide-react"; import { kyFetch } from "@ssr/common/utils/utils"; import { AppStatistics } from "@ssr/common/types/backend/app-statistics"; import { Config } from "@ssr/common/config"; @@ -10,11 +10,13 @@ export default async function SiteStats() { <div className="px-5 -mt-20 flex flex-col gap-10 select-none"> {/* Header */} <div className="flex flex-col gap-2.5"> - <div className="flex gap-3.5 items-center"> - <Database className="size-7 text-pp" /> - <h1 className="text-4xl font-bold text-ssr">Site Statistics</h1> + <div className="flex gap-3 items-center text-orange-600"> + <ChartNoAxesCombined className="p-2 size-11 bg-orange-800/15 rounded-lg" /> + <h1 className="text-3xl sm:text-4xl font-bold">Site Statistics</h1> </div> - <p className="opacity-85">posidonium novum ancillae ius conclusionemque splendide vel.</p> + <p className="max-w-5xl text-sm sm:text-base opacity-85"> + posidonium novum ancillae ius conclusionemque splendide vel. + </p> </div> {/* Content */} diff --git a/projects/website/src/components/home/statistic.tsx b/projects/website/src/components/home/statistic.tsx index e63d3ee..e8f1312 100644 --- a/projects/website/src/components/home/statistic.tsx +++ b/projects/website/src/components/home/statistic.tsx @@ -13,7 +13,7 @@ export default function Statistic({ icon, title, value }: Statistic) { return ( <div className="flex flex-col gap-2 text-center items-center text-lg"> {icon} - <h1 className="font-semibold text-ssr">{title}</h1> + <h1 className="font-semibold text-orange-400/85">{title}</h1> <span> <CountUp end={value} duration={1.2} enableScrollSpy scrollSpyOnce /> </span> diff --git a/projects/website/src/components/ui/animated-list.tsx b/projects/website/src/components/ui/animated-list.tsx new file mode 100644 index 0000000..4f045d8 --- /dev/null +++ b/projects/website/src/components/ui/animated-list.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React, { ReactElement, useEffect, useMemo, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; + +export interface AnimatedListProps { + className?: string; + children: React.ReactNode; + delay?: number; +} + +export const AnimatedList = React.memo( + ({ className, children, delay = 1000 }: AnimatedListProps) => { + const [index, setIndex] = useState(0); + const childrenArray = React.Children.toArray(children); + + useEffect(() => { + const interval = setInterval(() => { + setIndex((prevIndex) => (prevIndex + 1) % childrenArray.length); + }, delay); + + return () => clearInterval(interval); + }, [childrenArray.length, delay]); + + const itemsToShow = useMemo( + () => childrenArray.slice(0, index + 1).reverse(), + [index, childrenArray], + ); + + return ( + <div className={`flex flex-col items-center gap-4 ${className}`}> + <AnimatePresence> + {itemsToShow.map((item) => ( + <AnimatedListItem key={(item as ReactElement).key}> + {item} + </AnimatedListItem> + ))} + </AnimatePresence> + </div> + ); + }, +); + +AnimatedList.displayName = "AnimatedList"; + +export function AnimatedListItem({ children }: { children: React.ReactNode }) { + const animations = { + initial: { scale: 0, opacity: 0 }, + animate: { scale: 1, opacity: 1, originY: 0 }, + exit: { scale: 0, opacity: 0 }, + transition: { type: "spring", stiffness: 350, damping: 40 }, + }; + + return ( + <motion.div {...animations} layout className="mx-auto w-full"> + {children} + </motion.div> + ); +} diff --git a/projects/website/tailwind.config.ts b/projects/website/tailwind.config.ts index 0c5b861..e13c1e6 100644 --- a/projects/website/tailwind.config.ts +++ b/projects/website/tailwind.config.ts @@ -1,5 +1,8 @@ import type { Config } from "tailwindcss"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const defaultTheme = require("tailwindcss/defaultTheme"); + const config: Config = { darkMode: ["class"], content: [ @@ -8,6 +11,10 @@ const config: Config = { "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { + screens: { + xs: "475px", + ...defaultTheme.screens, + }, extend: { colors: { pp: "#4858ff",