diff --git a/src/components/big-interactive-pages/desktop-player.module.css b/src/components/big-interactive-pages/desktop-player.module.css new file mode 100644 index 0000000000..5479a71644 --- /dev/null +++ b/src/components/big-interactive-pages/desktop-player.module.css @@ -0,0 +1,177 @@ +.rootContainer { + display: flex; + align-items: center; + justify-content: center; + position: fixed; /* TODO: this is bad! figure out how to make better */ + top: 0; + height: 100vh; + width: 100vw; +} + +.root { + display: flex; + margin: 20px +} + +.screenContainer { + position: relative; +} + +.screen { + border: 2px solid var(--accent); + display: block; + background: #000000; + height: auto; + aspect-ratio: 1000 / 800; + image-rendering: pixelated; + outline: none; + width: 100%; +} + +.screenControls { + color: var(--accent); + font-size: 0.9em; + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 10px; + align-items: center; + padding: 8px 0; + position: absolute; + width: 100%; +} + +.screenControls > div { + display: flex; +} + +.screenControls > div > :not(:first-child) { + margin-left: 30px; +} + +@media all and (display-mode: fullscreen) { + .screenControls { + display: none; + } + + .screenContainer { + display: flex; + flex-direction: column; + align-items: center; + } + + .screen { + border: none; + height: 100%; + width: unset + } + + .canvasWrapper { + height: 100%; + } + + .screenControls.enabled { + display: flex; + position: relative; + bottom: 0; + width: 100%; + background: var(--bg-base); + padding: 8px 20px; + } +} + +.screenControls .mute, +.screenControls .stop { + cursor: var(--cursor-interactive); + user-select: none; + color: inherit; + font-family: inherit; + font-size: inherit; + border: none; + background: none; + padding: 0; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} + +.screenControls .mute :global(svg), +.screenControls .stop :global(svg) { + --size: 24px; + display: block; + width: var(--size); + height: var(--size); +} + +.meta { + margin-left: 20px; + border: 2px solid var(--accent); + padding: 20px; + min-width: 25%; + display: flex; + flex-direction: column; + justify-content: space-between; + background: var(--bg-surface); +} + + +.metaActions { + display: flex; + flex-direction: row; + justify-content: space-evenly; +} + +.metaActions > button:not(:first-child){ + margin-left: 8px; +} + +.title { + display: flex; + justify-content: space-between; +} + +.titleButtons { + display: flex; + align-items: center; + position: relative; +} + +.button { + display: flex; + align-items: center; + cursor: pointer; + margin-left: 10px; + position: relative; +} + +.button > svg { + height: 30px; + width: auto; +} + +.shareMenu { + position: absolute; + min-width: 200px; + bottom: -10px; + transform: translateY(100%); + right: 0; + cursor: auto; + border: 2px black solid; +} + +.shareButtonContainer { + position: relative; +} + +.shareMenuButton { + width: 100%; +} + +.button.hearted > svg { + color: #ef2828; +} + +.heartDisabled { + cursor: not-allowed; +} \ No newline at end of file diff --git a/src/components/big-interactive-pages/desktop-player.tsx b/src/components/big-interactive-pages/desktop-player.tsx new file mode 100644 index 0000000000..dc4772c62f --- /dev/null +++ b/src/components/big-interactive-pages/desktop-player.tsx @@ -0,0 +1,303 @@ +import {useEffect, useRef} from 'preact/hooks' +import { runGame } from '../../lib/engine' +import styles from './desktop-player.module.css' +import { + IoExpandOutline, + IoHeart, + IoHeartOutline, IoLink, IoLogoFacebook, IoLogoLinkedin, + IoLogoTwitter, + IoOpen, + IoPlay, + IoShareOutline, + IoStopCircleOutline + , + IoSyncCircleOutline, + IoVolumeHighOutline, + IoVolumeMuteOutline, + IoWarning +} from "react-icons/io5"; +import {muted, cleanupRef, errorLog} from "../../lib/state"; +import {useSignal, useSignalEffect} from "@preact/signals"; +import {exitFullscreen, fullscreenElement, requestFullscreen} from "../../lib/utils/fullscreen"; +import Button from "../design-system/button"; +import {VscLoading} from "react-icons/vsc"; +import {upload, uploadState} from "../../lib/upload"; + +interface DesktopPlayerProps { + code: string + gameName: string + authorName: string + filename: string + isLoggedIn: boolean + hearted: boolean +} + +export default function DesktopPlayer(props: DesktopPlayerProps) { + const screen = useRef(null) + const outputArea = useRef(null); + const screenContainer = useRef(null); + const screenControls = useRef(null); + + // acts a bit like a timer; if >0, shake game canvas + // activated when a game is run to show it's being run + const screenShake = useSignal(0); + + const onStop = async () => { + if (!screen.current) return; + if (cleanupRef.value) cleanupRef.value?.(); + }; + + const canvasScreenSize = useSignal({ + height: outputArea.current?.clientHeight!, // - screenControls.current?.clientHeight!, + maxHeight: screenContainer.current?.clientHeight + }); + + const onRun = async () => { + if (!screen.current) return; + if (cleanupRef.value) cleanupRef.value?.(); + errorLog.value = []; + const res = runGame(props.code, screen.current!, (error) => { + errorLog.value = [...errorLog.value, error]; + }); + + screen.current!.focus(); + if (screenShake) { + screenShake.value++; + } + setTimeout(() => { + if (screenShake) { + screenShake.value--; + } + }, 200); + + cleanupRef.value = res?.cleanup; + if (res && res.error) { + console.error(res.error.raw); + errorLog.value = [...errorLog.value, res.error]; + } + }; + + useEffect(() => { + onRun() + }, [props.code]) + + // mouse move timeout for auto-hiding fullscreen controls + const mouseMoveTimeout = useSignal(0); + const showFullscreenControls = useSignal(false); + + // show fullscreen controls code + timers + useEffect(() => { + window.addEventListener("mousemove", () => { + clearTimeout(mouseMoveTimeout.value) + showFullscreenControls.value= true; + if (fullscreenElement()) + mouseMoveTimeout.value = window.setTimeout(() => { + showFullscreenControls.value = false; + mouseMoveTimeout.value = 0; + }, 2000) + }) + }, []); + + const toggleFullscreen = async () => { + if (fullscreenElement()) { + exitFullscreen(); + } else { + requestFullscreen(screenContainer.current!); + } + }; + + const hearted = useSignal(props.hearted) + + const upvoteGame = () => { + hearted.value = true + return fetch("/api/games/upvote", { + method: "POST", + body: JSON.stringify({ + action: "upvote", + filename: props.filename + }) + }) + } + + const removeUpvote = () => { + hearted.value = false + return fetch("/api/games/upvote", { + method: "POST", + body: JSON.stringify({ + action: "remove", + filename: props.filename + }) + }) + } + + const shareMenuOpen = useSignal(false) + const shareMenuRef = useRef(null); + + const closeOpenMenus = (e: MouseEvent) => { + if (shareMenuOpen.value && !shareMenuRef.current?.contains(e.target as Element)) { + shareMenuOpen.value = false; + } + } + + useEffect(() => { + document.addEventListener("mousedown", closeOpenMenus); + return () => document.removeEventListener("mousedown", closeOpenMenus); + }, [shareMenuRef]); + + // TODO: better copy + const twTextEncoded = encodeURIComponent(`Check out this Sprig game, ${props.gameName}! It was made with the game engine by @hackclub.`) + const currentURLEncoded = encodeURIComponent(`https://sprig.hackclub.com/gallery/${encodeURIComponent(props.filename)}`) + + const embedLinkCopied = useSignal(false) + const gameLinkCopied = useSignal(false) + + useSignalEffect(() => { + if (!shareMenuOpen.value) { + embedLinkCopied.value = false + gameLinkCopied.value = false + } + }); + + return ( +
+
+
+
+ 0 ? "shake" : "" + }`} + style={outputArea.current ? { + height: canvasScreenSize.value.height, + maxHeight: canvasScreenSize.value.maxHeight, + width: (1.25 * canvasScreenSize.value.height), + maxWidth: "100%", + } : {}} + ref={screen} + tabIndex={0} + width="1000" + height="800" + /> +
+
+
+ + + +
+ +
+
+
+
+
+

{props.gameName}

+
+
{ + props.isLoggedIn && (hearted.value ? removeUpvote() : upvoteGame()) + }} + >{hearted.value ? : }
+ +
+ +
{ + shareMenuOpen.value = !shareMenuOpen.value + }}>
+ + {shareMenuOpen.value && +
+ + + + + + + + + + + + +
+ } + +
+ +
+
+ {props.authorName ? ( + + {' '}by {props.authorName} + + ) : null} + +
+ {/* TODO: description */} +
+
+ + + +
+
+
+
+
+ ) +} diff --git a/src/components/big-interactive-pages/mobile-player.tsx b/src/components/big-interactive-pages/mobile-player.tsx index ca16aa13c4..d01db865f1 100644 --- a/src/components/big-interactive-pages/mobile-player.tsx +++ b/src/components/big-interactive-pages/mobile-player.tsx @@ -2,6 +2,7 @@ import { useSignal } from '@preact/signals' import { useEffect, useRef } from 'preact/hooks' import { runGame } from '../../lib/engine' import styles from './mobile-player.module.css' +import {exitFullscreen, fullscreenElement, requestFullscreen} from "../../lib/utils/fullscreen"; interface MobilePlayerProps { code: string @@ -31,6 +32,34 @@ export default function MobilePlayer(props: MobilePlayerProps) { const keyTouches = useSignal>({}) + // There is no clear guidance there when it comes to handling device and browser-specific differentiations on mobile browsers,as such we need to do a custom implementation of vh + const calculateCSS = () => { + document.documentElement.style.setProperty( + "--vh", + `${window.innerHeight * 0.01}px` + ); + document.documentElement.style.setProperty( + "--vw", + `${window.innerWidth * 0.01}px` + ); + }; + + const toggleFullscreen = async () => { + if (fullscreenElement()) { + exitFullscreen(); + } else { + requestFullscreen(document.documentElement); + } + }; + + useEffect(() => { + calculateCSS(); + window.addEventListener("resize", calculateCSS); + document + .getElementById("toggle-fullscreen") + ?.addEventListener("click", toggleFullscreen); + }, []); + return (
diff --git a/src/lib/utils/fullscreen.ts b/src/lib/utils/fullscreen.ts new file mode 100644 index 0000000000..a4915a171f --- /dev/null +++ b/src/lib/utils/fullscreen.ts @@ -0,0 +1,42 @@ +// Vendor Agnostic Fullscreen Functions +export const requestFullscreen = (element: HTMLElement) => { + if (element.requestFullscreen) { + element.requestFullscreen(); + } else { // @ts-ignore + if (element.mozRequestFullScreen) { + // @ts-ignore + element.mozRequestFullScreen(); + } else { // @ts-ignore + if (element.webkitRequestFullscreen) { + // @ts-ignore + element.webkitRequestFullscreen(); + } + } + } +}; + +export const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else { // @ts-ignore + if (document.mozCancelFullScreen) { + // @ts-ignore + document.mozCancelFullScreen(); + } else { // @ts-ignore + if (document.webkitExitFullscreen) { + // @ts-ignore + document.webkitExitFullscreen(); + } + } + } +}; + +export const fullscreenElement = () => { + return ( + document.fullscreenElement || + // @ts-ignore + document.mozFullScreenElemen || + // @ts-ignore + document.webkitFullscreenElement + ); +}; \ No newline at end of file diff --git a/src/pages/api/games/upvote.ts b/src/pages/api/games/upvote.ts new file mode 100644 index 0000000000..2af5e865d4 --- /dev/null +++ b/src/pages/api/games/upvote.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from 'astro' +import {addDocument, deleteDocument, findDocument, getSession} from '../../../lib/game-saving/account' + +export const post: APIRoute = async ({ request, cookies }) => { + let filename: string + let action: "upvote" | "remove"; + try { + const body = await request.json() + if (typeof body.filename !== 'string') throw 'Missing/invalid game id' + filename = body.filename + if (typeof body.action !== 'string') throw 'Missing action' + if (body.action !== 'remove' && body.action !== 'upvote') throw 'Invalid action' + action = body.action + } catch (error) { + return new Response(typeof error === 'string' ? error : 'Bad request body', { status: 400 }) + } + + const session = await getSession(cookies) + if (!session) return new Response('Unauthorized', { status: 401 }) + + const existingRecords = await findDocument('upvotes', [ + ['filename', '==', filename], + ['userId', '==', session.user.id] + ]) + + if (action == "upvote") { + if (existingRecords.empty) await addDocument('upvotes', { + filename: filename, + userId: session.user.id + }) + console.log(filename) + } else { + if (existingRecords.empty) return new Response("User hasn't upvoted game", { status: 400 }) + await deleteDocument('upvotes', existingRecords.docs[0].id) + } + + + return new Response(JSON.stringify({}), { status: 200 }) +} \ No newline at end of file diff --git a/src/pages/gallery/[filename].astro b/src/pages/gallery/[filename].astro index 2f900c4212..abfbc5d70f 100644 --- a/src/pages/gallery/[filename].astro +++ b/src/pages/gallery/[filename].astro @@ -6,21 +6,15 @@ import { signal } from '@preact/signals' import type { PersistenceState } from '../../lib/state' import { getSession } from '../../lib/game-saving/account' import { getGalleryGames } from '../../lib/game-saving/gallery' -import MobilePlayer from '../../components/big-interactive-pages/mobile-player' import { mobileUserAgent } from '../../lib/utils/mobile' +import MobilePlayer from "../../components/big-interactive-pages/mobile-player"; import fs from 'fs' import path from 'path' -import { dirname } from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename); - const session = await getSession(Astro.cookies) const filename = Astro.params.filename ?? '' -const gameContentPath = path.resolve(__dirname, `../../../games/${filename}.js`) +const gameContentPath = path.resolve(`./games/${filename}.js`) const code = fs.readFileSync(gameContentPath).toString() if (code == null) { return Astro.redirect('/404', 302) @@ -54,86 +48,31 @@ const persistenceState = signal({ session }) const isMobile = mobileUserAgent(Astro.request.headers.get('user-agent') ?? '') + +// if (isMobile) return Astro.redirect(`/gallery/${filename}`, 302) --- - - - - - {isMobile ? ( - - ) : ( - - )} - - - + + + + +{isMobile ? ( + +) : ( + +)} + \ No newline at end of file diff --git a/src/pages/gallery/beta/[filename].astro b/src/pages/gallery/beta/[filename].astro new file mode 100644 index 0000000000..e22b134e1d --- /dev/null +++ b/src/pages/gallery/beta/[filename].astro @@ -0,0 +1,84 @@ +--- +import {findDocument} from "../../../lib/game-saving/account"; + +import '../../../global.css' +import DesktopPlayer from "../../../components/big-interactive-pages/desktop-player"; +import StandardHead from '../../../components/standard-head.astro' +import { getSession } from '../../../lib/game-saving/account' +import { getGalleryGames } from '../../../lib/game-saving/gallery' +import MobilePlayer from '../../../components/big-interactive-pages/mobile-player' +import { mobileUserAgent } from '../../../lib/utils/mobile' +import fs from 'fs' +import path from 'path' +import Navbar from "../../../components/navbar-main"; + +const session = await getSession(Astro.cookies) + +const filename = Astro.params.filename ?? '' +const gameContentPath = path.resolve(`./games/${filename}.js`) +const code = fs.readFileSync(gameContentPath).toString() +if (code == null) { + return Astro.redirect('/404', 302) +} + +const fileRegexp = /^.*\/(.+)-(\d+)\.md$/ + +const files = await Astro.glob('/games/*.md') +let tutorial: string[] | undefined = files + .filter(file => { + const regexedFile = file.file.match(fileRegexp) + return regexedFile && regexedFile[1] === filename + }) + ?.map(md => md.compiledContent()) +if (tutorial.length == 0) tutorial = undefined + +const games = getGalleryGames() +const metadata = games.find(game => game.filename === filename) +const name = metadata?.title +const authorName = metadata?.author + +const isMobile = mobileUserAgent(Astro.request.headers.get('user-agent') ?? '') + +const isLoggedIn = !!session?.session.full + +const hearted = isLoggedIn && await findDocument('upvotes', [ + ['filename', '==', filename], + ['userId', '==', session.user.id] +]).then(records => !records.empty) + +--- + + + + + + + +{isMobile ? ( + +) : ( + + +)} + + \ No newline at end of file