diff --git a/apps/website/src/app/(playgrounds)/playgrounds/_components/playground-card.tsx b/apps/website/src/app/(playgrounds)/playgrounds/_components/playground-card.tsx deleted file mode 100644 index 3a071ad..0000000 --- a/apps/website/src/app/(playgrounds)/playgrounds/_components/playground-card.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client'; - -import { - Card, - CardDescription, - CardHeader, - CardTitle, -} from '@evaluate/components/card'; -import type { PartialRuntime } from '@evaluate/shapes'; -import { CodeIcon } from 'lucide-react'; -import Link from 'next/link'; -import { useRef, useState } from 'react'; -import { - type RGB, - getDominantColour, -} from '~/app/(playgrounds)/playgrounds/_components/get-colour'; -import { ImageWithFallback } from '~/components/image-fallback'; - -declare module 'react' { - namespace CSS { - interface Properties { - [key: `--${string}`]: string; - } - } -} - -export function PlaygroundCard(p: { - runtime: PartialRuntime; - hash?: string; -}) { - const imageRef = useRef(null); - const [colour, setColour] = useState(); - - return ( - - -
- } - onLoad={() => setColour(getDominantColour(imageRef.current!))} - /> - - {p.runtime.name} -
- v{p.runtime.versions.at(-1)!} -
- - - {`Open ${p.runtime.name} Playground`} - -
- ); -} - -function makeIconUrl(icon: string) { - return `https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/940f2ea7a6fcdc0221ab9a8fc9454cc585de11f0/icons/${icon}.svg`; -} diff --git a/apps/website/src/app/(playgrounds)/playgrounds/content.tsx b/apps/website/src/app/(playgrounds)/playgrounds/content.tsx deleted file mode 100644 index feff6e2..0000000 --- a/apps/website/src/app/(playgrounds)/playgrounds/content.tsx +++ /dev/null @@ -1,124 +0,0 @@ -'use client'; - -import { Input } from '@evaluate/components/input'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@evaluate/components/select'; -import { toast } from '@evaluate/components/toast'; -import type { PartialRuntime } from '@evaluate/shapes'; -import Fuse from 'fuse.js'; -import { - ArrowDownWideNarrowIcon, - CircleDotIcon, - SearchIcon, - XIcon, -} from 'lucide-react'; -import { useDeferredValue, useEffect, useMemo } from 'react'; -import { useHashFragment } from '~/hooks/use-hash-fragment'; -import { useQueryParameter } from '~/hooks/use-query-parameter'; -import { PlaygroundCard } from './_components/playground-card'; - -export default function PlaygroundsPageContent(p: { - runtimes: PartialRuntime[]; -}) { - const [search, setSearch] = useQueryParameter('search'); - const deferredSearch = useDeferredValue(search); - - const queriedRuntimes = useMemo(() => { - if (!deferredSearch) return p.runtimes; - const engine = new Fuse(p.runtimes, { - keys: ['name', 'aliases', 'tags'], - threshold: 0.3, - }); - return engine.search(deferredSearch).map((result) => result.item); - }, [p.runtimes, deferredSearch]); - - type SortBy = 'popularity' | 'name'; - const [sortBy, setSortBy] = useQueryParameter('sort', 'popularity'); - const sortedRuntimes = useMemo(() => { - return queriedRuntimes.sort((a, b) => { - if (sortBy === 'name') return a.name.localeCompare(b.name); - return b.popularity - a.popularity; - }); - }, [queriedRuntimes, sortBy]); - - const [hash] = useHashFragment(); - useEffect(() => { - if (hash) - toast.info('Choose a playground!', { - icon: , - }); - }, [hash]); - - return ( -
-
-

- Playgrounds -

-

- Explore and run code in different programming languages and runtimes. -
- - Powered by the Piston execution engine. - -

