diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 49ae93c7..2c0973e7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -71,6 +71,10 @@ jobs: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- - name: Install dependencies run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} + - name: Prebuild - Download AVM2 Report + run: node ./scripts/fetch-ruffle-report.js + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build with Next.js run: ${{ steps.detect-package-manager.outputs.runner }} next build env: diff --git a/package.json b/package.json index 43a0f292..21fe0de5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "node scripts/fetch-ruffle-report.js && next dev", + "prebuild": "node scripts/fetch-ruffle-report.js", "build": "next build", "start": "next start", "lint": "next lint" diff --git a/scripts/fetch-ruffle-report.js b/scripts/fetch-ruffle-report.js new file mode 100644 index 00000000..b5bbb235 --- /dev/null +++ b/scripts/fetch-ruffle-report.js @@ -0,0 +1,69 @@ +const fs = require("fs"); +const path = require("path"); +const { Readable } = require("stream"); +const { Octokit } = require("octokit"); +const { createTokenAuth } = require("@octokit/auth-token"); + +const owner = "ruffle-rs"; +const repo = "ruffle"; +const assetName = "avm2_report.json"; +const outputDir = path.resolve(__dirname, "..", "public"); +const outputFile = path.join(outputDir, assetName); + +function createOctokit() { + if (process.env.GITHUB_TOKEN) { + const auth = createTokenAuth(process.env.GITHUB_TOKEN); + return new Octokit({ authStrategy: auth }); + } else { + console.warn( + "Please provide a GitHub Personal Access Token via the GITHUB_TOKEN environment variable.", + ); + return new Octokit(); + } +} + +async function downloadAsset(url, outputPath) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to download asset: ${res.statusText}`); + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + const nodeStream = Readable.from(res.body); + const fileStream = fs.createWriteStream(outputPath); + + await new Promise((resolve, reject) => { + nodeStream.pipe(fileStream); + nodeStream.on("error", reject); + fileStream.on("finish", resolve); + }); +} + +async function downloadAVM2Report() { + try { + const octokit = createOctokit(); + + const { data: releases } = await octokit.rest.repos.listReleases({ + owner, + repo, + request: { next: { revalidate: 1800 } }, + per_page: 7, + }); + + const asset = releases + .flatMap((release) => release.assets) + .find((a) => a.name === assetName); + + if (!asset) throw new Error(`No release contains asset "${assetName}"`); + + await downloadAsset(asset.browser_download_url, outputFile); + + console.log(`Downloaded ${assetName} from latest release to ${outputFile}`); + } catch (err) { + console.error("Error:", err.message); + process.exit(1); + } +} + +(async () => { + await downloadAVM2Report(); +})(); diff --git a/src/app/compatibility/avm.tsx b/src/app/compatibility/avm.tsx index 0acb110a..20f79712 100644 --- a/src/app/compatibility/avm.tsx +++ b/src/app/compatibility/avm.tsx @@ -1,3 +1,5 @@ +"use client"; + import classes from "./avm.module.css"; import { Button, @@ -24,7 +26,10 @@ function AvmProgress(props: AvmProgressPropsFull) { return ( - {props.name}: {props.done}% + {props.name}:{" "} + {typeof props.done === "number" && props.done > 0 + ? `${props.done}%` + : "Loading..."} - {props.stubbed && ( + {typeof props.stubbed === "number" && props.stubbed > 0 && ( + + + ); +} + +export function IconStub() { + return ( + + + + ); +} + +export function IconMissing() { + return ( + + + + ); +} + +export function ProgressIcon(type: "stub" | "missing" | "done") { + switch (type) { + case "stub": + return ; + case "missing": + return ; + case "done": + return ; + } +} diff --git a/src/app/compatibility/avm2/page.tsx b/src/app/compatibility/avm2/page.tsx index 0b1d447c..4f0ced60 100644 --- a/src/app/compatibility/avm2/page.tsx +++ b/src/app/compatibility/avm2/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Container, Group, @@ -8,16 +10,18 @@ import { Title, } from "@mantine/core"; import Image from "next/image"; -import React from "react"; +import React, { useEffect, useState } from "react"; import classes from "./avm2.module.css"; import { ClassBox } from "@/app/compatibility/avm2/class_box"; import { getReportByNamespace, + NamespaceStatus, +} from "@/app/compatibility/avm2/report_utils"; +import { IconDone, IconMissing, IconStub, - NamespaceStatus, -} from "@/app/compatibility/avm2/report_utils"; +} from "@/app/compatibility/avm2/icons"; import Link from "next/link"; function NamespaceBox(props: NamespaceStatus) { @@ -33,8 +37,21 @@ function NamespaceBox(props: NamespaceStatus) { ); } -export default async function Page() { - const byNamespace = await getReportByNamespace(); +export default function Page() { + const [byNamespace, setByNamespace] = useState< + { [name: string]: NamespaceStatus } | undefined + >(undefined); + useEffect(() => { + const fetchData = async () => { + try { + const byNamespace = await getReportByNamespace(); + setByNamespace(byNamespace); + } catch (error) { + console.error("Error fetching data", error); + } + }; + fetchData(); + }, []); return ( diff --git a/src/app/compatibility/avm2/report_utils.tsx b/src/app/compatibility/avm2/report_utils.tsx index 73ebfb07..8fa2a474 100644 --- a/src/app/compatibility/avm2/report_utils.tsx +++ b/src/app/compatibility/avm2/report_utils.tsx @@ -1,59 +1,4 @@ -import { rem, ThemeIcon } from "@mantine/core"; -import { IconCheck, IconProgress, IconX } from "@tabler/icons-react"; -import { fetchReport } from "@/app/downloads/github"; -import React from "react"; - -export function IconDone() { - return ( - - - - ); -} - -export function IconStub() { - return ( - - - - ); -} - -export function IconMissing() { - return ( - - - - ); -} - -export function ProgressIcon(type: "stub" | "missing" | "done") { - switch (type) { - case "stub": - return ; - case "missing": - return ; - case "done": - return ; - } -} +import type { AVM2Report } from "@/app/downloads/config"; export interface SummaryStatistics { max_points: number; @@ -82,7 +27,8 @@ export async function getReportByNamespace(): Promise< { [name: string]: NamespaceStatus } | undefined > { let byNamespace: { [name: string]: NamespaceStatus } = {}; - const report = await fetchReport(); + const reportReq = await fetch("/avm2_report.json"); + const report: AVM2Report = await reportReq.json(); if (!report) { return; } diff --git a/src/app/compatibility/page.tsx b/src/app/compatibility/page.tsx index 2b2c9fdb..4e8a08af 100644 --- a/src/app/compatibility/page.tsx +++ b/src/app/compatibility/page.tsx @@ -1,38 +1,67 @@ +"use client"; + +import React, { useEffect, useState } from "react"; import { Container, Flex, Group, Stack, Text } from "@mantine/core"; import classes from "./compatibility.module.css"; import { AvmBlock } from "@/app/compatibility/avm"; import Image from "next/image"; -import React from "react"; import { Title } from "@mantine/core"; import { List, ListItem } from "@mantine/core"; import { WeeklyContributions } from "@/app/compatibility/weekly_contributions"; import { - fetchReport, getAVM1Progress, getWeeklyContributions, } from "@/app/downloads/github"; -export default async function Downloads() { - const contributions = await getWeeklyContributions(); - const data = contributions.data.map((item) => { - return { - week: new Date(item.week * 1000).toISOString().split("T")[0], - Commits: item.total, +interface DataPoint { + week: string; + Commits: number; +} + +export default function Downloads() { + const [data, setData] = useState([]); + const [avm1ApiDone, setAvm1ApiDone] = useState(0); + const [avm2ApiDone, setAvm2ApiDone] = useState(0); + const [avm2ApiStubbed, setAvm2ApiStubbed] = useState(0); + useEffect(() => { + const fetchData = async () => { + try { + // Fetch weekly contributions + const contributionsRes = await getWeeklyContributions(); + const contributionsData = contributionsRes.data.map((item) => ({ + week: new Date(item.week * 1000).toISOString().split("T")[0], + Commits: item.total, + })); + setData(contributionsData); + + // Fetch AVM1 progress + const avm1ApiRes = await getAVM1Progress(); + setAvm1ApiDone(avm1ApiRes); + + // Fetch report + const reportReq = await fetch("/avm2_report.json"); + const reportRes = await reportReq.json(); + if (reportRes) { + const { summary } = reportRes; + const maxPoints = summary.max_points; + const implPoints = summary.impl_points; + const stubPenalty = summary.stub_penalty; + + const avm2ApiDone = Math.round( + ((implPoints - stubPenalty) / maxPoints) * 100, + ); + setAvm2ApiDone(avm2ApiDone); + + const avm2ApiStubbed = Math.round((stubPenalty / maxPoints) * 100); + setAvm2ApiStubbed(avm2ApiStubbed); + } + } catch (error) { + console.error("Error fetching data", error); + } }; - }); - const avm1ApiDone = await getAVM1Progress(); - const report = await fetchReport(); - if (!report) { - return; - } - const summary = report.summary; - const maxPoints = summary.max_points; - const implPoints = summary.impl_points; - const stubPenalty = summary.stub_penalty; - const avm2ApiDone = Math.round( - ((implPoints - stubPenalty) / maxPoints) * 100, - ); - const avm2ApiStubbed = Math.round((stubPenalty / maxPoints) * 100); + + fetchData(); + }, []); return ( diff --git a/src/app/contribute/page.tsx b/src/app/contribute/page.tsx index 44f9c9ef..c5625c24 100644 --- a/src/app/contribute/page.tsx +++ b/src/app/contribute/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Container, Group, diff --git a/src/app/contribute/sponsors.tsx b/src/app/contribute/sponsors.tsx index 88037c10..549190a6 100644 --- a/src/app/contribute/sponsors.tsx +++ b/src/app/contribute/sponsors.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Card, Group, Stack, Text, Title } from "@mantine/core"; import classes from "./sponsors.module.css"; import React from "react"; diff --git a/src/app/downloads/extensions.tsx b/src/app/downloads/extensions.tsx index 95784279..b818a67a 100644 --- a/src/app/downloads/extensions.tsx +++ b/src/app/downloads/extensions.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Group, Stack, Text, Title } from "@mantine/core"; import classes from "./extensions.module.css"; import React from "react"; diff --git a/src/app/downloads/page.tsx b/src/app/downloads/page.tsx index 622d6cc7..71c4e645 100644 --- a/src/app/downloads/page.tsx +++ b/src/app/downloads/page.tsx @@ -1,3 +1,6 @@ +"use client"; + +import React, { useState, useEffect } from "react"; import { Button, Code, @@ -8,7 +11,6 @@ import { Title, } from "@mantine/core"; import classes from "./downloads.module.css"; -import React from "react"; import { ExtensionList } from "@/app/downloads/extensions"; import { NightlyList } from "@/app/downloads/nightlies"; import Link from "next/link"; @@ -93,12 +95,24 @@ function DesktopDownload({ latest }: { latest: GithubRelease | null }) { ); } -export default async function Page() { - const releases = await getLatestReleases(); - const latest = releases.length > 0 ? releases[0] : null; - const nightlies = releases - .filter((release) => release.prerelease) - .slice(0, maxNightlies); +export default function Page() { + const [latest, setLatest] = useState(null); + const [nightlies, setNightlies] = useState([]); + useEffect(() => { + const fetchReleases = async () => { + try { + const releases = await getLatestReleases(); + const nightlies = releases + .filter((release) => release.prerelease) + .slice(0, maxNightlies); + setNightlies(nightlies); + setLatest(releases.length > 0 ? releases[0] : null); + } catch (err) { + console.warn("Failed to fetch releases", err); + } + }; + fetchReleases(); + }, []); return ( diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index a116d776..4a569bcc 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,3 +1,5 @@ +"use client"; + import classes from "./not-found.module.css"; import { Stack, Text, Title } from "@mantine/core"; import React from "react"; diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 3c5c7eef..507e6190 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Container, Group, ActionIcon, rem, Text } from "@mantine/core"; import Link from "next/link"; diff --git a/src/components/logo.tsx b/src/components/logo.tsx index b950a93c..67393270 100644 --- a/src/components/logo.tsx +++ b/src/components/logo.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Script from "next/script"; import classes from "../app/index.module.css"; @@ -27,44 +27,29 @@ interface LogoProps { className?: string; } -interface LogoState { - player: RufflePlayer | null; -} - -export default class InteractiveLogo extends React.Component< - LogoProps, - LogoState -> { - private readonly container: React.RefObject; - private player: RufflePlayer | null = null; +export default function InteractiveLogo({ className }: LogoProps) { + const container = useRef(null); + const [player, setPlayer] = useState(null); - constructor(props: LogoProps) { - super(props); - - this.container = React.createRef(); - this.state = { - player: null, - }; - } + const removeRufflePlayer = () => { + player?.remove(); + setPlayer(null); + }; - private removeRufflePlayer() { - this.player?.remove(); - this.player = null; - this.setState({ player: null }); - } - - private load() { - if (this.player) { + const loadPlayer = () => { + if (player) { // Already loaded. return; } - this.player = (window.RufflePlayer as PublicAPI)?.newest()?.createPlayer(); + const rufflePlayer = (window.RufflePlayer as PublicAPI) + ?.newest() + ?.createPlayer(); - if (this.player) { - this.container.current!.appendChild(this.player); + if (rufflePlayer) { + container.current!.appendChild(rufflePlayer); - this.player + rufflePlayer .load({ url: "/logo-anim.swf", autoplay: "on", @@ -75,39 +60,33 @@ export default class InteractiveLogo extends React.Component< preferredRenderer: "canvas", }) .catch(() => { - this.removeRufflePlayer(); + removeRufflePlayer(); }); - this.player.style.width = "100%"; - this.player.style.height = "100%"; - this.setState({ player: this.player }); + rufflePlayer.style.width = "100%"; + rufflePlayer.style.height = "100%"; + setPlayer(rufflePlayer); } - } - - componentDidMount() { - this.load(); - } - - componentWillUnmount() { - this.removeRufflePlayer(); - } - - render() { - return ( - <> -