-
- -
-
-
- - - setSearch(e.target.value)} - /> - - {search && ( - setSearch('')} - > - Clear Search - - )} -
- - -
- -
- {sortedRuntimes.map((runtime) => ( - - ))} -
-
-
- ); -} diff --git a/apps/website/src/app/(playgrounds)/playgrounds/_components/get-colour.ts b/apps/website/src/app/(playgrounds)/playgrounds/get-colour.ts similarity index 100% rename from apps/website/src/app/(playgrounds)/playgrounds/_components/get-colour.ts rename to apps/website/src/app/(playgrounds)/playgrounds/get-colour.ts diff --git a/apps/website/src/app/(playgrounds)/playgrounds/page.tsx b/apps/website/src/app/(playgrounds)/playgrounds/page.tsx index 742cc95..0988e9b 100644 --- a/apps/website/src/app/(playgrounds)/playgrounds/page.tsx +++ b/apps/website/src/app/(playgrounds)/playgrounds/page.tsx @@ -1,7 +1,6 @@ import { fetchRuntimes } from '@evaluate/engine/runtimes'; -import { Suspense } from 'react'; import { generateBaseMetadata } from '~/app/metadata'; -import PlaygroundsPageContent from './content'; +import { PlaygroundCardList } from './playground-card-list'; export function generateMetadata() { return generateBaseMetadata('/playgrounds'); @@ -9,9 +8,23 @@ export function generateMetadata() { export default async function PlaygroundsPage() { const runtimes = await fetchRuntimes(); + return ( - - - +
+
+

+ Playgrounds +

+

+ Explore and run code in different programming languages and runtimes. +
+ + Powered by the Piston execution engine. + +

+
+ + +
); } diff --git a/apps/website/src/app/(playgrounds)/playgrounds/playground-card-list.tsx b/apps/website/src/app/(playgrounds)/playgrounds/playground-card-list.tsx new file mode 100644 index 0000000..e4891a9 --- /dev/null +++ b/apps/website/src/app/(playgrounds)/playgrounds/playground-card-list.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { Input } from '@evaluate/components/input'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@evaluate/components/select'; +import { Separator } from '@evaluate/components/separator'; +import { toast } from '@evaluate/components/toast'; +import type { PartialRuntime } from '@evaluate/shapes'; +import Fuse from 'fuse.js'; +import { + ArrowDownWideNarrowIcon, + CircleDotIcon, + SearchIcon, + XIcon, +} from 'lucide-react'; +import { useDeferredValue, useEffect, useMemo } from 'react'; +import { useHashFragment } from '~/hooks/use-hash-fragment'; +import { useLocalStorage } from '~/hooks/use-local-storage'; +import { useQueryParameter } from '~/hooks/use-query-parameter'; +import { PlaygroundCard } from './playground-card'; + +export function PlaygroundCardList({ + initialRuntimes, +}: { initialRuntimes: PartialRuntime[] }) { + const [search, setSearch] = useQueryParameter('search'); + const deferredSearch = useDeferredValue(search); + const searchedRuntimes = useMemo(() => { + if (!deferredSearch) return initialRuntimes; + const engine = new Fuse(initialRuntimes, { + keys: ['name', 'aliases', 'tags'], + threshold: 0.3, + }); + return engine + .search(deferredSearch) // + .map((result) => result.item); + }, [initialRuntimes, deferredSearch]); + + type SortBy = 'popularity' | 'name'; + const [sortBy, setSortBy] = useQueryParameter('sort', 'popularity'); + const sortedRuntimes = useMemo(() => { + return searchedRuntimes.sort((a, b) => { + if (sortBy === 'name') return a.name.localeCompare(b.name); + return b.popularity - a.popularity; + }); + }, [searchedRuntimes, sortBy]); + + const [hash] = useHashFragment(); + useEffect(() => { + if (hash) + toast.info('Choose a playground!', { + icon: , + }); + }, [hash]); + + const [pinnedRuntimeIds] = useLocalStorage('evaluate.pinned', []); + const pinnedRuntimes = pinnedRuntimeIds + .map((id) => initialRuntimes.find((r) => r.id === id)!) + .filter(Boolean); + + return ( +
+ +
+ + + setSearch(e.target.value)} + /> + + {search && ( + setSearch('')} + > + Clear Search + + )} +
+ + +
+ + {pinnedRuntimes.length > 0 && ( + <> +
+ {pinnedRuntimes.map((runtime) => ( + + ))} +
+ + + )} + +
+ {sortedRuntimes.map((runtime) => ( + + ))} +
+
+ ); +} diff --git a/apps/website/src/app/(playgrounds)/playgrounds/playground-card.tsx b/apps/website/src/app/(playgrounds)/playgrounds/playground-card.tsx new file mode 100644 index 0000000..34c97cd --- /dev/null +++ b/apps/website/src/app/(playgrounds)/playgrounds/playground-card.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { Button } from '@evaluate/components/button'; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from '@evaluate/components/card'; +import type { PartialRuntime } from '@evaluate/shapes'; +import { CodeIcon, PinIcon } from 'lucide-react'; +import Link from 'next/link'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { ImageWithFallback } from '~/components/image-fallback'; +import { useLocalStorage } from '~/hooks/use-local-storage'; +import { type RGB, getDominantColour } from './get-colour'; + +declare module 'react' { + namespace CSS { + interface Properties { + [key: `--${string}`]: string; + } + } +} + +export function PlaygroundCard({ + runtime, + hash, +}: { runtime: PartialRuntime; hash?: string }) { + const imageRef = useRef(null); + const [colour, setColour] = useState(); + + const [pinnedRuntimes, setPinnedRuntimes] = // + useLocalStorage('evaluate.pinned', []); + const pinned = useMemo( + () => pinnedRuntimes.includes(runtime.id), + [pinnedRuntimes, runtime.id], + ); + const togglePin = useCallback(() => { + if (pinned) { + setPinnedRuntimes(pinnedRuntimes.filter((id) => id !== runtime.id)); + } else { + setPinnedRuntimes([...pinnedRuntimes, runtime.id]); + } + }, [pinned, pinnedRuntimes, runtime.id, setPinnedRuntimes]); + + return ( + + +
+ } + onLoad={() => setColour(getDominantColour(imageRef.current!))} + /> + + {runtime.name} +
+ + v{runtime.versions.at(-1)!} +
+ + + {`Open ${runtime.name} Playground`} + + +
+ +
+
+ ); +} + +function makeIconUrl(icon: string) { + return `https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/940f2ea7a6fcdc0221ab9a8fc9454cc585de11f0/icons/${icon}.svg`; +} diff --git a/apps/website/src/hooks/use-local-storage.ts b/apps/website/src/hooks/use-local-storage.ts new file mode 100644 index 0000000..ebe6b25 --- /dev/null +++ b/apps/website/src/hooks/use-local-storage.ts @@ -0,0 +1,163 @@ +import { useEventCallback } from '@evaluate/hooks/event-callback'; +import { useEventListener } from '@evaluate/hooks/event-listener'; +import { useCallback, useEffect, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; + +declare global { + interface WindowEventMap { + 'local-storage': CustomEvent; + } +} + +type UseLocalStorageOptions = { + serializer?: (value: T) => string; + deserializer?: (value: string) => T; + initializeWithValue?: boolean; +}; + +const IS_SERVER = typeof window === 'undefined'; + +export function useLocalStorage( + key: string, + initialValue: T | (() => T), + options: UseLocalStorageOptions = {}, +): [T, Dispatch>, () => void] { + const { initializeWithValue = true } = options; + + const serializer = useCallback<(value: T) => string>( + (value) => { + if (options.serializer) { + return options.serializer(value); + } + + return JSON.stringify(value); + }, + [options], + ); + + const deserializer = useCallback<(value: string) => T>( + (value) => { + if (options.deserializer) { + return options.deserializer(value); + } + // Support 'undefined' as a value + if (value === 'undefined') { + return undefined as unknown as T; + } + + const defaultValue = + initialValue instanceof Function ? initialValue() : initialValue; + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + console.error('Error parsing JSON:', error); + return defaultValue; // Return initialValue if parsing fails + } + + return parsed as T; + }, + [options, initialValue], + ); + + // Get from local storage then + // parse stored json or return initialValue + const readValue = useCallback((): T => { + const initialValueToUse = + initialValue instanceof Function ? initialValue() : initialValue; + + // Prevent build error "window is undefined" but keep working + if (IS_SERVER) { + return initialValueToUse; + } + + try { + const raw = window.localStorage.getItem(key); + return raw ? deserializer(raw) : initialValueToUse; + } catch (error) { + console.warn(`Error reading localStorage key “${key}”:`, error); + return initialValueToUse; + } + }, [initialValue, key, deserializer]); + + const [storedValue, setStoredValue] = useState(() => { + if (initializeWithValue) { + return readValue(); + } + + return initialValue instanceof Function ? initialValue() : initialValue; + }); + + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue: Dispatch> = useEventCallback((value) => { + // Prevent build error "window is undefined" but keeps working + if (IS_SERVER) { + console.warn( + `Tried setting localStorage key “${key}” even though environment is not a client`, + ); + } + + try { + // Allow value to be a function so we have the same API as useState + const newValue = value instanceof Function ? value(readValue()) : value; + + // Save to local storage + window.localStorage.setItem(key, serializer(newValue)); + + // Save state + setStoredValue(newValue); + + // We dispatch a custom event so every similar useLocalStorage hook is notified + window.dispatchEvent(new StorageEvent('local-storage', { key })); + } catch (error) { + console.warn(`Error setting localStorage key “${key}”:`, error); + } + }); + + const removeValue = useEventCallback(() => { + // Prevent build error "window is undefined" but keeps working + if (IS_SERVER) { + console.warn( + `Tried removing localStorage key “${key}” even though environment is not a client`, + ); + } + + const defaultValue = + initialValue instanceof Function ? initialValue() : initialValue; + + // Remove the key from local storage + window.localStorage.removeItem(key); + + // Save state with default value + setStoredValue(defaultValue); + + // We dispatch a custom event so every similar useLocalStorage hook is notified + window.dispatchEvent(new StorageEvent('local-storage', { key })); + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + setStoredValue(readValue()); + }, [key]); + + const handleStorageChange = useCallback( + (event: StorageEvent | CustomEvent) => { + if ((event as StorageEvent).key && (event as StorageEvent).key !== key) { + return; + } + setStoredValue(readValue()); + }, + [key, readValue], + ); + + // this only works for other documents, not the current one + useEventListener('storage', handleStorageChange); + + // this is a custom event, triggered in writeValueToLocalStorage + // See: useLocalStorage() + useEventListener('local-storage', handleStorageChange); + + return [storedValue, setValue, removeValue]; +} diff --git a/packages/hooks/src/event-callback.ts b/packages/hooks/src/event-callback.ts new file mode 100644 index 0000000..4e889ff --- /dev/null +++ b/packages/hooks/src/event-callback.ts @@ -0,0 +1,25 @@ +import { useCallback, useRef } from 'react'; +import { useIsomorphicLayoutEffect } from './isomorphic-layout-effect'; + +export function useEventCallback( + fn: (...args: Args) => R, +): (...args: Args) => R; +export function useEventCallback( + fn: ((...args: Args) => R) | undefined, +): ((...args: Args) => R) | undefined; +export function useEventCallback( + fn: ((...args: Args) => R) | undefined, +): ((...args: Args) => R) | undefined { + const ref = useRef(() => { + throw new Error('Cannot call an event handler while rendering.'); + }); + + useIsomorphicLayoutEffect(() => { + ref.current = fn; + }, [fn]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + return useCallback((...args: Args) => ref.current?.(...args), [ref]) as ( + ...args: Args + ) => R; +} diff --git a/packages/style/src/style.css b/packages/style/src/style.css index 1955133..495890b 100644 --- a/packages/style/src/style.css +++ b/packages/style/src/style.css @@ -52,6 +52,7 @@ @layer base { * { @apply border-border; + outline: none; } body {