diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c091e87..c09f936a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,6 +30,9 @@ "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, "[mdx]": { "editor.defaultFormatter": "unifiedjs.vscode-mdx" }, diff --git a/index.html b/index.html index 156dc2de..2dd4b98f 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/package.json b/package.json index d69b5066..a9b52794 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,15 @@ "test": "vitest" }, "dependencies": { - "@ark-ui/react": "^5.23.0", - "@fontsource-variable/inconsolata": "^5.2.7", - "@fontsource-variable/oswald": "^5.2.6", - "@fontsource-variable/raleway": "^5.2.6", - "@react-spring/three": "^10.0.2", - "@react-spring/web": "^10.0.2", - "@react-three/drei": "^10.7.5", - "@react-three/fiber": "^9.3.0", + "@ark-ui/react": "^5.31.0", + "@fontsource-variable/inconsolata": "^5.2.8", + "@fontsource-variable/oswald": "^5.2.8", + "@fontsource-variable/raleway": "^5.2.8", + "@mdx-js/mdx": "^3.1.1", + "@react-spring/three": "^10.0.3", + "@react-spring/web": "^10.0.3", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", "@react-three/postprocessing": "^3.0.4", "@reduxjs/toolkit": "^2.9.0", "@standard-schema/spec": "^1.0.0", @@ -30,52 +31,57 @@ "@std/path": "jsr:^1.1.2", "@std/random": "jsr:^0.1.2", "@std/text": "jsr:^1.0.16", - "@tanstack/react-form": "^1.19.5", - "@tanstack/react-pacer": "^0.16.2", - "@tanstack/react-query": "^5.87.4", - "@tanstack/react-router": "^1.131.36", - "@tanstack/react-router-devtools": "^1.131.36", + "@tanstack/react-form": "^1.28.3", + "@tanstack/react-pacer": "^0.20.0", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-router": "^1.161.1", "@tanstack/react-table": "^8.21.3", - "@zag-js/color-utils": "^1.22.1", - "@zag-js/file-utils": "^1.22.1", + "@zag-js/color-utils": "^1.33.1", + "@zag-js/file-utils": "^1.33.1", "bsmap": "^2.2.9", "date-fns": "^4.1.0", "fflate": "^0.8.2", "file-saver": "^2.0.5", "idb": "^8.0.3", - "lucide-react": "^0.543.0", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "lucide-react": "^0.574.0", + "postprocessing": "^6.38.2", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-redux": "^9.2.0", "redux-state-sync": "^3.1.4", "redux-undo": "^1.1.0", - "three": "^0.174.0", - "three-stdlib": "^2.36.0", + "three": "^0.183.0", "unstorage": "^1.17.1", "valibot": "^1.1.0", "waveform-data": "^4.5.2" }, "devDependencies": { "@biomejs/biome": "2.2.4", - "@pandacss/dev": "^1.3.0", - "@pandacss/preset-base": "^1.3.0", - "@tanstack/router-cli": "^1.131.36", - "@tanstack/router-plugin": "^1.131.36", - "@tanstack/virtual-file-routes": "^1.131.2", + "@pandacss/dev": "^1.8.2", + "@pandacss/types": "^1.8.2", + "@tanstack/devtools-vite": "^0.5.1", + "@tanstack/react-devtools": "^0.9.6", + "@tanstack/react-form-devtools": "^0.2.16", + "@tanstack/react-pacer-devtools": "^0.5.2", + "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-router-devtools": "^1.161.1", + "@tanstack/router-cli": "^1.161.1", + "@tanstack/router-plugin": "^1.161.1", + "@tanstack/virtual-file-routes": "^1.154.7", "@types/file-saver": "^2.0.7", "@types/node": "^22.18.1", - "@types/react": "^19.1.12", - "@types/react-dom": "^19.1.9", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/redux-state-sync": "^3.1.10", - "@types/three": "^0.174.0", + "@types/three": "^0.183.0", "@velite/plugin-vite": "^0.0.1", "@vite-pwa/assets-generator": "^1.0.1", - "@vitejs/plugin-react": "^5.0.2", + "@vitejs/plugin-react": "^5.1.4", "concurrently": "^9.2.1", "lefthook": "^1.12.4", "mdx": "^0.3.1", - "rehype-expressive-code": "^0.41.3", - "rehype-github-alerts": "^4.1.1", + "rehype-expressive-code": "^0.41.6", + "rehype-github-alerts": "^4.2.0", "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", "typescript": "^5.9.2", @@ -85,7 +91,6 @@ "vitest": "^3.2.4" }, "resolutions": { - "@types/react": "^18", - "redux": "^5" + "three-stdlib": "^2.36.1" } } diff --git a/panda.config.ts b/panda.config.ts index a3130e3a..152dd28b 100644 --- a/panda.config.ts +++ b/panda.config.ts @@ -1,14 +1,24 @@ -import { defineConfig } from "@pandacss/dev"; -import { default as base } from "@pandacss/preset-base"; +import { defineConfig, definePlugin } from "@pandacss/dev"; import { default as beatmapper } from "./src/styles/preset"; +const removePandaTokens = definePlugin({ + name: "remove-colors", + hooks: { + "preset:resolved": ({ utils, preset, name }) => { + if (name === "@pandacss/preset-panda") return utils.omit(preset, ["theme.tokens.colors", "theme.semanticTokens.colors"]); + return preset; + }, + }, +}); + export default defineConfig({ preflight: true, - include: ["./src/**/*.{js,jsx,ts,tsx}", "./pages/**/*.{js,jsx,ts,tsx}"], + include: ["./src/**/*.{js,jsx,ts,tsx}"], importMap: "$:styled-system", outdir: "styled-system", - presets: [base, beatmapper({})], + presets: ["@pandacss/preset-base", "@pandacss/preset-panda", beatmapper({})], + plugins: [removePandaTokens], jsxFramework: "react", jsxStyleProps: "none", shorthands: false, diff --git a/src/components/app/atoms/file.tsx b/src/components/app/atoms/file.tsx deleted file mode 100644 index 223baedc..00000000 --- a/src/components/app/atoms/file.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { ReactNode } from "react"; - -import { useLocalFileQuery } from "$/components/app/hooks"; -import { convertFileToDataUrl } from "$/helpers/file.helpers"; - -export interface LocalFileProps { - filename: string; - fallback?: ReactNode; - children: (src: string | undefined, isLoading: boolean) => ReactNode; -} -export function LocalFilePreview({ filename, fallback, children }: LocalFileProps) { - const { data: url, isFetching } = useLocalFileQuery(filename, { - queryKeySuffix: "preview", - transform: async (file) => await convertFileToDataUrl(file), - }); - if (!url) return fallback; - return children(url, isFetching); -} diff --git a/src/components/app/atoms/index.ts b/src/components/app/atoms/index.ts index db45f6fb..51ce3519 100644 --- a/src/components/app/atoms/index.ts +++ b/src/components/app/atoms/index.ts @@ -1 +1 @@ -export { LocalFilePreview, type LocalFileProps } from "./file"; +export { LocalFile } from "./local-file"; diff --git a/src/components/app/atoms/local-file.tsx b/src/components/app/atoms/local-file.tsx new file mode 100644 index 00000000..1e8b8b11 --- /dev/null +++ b/src/components/app/atoms/local-file.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +import { useLocalFileQuery } from "$/components/app/hooks/local-file.hooks"; +import { convertFileToDataUrl } from "$/helpers/file.helpers"; + +export interface LocalFileProps { + filename: string; + fallback?: ReactNode; + children: (src: string | undefined, isLoading: boolean) => ReactNode; +} +export function LocalFile({ filename, fallback, children }: LocalFileProps) { + const { data: url, isLoading } = useLocalFileQuery(filename, { + queryKey: ["supplier"], + transformFile: async (file) => await convertFileToDataUrl(file), + }); + + if (!url) return fallback; + + return children(url, isLoading); +} diff --git a/src/components/app/compositions/file-upload.tsx b/src/components/app/compositions/file-upload.tsx deleted file mode 100644 index 9866ca40..00000000 --- a/src/components/app/compositions/file-upload.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { FileUploadFileAcceptDetails } from "@ark-ui/react/file-upload"; -import type { ComponentProps } from "react"; - -import { APP_TOASTER, MAP_ARCHIVE_FILE_ACCEPT_TYPE } from "$/components/app/constants"; -import { useLocalFileQuery } from "$/components/app/hooks"; -import { FileUpload } from "$/components/ui/compositions"; -import { addSongFromFile } from "$/store/actions"; -import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectSongIds } from "$/store/selectors"; - -export function LocalFileUpload({ filename, children, ...rest }: ComponentProps & { filename: string }) { - const { data: currentFiles, isSuccess } = useLocalFileQuery(filename, { - queryKeySuffix: "picker", - transform: (file) => (file ? [file] : []), - }); - - return ( - - {children} - - ); -} - -export function MapArchiveFileUpload({ onFileAccept, ...rest }: ComponentProps) { - const dispatch = useAppDispatch(); - const songIds = useAppSelector(selectSongIds); - - const handleFileAccept = async (details: FileUploadFileAcceptDetails) => { - for (const file of details.files) { - try { - await dispatch(addSongFromFile({ file, options: { currentSongIds: songIds } })); - } catch (err) { - console.error("Could not import map:", err); - if (onFileAccept) onFileAccept({ files: [] }); - return APP_TOASTER.create({ - id: "import-map-fail", - type: "error", - description: "Could not import map. See console for more info.", - }); - } - } - if (onFileAccept) onFileAccept(details); - }; - - return ( - - Map Archive File - - ); -} diff --git a/src/components/app/compositions/file.tsx b/src/components/app/compositions/file.tsx deleted file mode 100644 index 80761fff..00000000 --- a/src/components/app/compositions/file.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Assign } from "@ark-ui/react"; -import { type ComponentProps, type CSSProperties, type PropsWithoutRef, useMemo } from "react"; - -import { LocalFilePreview, type LocalFileProps } from "$/components/app/atoms"; -import { Spinner } from "$/components/ui/compositions"; -import { BeatmapFilestore } from "$/services/file.service"; -import type { SongId } from "$/types"; -import { styled } from "$:styled-system/jsx"; -import { center } from "$:styled-system/patterns"; - -interface CoverArtProps extends PropsWithoutRef> { - sid: SongId; - width?: CSSProperties["width"]; -} -export function CoverArtFilePreview({ sid, width, ...rest }: Assign, CoverArtProps>) { - const style = useMemo(() => ({ width, height: width }), [width]); - return ( - - }> - {(src) => } - - - ); -} - -const CoverArtWrapper = styled("div", { - base: center.raw(), -}); -const CoverArtImage = styled("img", { - base: { - objectFit: "cover", - borderRadius: "sm", - aspectRatio: "square", - }, -}); diff --git a/src/components/app/compositions/index.ts b/src/components/app/compositions/index.ts index fb2599b7..9295b3d9 100644 --- a/src/components/app/compositions/index.ts +++ b/src/components/app/compositions/index.ts @@ -1,5 +1,2 @@ -export { CoverArtFilePreview } from "./file"; -export { LocalFileUpload, MapArchiveFileUpload } from "./file-upload"; +export { CoverArtFile } from "./local-file"; export { default as Logo } from "./logo"; -export { AppPrompter, useAppPrompterContext } from "./prompter"; -export { Shortcut } from "./shortcut"; diff --git a/src/components/app/compositions/local-file.tsx b/src/components/app/compositions/local-file.tsx new file mode 100644 index 00000000..8eaa7cee --- /dev/null +++ b/src/components/app/compositions/local-file.tsx @@ -0,0 +1,29 @@ +import type { Assign } from "@ark-ui/react"; +import { type ComponentProps, useMemo } from "react"; + +import { LocalFile } from "$/components/app/atoms"; +import { Spinner } from "$/components/ui/compositions"; +import { Center, styled } from "$:styled-system/jsx"; + +export interface CoverArtProps { + boxSize?: number; +} +export function CoverArtFile({ filename, boxSize, ...rest }: Assign, CoverArtProps & { filename: string }>) { + const style = useMemo(() => ({ width: boxSize, height: boxSize }), [boxSize]); + + return ( +
+ }> + {(src) => } + +
+ ); +} + +const CoverArt = styled("img", { + base: { + objectFit: "cover", + borderRadius: "sm", + aspectRatio: "square", + }, +}); diff --git a/src/components/app/compositions/logo.tsx b/src/components/app/compositions/logo.tsx index 797a567c..5bdd93f7 100644 --- a/src/components/app/compositions/logo.tsx +++ b/src/components/app/compositions/logo.tsx @@ -1,9 +1,9 @@ import { animated as a, useSpring } from "@react-spring/three"; import { Canvas } from "@react-three/fiber"; -import { Link } from "@tanstack/react-router"; +import { Link, useRouteContext } from "@tanstack/react-router"; import { createColorNote, NoteDirection } from "bsmap"; import type { EnvironmentAllName } from "bsmap/types"; -import { type DateArg, endOfMonth, endOfWeek, isWithinInterval, startOfMonth, startOfWeek } from "date-fns"; +import { getYear, isThisMonth, isThisWeek, setYear } from "date-fns"; import { useMemo, useRef, useState } from "react"; import { ColorNote } from "$/components/scene/compositions"; @@ -12,26 +12,29 @@ import { HStack, Stack, styled } from "$:styled-system/jsx"; const MOCK_NOTE = createColorNote({ direction: NoteDirection.DOWN }); -function checkDate(date: `${number}/${number}`, start: (date: DateArg) => Date, end: (date: DateArg) => Date) { - const today = new Date(); - return isWithinInterval(today, { start: start(`${date}/${today.getFullYear()}`), end: end(`${date}/${today.getFullYear()}`) }); -} - -function deriveNoteColor() { +function deriveNoteColor(now: number) { let environment: EnvironmentAllName = "DefaultEnvironment" as const; - if (checkDate("06/01", startOfMonth, endOfMonth)) environment = "GagaEnvironment"; - if (checkDate("10/31", startOfWeek, endOfWeek)) environment = "HalloweenEnvironment"; + + if (isThisMonth(setYear("06/01", getYear(now)))) { + environment = "GagaEnvironment"; + } + if (isThisWeek(setYear("10/31", getYear(now)))) { + environment = "HalloweenEnvironment"; + } + const { colorLeft, colorRight } = deriveColorSchemeFromEnvironment(environment); - if (import.meta.env.DEV) return colorRight; - return colorLeft; + return import.meta.env.DEV ? colorRight : colorLeft; } interface Props { size?: "full" | "mini"; } function Logo({ size = "full" }: Props) { + const { now } = useRouteContext({ from: "__root__" }); + const [isHovering, setIsHovering] = useState(false); - const color = useRef(deriveNoteColor()); + + const color = useRef(deriveNoteColor(now)); const [spring] = useSpring(() => ({ rotation: isHovering ? 0 : -0.35 }), [isHovering]); @@ -47,7 +50,7 @@ function Logo({ size = "full" }: Props) { - + @@ -81,7 +84,7 @@ const Subtitle = styled("span", { base: { color: "fg.muted", fontFamily: "body", - fontWeight: "medium", + fontWeight: "normal", }, variants: { size: { diff --git a/src/components/app/compositions/prompter.tsx b/src/components/app/compositions/prompter.tsx deleted file mode 100644 index 61225a74..00000000 --- a/src/components/app/compositions/prompter.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useParams, useRouteContext } from "@tanstack/react-router"; -import type { PropsWithChildren } from "react"; -import { gtValue, nonEmpty, number, object, pipe, regex, string } from "valibot"; - -import { createPrompterFactory } from "$/components/ui/compositions"; -import { store } from "$/setup"; -import { addBookmark, jumpToBeat, saveGridPreset, selectAllEntitiesInRange, updateAllSelectedObstacles } from "$/store/actions"; -import { useAppSelector } from "$/store/hooks"; -import { selectAllSelectedObstacles, selectGridPresets } from "$/store/selectors"; -import type { App, IGridPresets, SongId, View } from "$/types"; - -interface Props { - sid: SongId; - view: View; - gridPresets: IGridPresets; - selectedObstacles: App.IObstacle[]; -} - -const createPrompter = createPrompterFactory(); - -const { Provider, useContext } = createPrompter(({ createPrompt }) => { - return { - QUICK_SELECT: createPrompt({ - title: "Quick Select", - defaultValues: () => ({ range: "" }), - validate: object({ - range: pipe( - string(), - regex(/^\d+(-\d+)?$/, (issue) => `Invalid format: Expected or - but received "${issue.input}"`), - ), - }), - render: ({ form }) => {(ctx) => }, - onSubmit: ({ value, props: { sid, view } }) => { - let [start, end] = value.range - .trim() - .split("-") - .map((x) => Number.parseFloat(x)); - if (typeof end !== "number") { - end = Number.POSITIVE_INFINITY; - } - return store.dispatch(selectAllEntitiesInRange({ songId: sid, view: view, start, end })); - }, - }), - JUMP_TO_BEAT: createPrompt({ - title: "Jump to Beat", - defaultValues: () => ({ beatNum: 0 }), - validate: object({ beatNum: number() }), - render: ({ form }) => {(ctx) => }, - onSubmit: ({ value, props: { sid } }) => { - return store.dispatch(jumpToBeat({ songId: sid, pauseTrack: true, beatNum: value.beatNum })); - }, - }), - ADD_BOOKMARK: createPrompt({ - title: "Add Bookmark", - defaultValues: () => ({ name: "" }), - validate: object({ name: pipe(string(), nonEmpty()) }), - render: ({ form }) => {(ctx) => }, - onSubmit: ({ value, props: { sid, view } }) => { - return store.dispatch(addBookmark({ songId: sid, view, name: value.name })); - }, - }), - UPDATE_OBSTACLE_DURATION: createPrompt({ - title: "Update Duration for Obstacles", - defaultValues: ({ props: { selectedObstacles } }) => ({ duration: selectedObstacles[0].duration }), - validate: object({ duration: pipe(number(), gtValue(0)) }), - render: ({ form }) => {(ctx) => }, - onSubmit: ({ value }) => { - return store.dispatch(updateAllSelectedObstacles({ changes: { duration: value.duration } })); - }, - }), - SAVE_GRID_PRESET: createPrompt({ - title: "Save Grid Preset", - defaultValues: () => ({ slot: "" }), - validate: object({ slot: pipe(string(), nonEmpty()) }), - render: ({ form }) => {(ctx) => }, - onSubmit: ({ value, props: { sid } }) => { - return store.dispatch(saveGridPreset({ songId: sid, presetSlot: value.slot })); - }, - }), - }; -}); - -export function AppPrompter({ children }: PropsWithChildren) { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); - const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); - - const selectedObstacles = useAppSelector(selectAllSelectedObstacles); - const gridPresets = useAppSelector(selectGridPresets); - - return ( - - {children} - - ); -} - -export { useContext as useAppPrompterContext }; diff --git a/src/components/app/compositions/shortcut.tsx b/src/components/app/compositions/shortcut.tsx deleted file mode 100644 index e2cc6382..00000000 --- a/src/components/app/compositions/shortcut.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Children, type PropsWithChildren } from "react"; - -import { KBD } from "$/components/ui/styled"; -import { getMetaKeyLabel, getOptionKeyLabel } from "$/utils"; -import { styled } from "$:styled-system/jsx"; -import Mouse from "./mouse"; - -function resolveIcon(code: string) { - const aliases: Record = { - meta: getMetaKeyLabel(), - option: getOptionKeyLabel(), - space: "Spacebar", - up: "↑", - down: "↓", - left: "←", - right: "→", - escape: "Esc", - delete: "Del", - }; - const alias = code.toLowerCase() in aliases ? aliases[code.toLowerCase()] : code.toLowerCase(); - - if (code.length === 1) { - return {alias}; - } - switch (code.toLowerCase()) { - case "up": - case "down": - case "left": - case "right": { - return {alias}; - } - case "option": - case "meta": { - return {alias}; - } - case "spacebar": - case "space": { - return {alias}; - } - case "move": - case "clickleft": - case "clickright": - case "clickmiddle": - case "scroll": { - return ; - } - default: { - return {alias}; - } - } -} - -interface Props extends PropsWithChildren { - separator?: string; -} -export function Shortcut({ separator = "+", children }: Props) { - return Children.map(children, (child) => { - if (typeof child !== "string") throw new Error(""); - const keys = child.toString().trim().split(separator); - return ( - - {keys.map((c, i) => { - if (i !== 0) return [separator, resolveIcon(c.trim())]; - return resolveIcon(c.trim()); - })} - - ); - }); -} - -const Row = styled("span", { - base: { - display: "inline-block", - lineHeight: 1, - }, -}); diff --git a/src/components/app/constants.ts b/src/components/app/constants.ts index 4f659cba..6a1a0012 100644 --- a/src/components/app/constants.ts +++ b/src/components/app/constants.ts @@ -3,7 +3,9 @@ import { createToaster } from "@ark-ui/react/toast"; import type { FileMimeType } from "@zag-js/file-utils"; import { CharacteristicRename, DifficultyRename, EnvironmentRename } from "bsmap"; import { type CharacteristicName, EnvironmentName, EnvironmentV3Name } from "bsmap/types"; +import { nonEmpty, number, object, pipe, regex, string, transform } from "valibot"; +import { createPromptFactory } from "$/components/ui/compositions"; import { SNAPPING_INCREMENTS } from "$/constants"; import type { App, BeatmapId } from "$/types"; import { getMetaKeyLabel } from "$/utils"; @@ -57,8 +59,7 @@ interface ColorSchemeListCollectionOptions { } export function createColorSchemeCollection({ colorSchemeIds }: ColorSchemeListCollectionOptions) { return createListCollection({ - items: ["", ...colorSchemeIds], - itemToString: (item) => (item === "" ? "Unset" : item), + items: colorSchemeIds, }); } @@ -104,3 +105,41 @@ export function createBeatmapDifficultyListCollection({ beatmaps, currentBeatmap }, }); } + +export const createQuickSelectPrompt = createPromptFactory({ + title: "Quick Select", + description: "Selects all objects within the provided range of beats.", + defaultValues: { range: "" }, + validate: pipe( + object({ + range: pipe( + string(), + regex(/^\d+(-\d+)?$/, (issue) => `Invalid format: Expected or - but received "${issue.input}"`), + ), + }), + transform(({ range }) => { + let [start, end] = range + .trim() + .split("-") + .map((x) => Number.parseFloat(x)); + if (typeof end !== "number") { + end = Number.POSITIVE_INFINITY; + } + return { start, end }; + }), + ), +}); + +export const createJumpToBeatPrompt = createPromptFactory({ + title: "Jump to Beat", + description: "Moves the cursor to the provided beat number.", + defaultValues: { beatNum: 0 }, + validate: object({ beatNum: number() }), +}); + +export const createAddBookmarkPrompt = createPromptFactory({ + title: "Add Bookmark", + description: "Creates a new bookmark at the current beat.", + defaultValues: { name: "" }, + validate: object({ name: pipe(string(), nonEmpty()) }), +}); diff --git a/src/components/app/forms/create-beatmap.tsx b/src/components/app/forms/create-beatmap.tsx index 328ae959..5f355d38 100644 --- a/src/components/app/forms/create-beatmap.tsx +++ b/src/components/app/forms/create-beatmap.tsx @@ -1,10 +1,11 @@ +import type { Assign } from "@ark-ui/react"; import type { UseDialogContext } from "@ark-ui/react/dialog"; import { useStore } from "@tanstack/react-form"; import { useParams } from "@tanstack/react-router"; import { CharacteristicNameSchema, DifficultyNameSchema } from "bsmap"; import type { CharacteristicName, DifficultyName } from "bsmap/types"; -import { type ReactNode, useMemo } from "react"; -import { object } from "valibot"; +import { type PropsWithChildren, useMemo } from "react"; +import { type InferOutput, object } from "valibot"; import { APP_TOASTER, createBeatmapCharacteristicListCollection, createBeatmapDifficultyListCollection } from "$/components/app/constants"; import { useAppForm } from "$/components/ui/compositions"; @@ -20,11 +21,10 @@ const SCHEMA = object({ interface Props { dialog?: UseDialogContext; - onSubmit: (bid: BeatmapId, data: { characteristic: CharacteristicName; difficulty: DifficultyName }) => void; - children: (beatmap: { id: BeatmapId }) => ReactNode; + onSubmit: (bid: BeatmapId, data: InferOutput) => void; } -function CreateBeatmapForm({ dialog, onSubmit: afterCreate, children }: Props) { - const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid" }); +function CreateBeatmapForm({ children = "Create", dialog, onSubmit }: Assign) { + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const beatmaps = useAppSelector((state) => selectAllBeatmaps(state, sid)); const currentBeatmap = useAppSelector((state) => selectBeatmapById(state, sid, bid)); @@ -39,27 +39,25 @@ function CreateBeatmapForm({ dialog, onSubmit: afterCreate, children }: Props) { onChange: SCHEMA, onSubmit: SCHEMA, }, - onSubmit: async ({ value }) => { - const withMatchingCharacteristic = beatmaps.filter((beatmap) => beatmap.characteristic === value.characteristic); - if (withMatchingCharacteristic.length >= DIFFICULTY_LIST_COLLECTION.size) { - return APP_TOASTER.create({ - id: "all-difficulties-exist", - type: "error", - description: "All difficulties currently exist for this characteristic. Please choose a different characteristic.", - }); - } - const withMatchingDifficulty = withMatchingCharacteristic.some((beatmap) => beatmap.difficulty === value.difficulty); - if (withMatchingDifficulty) { - return APP_TOASTER.create({ - id: "difficulty-exists", - type: "error", - description: "The selected difficulty already exists for this characteristic. Please choose a different difficulty.", - }); - } + onSubmit: ({ value }) => { + try { + const withMatchingCharacteristic = beatmaps.filter((beatmap) => beatmap.characteristic === value.characteristic); + if (withMatchingCharacteristic.length >= DIFFICULTY_LIST_COLLECTION.size) { + throw new Error("All difficulties currently exist for this characteristic. Please choose a different characteristic."); + } + + const withMatchingDifficulty = withMatchingCharacteristic.some((beatmap) => beatmap.difficulty === value.difficulty); + if (withMatchingDifficulty) { + throw new Error("The selected difficulty already exists for this characteristic. Please choose a different difficulty."); + } - const beatmapId = resolveBeatmapId(value); - afterCreate(beatmapId, value); - if (dialog) dialog.setOpen(false); + onSubmit(resolveBeatmapId(value), value); + + if (dialog) dialog.setOpen(false); + } catch (error) { + APP_TOASTER.error({ description: error instanceof Error ? error.message : "Error creating beatmap. See console for more info." }); + return console.error(error); + } }, }); @@ -73,9 +71,7 @@ function CreateBeatmapForm({ dialog, onSubmit: afterCreate, children }: Props) { {(ctx) => } {(ctx) => } - - {(ctx) => children({ id: ctx.values.difficulty })} - + {children} ); diff --git a/src/components/app/forms/create-map.tsx b/src/components/app/forms/create-map.tsx index 0471b6e4..ee7274bd 100644 --- a/src/components/app/forms/create-map.tsx +++ b/src/components/app/forms/create-map.tsx @@ -1,17 +1,18 @@ import type { UseDialogContext } from "@ark-ui/react/dialog"; import { CharacteristicNameSchema, DifficultyNameSchema } from "bsmap"; import type { CharacteristicName, DifficultyName } from "bsmap/types"; -import { useState } from "react"; -import { gtValue, minLength, number, object, pipe, string, transform } from "valibot"; +import { array, file, gtValue, minLength, nonEmpty, number, object, pipe, string, transform } from "valibot"; import { APP_TOASTER, CHARACTERISTIC_COLLECTION, COVER_ART_FILE_ACCEPT_TYPE, DIFFICULTY_COLLECTION, SONG_FILE_ACCEPT_TYPE } from "$/components/app/constants"; -import { Field, FileUpload, useAppForm } from "$/components/ui/compositions"; +import { useAppForm } from "$/components/ui/compositions"; import { createSongId, resolveBeatmapId } from "$/helpers/song.helpers"; import { addSong } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectSongIds, selectUsername } from "$/store/selectors"; const SCHEMA = object({ + songFile: pipe(array(file()), nonEmpty("You must provide exactly one file.")), + coverArtFile: pipe(array(file()), nonEmpty("You must provide exactly one file.")), name: pipe(string(), minLength(1)), subName: pipe(string()), artistName: pipe(string(), minLength(1)), @@ -32,13 +33,10 @@ function CreateMapForm({ dialog }: Props) { const currentSongIds = useAppSelector(selectSongIds); const username = useAppSelector(selectUsername); - // These files are sent to the redux middleware. - // We'll store them on disk (currently in indexeddb, but that may change), and capture a reference to them by a filename, which we'll store in redux. - const [coverArtFile, setCoverArtFile] = useState(null); - const [songFile, setSongFile] = useState(null); - const Form = useAppForm({ defaultValues: { + songFile: [] as File[], + coverArtFile: [] as File[], name: "", subName: "", artistName: "", @@ -53,61 +51,49 @@ function CreateMapForm({ dialog }: Props) { onSubmit: SCHEMA, }, onSubmit: async ({ value }) => { - if (!songFile) { - return APP_TOASTER.create({ - type: "error", - description: "Please select a song file first", - }); - } - if (!coverArtFile) { - return APP_TOASTER.create({ - type: "error", - description: "Please select a cover art file first", - }); - } + try { + const songId = createSongId(value); - const songId = createSongId(value); - const beatmapId = resolveBeatmapId({ characteristic: value.characteristic, difficulty: value.difficulty }); + // Song IDs must be unique, and song IDs are generated from the name. + // TODO: I could probably just append a `-2` or something, if this constraint turns out to be annoying in some cases + if (currentSongIds.some((id) => id === songId)) { + throw new Error("You already have a song with this name. Please choose a unique name."); + } - // Song IDs must be unique, and song IDs are generated from the name. - // TODO: I could probably just append a `-2` or something, if this constraint turns out to be annoying in some cases - if (currentSongIds.some((id) => id === songId)) { - return APP_TOASTER.create({ - id: "song-already-exists", - type: "error", - description: "You already have a song with this name. Please choose a unique name.", - }); - } + const beatmapId = resolveBeatmapId({ characteristic: value.characteristic, difficulty: value.difficulty }); - try { - dispatch(addSong({ songId, beatmapId, name: value.name, subName: value.subName, artistName: value.artistName, bpm: value.bpm, offset: value.offset, songFile, coverArtFile, username: username, selectedCharacteristic: value.characteristic, selectedDifficulty: value.difficulty })); + dispatch( + addSong({ + songId, + beatmapId, + name: value.name, + subName: value.subName, + artistName: value.artistName, + bpm: value.bpm, + offset: value.offset ?? 0, + songFile: value.songFile[0], + coverArtFile: value.coverArtFile[0], + username: username, + selectedCharacteristic: value.characteristic, + selectedDifficulty: value.difficulty, + }), + ); if (dialog) dialog.setOpen(false); - } catch (err) { - console.error("Could not save files to local storage", err); - return APP_TOASTER.create({ - description: "Error creating map. See console for more information.", - type: "error", - }); + } catch (error) { + APP_TOASTER.error({ description: error instanceof Error ? error.message : `Error creating map: See console for more information.` }); + console.error("Could not save files to local storage", error); } }, }); return ( - - - setSongFile(details.files[0])}> - Audio File - - - - setCoverArtFile(details.files[0])}> - Image File - - - + + {(ctx) => } + {(ctx) => } + {(ctx) => } {(ctx) => } @@ -119,7 +105,7 @@ function CreateMapForm({ dialog }: Props) { {(ctx) => } {(ctx) => } - Create + Create new map ); diff --git a/src/components/app/forms/export-map.tsx b/src/components/app/forms/export-map.tsx new file mode 100644 index 00000000..18a4629c --- /dev/null +++ b/src/components/app/forms/export-map.tsx @@ -0,0 +1,82 @@ +import type { BeatmapFileType, ISaveOptions } from "bsmap/types"; +import { boolean, null_, object, picklist, union } from "valibot"; + +import { VERSION_COLLECTION } from "$/components/app/constants"; +import { Heading, useAppForm } from "$/components/ui/compositions"; +import type { ImplicitVersion } from "$/helpers/serialization.helpers"; +import { Stack, styled, Text, VStack } from "$:styled-system/jsx"; + +const SCHEMA = object({ + version: union([picklist(["1", "2", "3", "4"]), null_()]), + minify: boolean(), + purgeZeros: boolean(), +}); + +interface Props { + onSubmit: (ctx: { version: ImplicitVersion | undefined; options: ISaveOptions }) => void; +} +function ExportMapForm({ onSubmit }: Props) { + const Form = useAppForm({ + defaultValues: { + version: null as "1" | "2" | "3" | "4" | null, + minify: false, + purgeZeros: false, + }, + validators: { + onMount: SCHEMA, + onChange: SCHEMA, + onSubmit: SCHEMA, + }, + onSubmit: ({ value }) => { + return onSubmit({ + version: value.version ? (Number.parseInt(value.version, 10) as ImplicitVersion) : undefined, + options: { format: value.minify ? 0 : 2, optimize: { purgeZeros: value.purgeZeros } }, + }); + }, + }); + + return ( + + + + + Click to download a .zip containing all of the files needed to transfer your map onto a device for testing, or to submit for uploading. + + Download map files + + + + + Options + + + + {(ctx) => ( + + + + {ctx.state.value !== null ? null : "NOTE: If the version is left unset, the implicit version of your map will be used (derived from when the map was originally created/imported in the editor)."} + + + )} + + + + {(ctx) => } + {(ctx) => } + + + + ); +} + +const Panel = styled(VStack, { + base: { + layerStyle: "fill.surface", + colorPalette: "slate", + padding: 4, + textAlign: "center", + }, +}); + +export default ExportMapForm; diff --git a/src/components/app/forms/import-map.tsx b/src/components/app/forms/import-map.tsx index 118bba98..b370b75e 100644 --- a/src/components/app/forms/import-map.tsx +++ b/src/components/app/forms/import-map.tsx @@ -1,27 +1,33 @@ import type { UseDialogContext } from "@ark-ui/react/dialog"; -import { useCallback } from "react"; +import type { FileUploadFileAcceptDetails } from "@ark-ui/react/file-upload"; import { Fragment } from "react/jsx-runtime"; -import { MapArchiveFileUpload } from "$/components/app/compositions/file-upload"; -import { List, Text } from "$/components/ui/compositions"; -import { useAppSelector } from "$/store/hooks"; -import { selectProcessingImport } from "$/store/selectors"; -import { Stack } from "$:styled-system/jsx"; +import { APP_TOASTER, MAP_ARCHIVE_FILE_ACCEPT_TYPE } from "$/components/app/constants"; +import { FileUpload, List } from "$/components/ui/compositions"; +import { Stack, Text } from "$:styled-system/jsx"; interface Props { dialog?: UseDialogContext; + onAccept: (file: File) => void; } -function ImportMapForm({ dialog }: Props) { - const isProcessingImport = useAppSelector(selectProcessingImport); - - const handleFileAccept = useCallback(() => { +function ImportMapForm({ dialog, onAccept }: Props) { + const handleFileAccept = (details: FileUploadFileAcceptDetails) => { if (dialog) dialog.setOpen(false); - }, [dialog]); + + try { + for (const file of details.files) { + onAccept(file); + } + } catch (error) { + APP_TOASTER.error({ description: error instanceof Error ? error.message : "Could not import map. See console for more info." }); + console.error(error); + } + }; return ( - + To import a map, the following conditions must be met: @@ -32,10 +38,10 @@ function ImportMapForm({ dialog }: Props) { - + Drag and drop (or click to select) the .zip file: - + ); diff --git a/src/components/app/forms/index.ts b/src/components/app/forms/index.ts index fc6a335f..4a00d02d 100644 --- a/src/components/app/forms/index.ts +++ b/src/components/app/forms/index.ts @@ -1,5 +1,7 @@ export { default as CreateBeatmapForm } from "./create-beatmap"; export { default as CreateMapForm } from "./create-map"; +export { default as ExportMapForm } from "./export-map"; export { default as ImportMapForm } from "./import-map"; export { default as AppSettingsForm } from "./settings"; export { default as UpdateBeatmapForm } from "./update-beatmap"; +export { default as UpdateSongForm } from "./update-song"; diff --git a/src/components/app/forms/settings/advanced.tsx b/src/components/app/forms/settings/advanced.tsx index d5878108..8fef216c 100644 --- a/src/components/app/forms/settings/advanced.tsx +++ b/src/components/app/forms/settings/advanced.tsx @@ -1,21 +1,21 @@ import { Field, FieldInput } from "$/components/ui/compositions"; -import { Form } from "$/components/ui/styled"; import { updatePacerWait } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectPacerWait } from "$/store/selectors"; +import { Stack, Wrap } from "$:styled-system/jsx"; function AppAdvancedSettings() { const dispatch = useAppDispatch(); const wait = useAppSelector(selectPacerWait); return ( - - + + dispatch(updatePacerWait({ value: x.valueAsNumber }))} /> - - + + ); } diff --git a/src/components/app/forms/settings/audio.tsx b/src/components/app/forms/settings/audio.tsx index feb74af6..9b7eaaf6 100644 --- a/src/components/app/forms/settings/audio.tsx +++ b/src/components/app/forms/settings/audio.tsx @@ -1,11 +1,10 @@ import { createListCollection } from "@ark-ui/react/collection"; -import { ListCollectionFor } from "$/components/ui/atoms"; -import { Field, FieldInput, FieldSelect } from "$/components/ui/compositions"; -import { Form } from "$/components/ui/styled"; +import { Field, FieldInput, FieldSelectGroup } from "$/components/ui/compositions"; import { updateProcessingDelay, updateTickType } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectAudioProcessingDelay, selectTickType } from "$/store/selectors"; +import { Stack, Wrap } from "$:styled-system/jsx"; const TICK_MAP = ["woodblock", "switch"]; @@ -19,24 +18,16 @@ function AppAudioSettings() { const tickType = useAppSelector(selectTickType); return ( - - + + - dispatch(updateTickType({ value: TICK_MAP.indexOf(details.value) }))}> - - {(x) => ( - - )} - - + dispatch(updateTickType({ value: TICK_MAP.indexOf(details.valueAsString) }))} /> dispatch(updateProcessingDelay({ value: details.valueAsNumber }))} /> - - + + ); } diff --git a/src/components/app/forms/settings/controls.tsx b/src/components/app/forms/settings/controls.tsx index fb8b30b6..ebdee8f2 100644 --- a/src/components/app/forms/settings/controls.tsx +++ b/src/components/app/forms/settings/controls.tsx @@ -1,11 +1,10 @@ -import { Text } from "$/components/ui/compositions"; -import { Form } from "$/components/ui/styled"; +import { Stack, Wrap } from "$:styled-system/jsx"; function AppControlsSettings() { return ( - - Soon™ - + + Soon™ + ); } diff --git a/src/components/app/forms/settings/graphics.tsx b/src/components/app/forms/settings/graphics.tsx index 2a94ad98..2c2534d3 100644 --- a/src/components/app/forms/settings/graphics.tsx +++ b/src/components/app/forms/settings/graphics.tsx @@ -1,8 +1,8 @@ import { Field, Slider, Switch } from "$/components/ui/compositions"; -import { Form } from "$/components/ui/styled"; import { updateBloomEnabled, updateRenderScale } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectBloomEnabled, selectRenderScale } from "$/store/selectors"; +import { Stack, Wrap } from "$:styled-system/jsx"; function AppGraphicsSettings() { const dispatch = useAppDispatch(); @@ -10,16 +10,16 @@ function AppGraphicsSettings() { const isBloomEnabled = useAppSelector(selectBloomEnabled); return ( - - + + dispatch(updateRenderScale({ value: details.value[0] }))} /> dispatch(updateBloomEnabled({ checked: details.checked }))} /> - - + + ); } diff --git a/src/components/app/forms/settings/index.tsx b/src/components/app/forms/settings/index.tsx index bad248a6..3cb653b6 100644 --- a/src/components/app/forms/settings/index.tsx +++ b/src/components/app/forms/settings/index.tsx @@ -23,7 +23,7 @@ const collection = createListCollection({ function AppSettings() { return ( - + item.render()} /> ); } diff --git a/src/components/app/forms/settings/user.tsx b/src/components/app/forms/settings/user.tsx index 269e9a37..8c34a739 100644 --- a/src/components/app/forms/settings/user.tsx +++ b/src/components/app/forms/settings/user.tsx @@ -1,21 +1,21 @@ import { Field, FieldInput } from "$/components/ui/compositions"; -import { Form } from "$/components/ui/styled"; import { updateUsername } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectUsername } from "$/store/selectors"; +import { Stack, Wrap } from "$:styled-system/jsx"; function AppUserSettings() { const dispatch = useAppDispatch(); const username = useAppSelector(selectUsername); return ( - - + + dispatch(updateUsername({ value: details.valueAsString }))} /> - - + + ); } diff --git a/src/components/app/forms/update-beatmap.tsx b/src/components/app/forms/update-beatmap.tsx index d0a925c0..95ff5a61 100644 --- a/src/components/app/forms/update-beatmap.tsx +++ b/src/components/app/forms/update-beatmap.tsx @@ -1,27 +1,27 @@ import { useDialog } from "@ark-ui/react/dialog"; import { useBlocker, useNavigate, useParams, useRouteContext } from "@tanstack/react-router"; -import { CharacteristicRename, DifficultyRename, EnvironmentAllNameSchema } from "bsmap"; -import type { CharacteristicName, DifficultyName } from "bsmap/types"; +import { CharacteristicRename, DifficultyRename } from "bsmap"; +import type { CharacteristicName, DifficultyName, EnvironmentAllName } from "bsmap/types"; import { DotIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; -import { array, minValue, number, object, pipe, string, transform } from "valibot"; +import { array, custom, minValue, null_, number, object, pipe, string, transform, union } from "valibot"; import { APP_TOASTER, createColorSchemeCollection, ENVIRONMENT_COLLECTION } from "$/components/app/constants"; import { CreateBeatmapForm } from "$/components/app/forms"; import { Interleave } from "$/components/ui/atoms"; -import { AlertDialogProvider, Button, Collapsible, Dialog, Heading, Text, useAppForm } from "$/components/ui/compositions"; +import { AlertDialogProvider, Button, Collapsible, Dialog, Heading, useAppForm } from "$/components/ui/compositions"; import { copyBeatmap, removeBeatmap, updateBeatmap } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectBeatmapById, selectBeatmaps, selectColorSchemeIds } from "$/store/selectors"; import type { BeatmapId } from "$/types"; -import { HStack, Stack, Wrap } from "$:styled-system/jsx"; +import { HStack, Stack, Text, Wrap } from "$:styled-system/jsx"; const SCHEMA = object({ lightshowId: string(), noteJumpSpeed: pipe(number(), minValue(0)), startBeatOffset: number(), - environmentName: EnvironmentAllNameSchema, - colorSchemeName: string(), + environmentName: custom((name) => typeof name === "string" && name.endsWith("Environment"), 'Invalid environment name: Must end with "Environment" as the suffix.'), + colorSchemeName: union([string(), null_()]), mappers: array(string()), lighters: array(string()), customLabel: pipe( @@ -34,7 +34,7 @@ interface Props { bid: BeatmapId; } function UpdateBeatmapForm({ bid }: Props) { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); @@ -50,7 +50,7 @@ function UpdateBeatmapForm({ bid }: Props) { noteJumpSpeed: savedVersion.noteJumpSpeed, startBeatOffset: savedVersion.startBeatOffset, environmentName: savedVersion.environmentName, - colorSchemeName: savedVersion.colorSchemeName ?? "", + colorSchemeName: savedVersion.colorSchemeName, mappers: savedVersion.mappers ?? [], lighters: savedVersion.lighters ?? [], customLabel: savedVersion.customLabel ?? "", @@ -61,19 +61,24 @@ function UpdateBeatmapForm({ bid }: Props) { onSubmit: SCHEMA, }, onSubmit: async ({ value, formApi }) => { - dispatch( - updateBeatmap({ - songId: sid, - beatmapId: bid, - changes: { - ...value, - colorSchemeName: value.colorSchemeName === "" ? null : value.colorSchemeName, - customLabel: value.customLabel === "" ? undefined : value.customLabel, - }, - }), - ); - - formApi.reset(value); + try { + dispatch( + updateBeatmap({ + songId: sid, + beatmapId: bid, + changes: { + ...value, + colorSchemeName: value.colorSchemeName === "" ? null : value.colorSchemeName, + customLabel: value.customLabel === "" ? undefined : value.customLabel, + }, + }), + ); + + formApi.reset(value); + } catch (error) { + APP_TOASTER.error({ description: error instanceof Error ? error.message : `Error updating beatmap: See console for more information.` }); + console.error(error); + } }, }); @@ -121,12 +126,12 @@ function UpdateBeatmapForm({ bid }: Props) { return ( - {status === "blocked" && You have unsaved changes! Are you sure you want to leave this page? (You tweaked a value for the "{bid}" beatmap)} onSubmit={proceed} onCancel={reset} />} + {status === "blocked" && You have unsaved changes! Are you sure you want to leave this page? (You tweaked a value for the "{bid}" beatmap)} onSubmit={proceed} onCancel={reset} />} {savedVersion.customLabel ?? bid} - }> + }> {CharacteristicRename[savedVersion.characteristic]} {DifficultyRename[savedVersion.difficulty]} @@ -134,22 +139,22 @@ function UpdateBeatmapForm({ bid }: Props) { - {(ctx) => } - {(ctx) => } + {(ctx) => } + {(ctx) => } - {(ctx) => } - {(ctx) => } + {(ctx) => } + {(ctx) => } setShowAdvancedControls(x.open)} render={() => ( - {(ctx) => } - {(ctx) => } + {(ctx) => } + {(ctx) => } - {(ctx) => } - {(ctx) => } + {(ctx) => } + {(ctx) => } )} > @@ -167,7 +172,7 @@ function UpdateBeatmapForm({ bid }: Props) { unmountOnExit render={(ctx) => ( - {() => "Copy beatmap"} + Copy beatmap )} > @@ -175,7 +180,7 @@ function UpdateBeatmapForm({ bid }: Props) { Copy - Are you sure you want to do this? This action cannot be undone.} onSubmit={handleDeleteBeatmap}> + Are you sure you want to do this? This action cannot be undone.} onSubmit={handleDeleteBeatmap}> diff --git a/src/components/app/forms/update-song.tsx b/src/components/app/forms/update-song.tsx new file mode 100644 index 00000000..b8496b81 --- /dev/null +++ b/src/components/app/forms/update-song.tsx @@ -0,0 +1,147 @@ +import { useDialog } from "@ark-ui/react/dialog"; +import { useBlocker, useParams } from "@tanstack/react-router"; +import type { EnvironmentName, EnvironmentV3Name } from "bsmap/types"; +import { custom, gtValue, minLength, number, object, pipe, string, transform } from "valibot"; + +import { APP_TOASTER, COVER_ART_FILE_ACCEPT_TYPE, ENVIRONMENT_COLLECTION, SONG_FILE_ACCEPT_TYPE } from "$/components/app/constants"; +import { useLocalFileMutation, useLocalFileQuery } from "$/components/app/hooks/local-file.hooks"; +import { AlertDialogProvider, Field, FileUpload, useAppForm } from "$/components/ui/compositions"; +import { BeatmapFilestore } from "$/services/file.service"; +import { filestore } from "$/setup"; +import { updateSong } from "$/store/actions"; +import { useAppDispatch, useAppSelector } from "$/store/hooks"; +import { selectSongById } from "$/store/selectors"; +import { Text } from "$:styled-system/jsx"; + +const SCHEMA = object({ + name: pipe(string(), minLength(1)), + subName: pipe(string()), + artistName: pipe(string(), minLength(1)), + bpm: pipe(number(), gtValue(0)), + offset: pipe( + number(), + transform((input) => (Number.isNaN(input) ? undefined : input)), + ), + swingAmount: pipe( + number(), + transform(() => 0.5), + ), + swingPeriod: pipe( + number(), + transform(() => 0), + ), + previewStartTime: pipe(number()), + previewDuration: pipe(number()), + environment: custom((name) => typeof name === "string" && name.endsWith("Environment"), 'Invalid environment name: Must end with "Environment" as the suffix.'), +}); + +function UpdateSongForm() { + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + + const dispatch = useAppDispatch(); + const song = useAppSelector((state) => selectSongById(state, sid)); + + const { data: acceptedSongFile } = useLocalFileQuery(BeatmapFilestore.resolveFilename(sid, "song", {}), { + queryKey: ["file-upload"], + transformFile: (file) => (file ? [file] : []), + }); + const { data: acceptedCoverArtFile } = useLocalFileQuery(BeatmapFilestore.resolveFilename(sid, "cover", {}), { + queryKey: ["file-upload"], + transformFile: (file) => (file ? [file] : []), + }); + + const { mutate: handleAcceptSongFile } = useLocalFileMutation(BeatmapFilestore.resolveFilename(sid, "song", {}), { + onSuccess: () => { + APP_TOASTER.success({ id: "song-file-accepted", description: "Successfully updated song file!" }); + }, + }); + const { mutate: handleAcceptCoverArtFile } = useLocalFileMutation(BeatmapFilestore.resolveFilename(sid, "cover", {}), { + onSuccess: () => { + APP_TOASTER.success({ id: "cover-art-file-accepted", description: "Successfully updated cover art file!" }); + }, + }); + + const Form = useAppForm({ + defaultValues: { + name: song.name ?? "", + subName: song.subName ?? "", + artistName: song.artistName ?? "", + bpm: song.bpm ?? 120, + offset: song.offset ?? 0, + swingAmount: song.swingAmount ?? 0, + swingPeriod: song.swingPeriod ?? 0, + previewStartTime: song.previewStartTime ?? 12, + previewDuration: song.previewDuration ?? 10, + environment: song.environment, + }, + validators: { + onMount: SCHEMA, + onChange: SCHEMA, + onSubmit: SCHEMA, + }, + onSubmit: async ({ value, formApi }) => { + try { + const newSongObject = { ...song, ...value }; + + if (acceptedCoverArtFile) { + const { filename: coverArtFilename } = await filestore.saveCoverArtFile(sid, acceptedCoverArtFile[0]); + newSongObject.coverArtFilename = coverArtFilename; + } + + if (acceptedSongFile) { + const { filename: songFilename } = await filestore.saveSongFile(sid, acceptedSongFile[0]); + newSongObject.songFilename = songFilename; + } + + // Update our redux state + dispatch(updateSong({ songId: sid, changes: newSongObject })); + + formApi.reset(value); + } catch (error) { + APP_TOASTER.error({ description: error instanceof Error ? error.message : `Error updating song: See console for more information.` }); + console.error(error); + } + }, + }); + + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => Form.state.isDirty, + withResolver: true, + }); + + const isDirtyAlert = useDialog({ role: "alertdialog", open: status === "blocked" }); + + return ( + + {status === "blocked" && You have unsaved changes! Are you sure you want to leave this page?} onSubmit={proceed} onCancel={reset} />} + + + + handleAcceptSongFile(details.files[0])} /> + + + handleAcceptCoverArtFile(details.files[0])} /> + + + + {/* @ts-ignore */} + {(ctx) => } + {(ctx) => } + {(ctx) => } + + + {(ctx) => } + {(ctx) => } + {(ctx) => } + {(ctx) => } + + + {(ctx) => } + + Update song details + + + ); +} + +export default UpdateSongForm; diff --git a/src/components/app/hooks/index.ts b/src/components/app/hooks/index.ts deleted file mode 100644 index b3934f81..00000000 --- a/src/components/app/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useLocalFileQuery } from "./use-local-files"; diff --git a/src/components/app/hooks/local-file.hooks.ts b/src/components/app/hooks/local-file.hooks.ts new file mode 100644 index 00000000..31ea0715 --- /dev/null +++ b/src/components/app/hooks/local-file.hooks.ts @@ -0,0 +1,33 @@ +import { type UseQueryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { filestore } from "$/setup"; + +interface UseLocalFileQueryOptions extends Omit, "queryFn"> { + transformFile?: (file: File) => Promise | T; +} +export function useLocalFileQuery(filename: string, { ...rest }: UseLocalFileQueryOptions) { + return useQuery({ + ...rest, + queryKey: [...rest.queryKey, filename], + queryFn: async () => { + const blob = await filestore.loadFile(filename); + let file = blob as unknown as File; + if (!(blob instanceof File)) file = new File([blob], "name" in blob && typeof blob.name === "string" ? blob.name : filename, { type: blob.type }); + return (await rest.transformFile?.(file)) ?? (file as T); + }, + }); +} + +export function useLocalFileMutation(filename: string, options: { onSuccess: () => void }) { + const client = useQueryClient(); + + return useMutation({ + mutationFn: async (file: File) => { + filestore.saveFile(filename, file); + }, + onSuccess: () => { + options.onSuccess(); + client.invalidateQueries({ predicate: (query) => query.queryKey.some((x) => x === filename) }); + }, + }); +} diff --git a/src/components/app/hooks/use-local-files.ts b/src/components/app/hooks/use-local-files.ts deleted file mode 100644 index 528fc234..00000000 --- a/src/components/app/hooks/use-local-files.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; - -import { filestore } from "$/setup"; - -export function useLocalFileQuery(filename: string, { queryKeySuffix: suffix, ...rest }: Omit, "queryKey" | "queryFn"> & { queryKeySuffix: string; transform?: (file: File) => Promise | T }) { - return useQuery({ - ...rest, - queryKey: [`filestore.${filename}`].map((key) => `${key}.${suffix}`), - queryFn: async () => { - const blob = await filestore.loadFile(filename); - let file = blob as unknown as File; - if (!(blob instanceof File)) file = new File([blob], "name" in blob && typeof blob.name === "string" ? blob.name : filename, { type: blob.type }); - return (await rest.transform?.(file)) ?? (file as T); - }, - }); -} diff --git a/src/components/app/layouts/action-panel/index.ts b/src/components/app/layouts/action-panel/index.ts new file mode 100644 index 00000000..f3f8374c --- /dev/null +++ b/src/components/app/layouts/action-panel/index.ts @@ -0,0 +1 @@ +export { default as Root } from "./root"; diff --git a/src/components/app/layouts/action-panel/root.tsx b/src/components/app/layouts/action-panel/root.tsx new file mode 100644 index 00000000..bda943ec --- /dev/null +++ b/src/components/app/layouts/action-panel/root.tsx @@ -0,0 +1,46 @@ +import type { PropsWithChildren } from "react"; + +import { styled } from "$:styled-system/jsx"; +import { center, vstack } from "$:styled-system/patterns"; + +function Root({ children }: PropsWithChildren) { + return ( + ev.stopPropagation()}> + {children} + + ); +} + +const OuterWrapper = styled("div", { + base: center.raw({ + position: "absolute", + top: 0, + bottom: "calc({sizes.navigationPanel} + {sizes.statusBar})", + right: 0, + width: "200px", + pointerEvents: "none", + }), +}); + +const Wrapper = styled("div", { + base: vstack.raw({ + height: "fit-content", + maxHeight: "100%", + justify: "start", + padding: 4, + gap: 4, + backgroundColor: "bg.translucent", + color: "fg.default", + borderLeftRadius: "md", + borderBlockWidth: "sm", + borderLeftWidth: "sm", + borderColor: "border.muted", + backdropFilter: "blur(8px)", + pointerEvents: "auto", + userSelect: "none", + overflowY: "auto", + _scrollbar: { display: "none" }, + }), +}); + +export default Root; diff --git a/src/components/app/templates/events/background-box.tsx b/src/components/app/layouts/event-grid/background-box.tsx similarity index 96% rename from src/components/app/templates/events/background-box.tsx rename to src/components/app/layouts/event-grid/background-box.tsx index cb0d50a2..fcdcaeaf 100644 --- a/src/components/app/templates/events/background-box.tsx +++ b/src/components/app/layouts/event-grid/background-box.tsx @@ -12,7 +12,7 @@ interface Props { box: IBackgroundBox; } function EventGridBackgroundBox({ box }: Props) { - const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { startBeat, endBeat } = useAppSelector((state) => selectEventEditorStartAndEndBeat(state, sid)); const colorScheme = useAppSelector((state) => selectColorScheme(state, sid, bid)); diff --git a/src/styles/utilities.ts b/src/components/app/layouts/event-grid/context.ts similarity index 100% rename from src/styles/utilities.ts rename to src/components/app/layouts/event-grid/context.ts diff --git a/src/components/app/templates/events/cursor.tsx b/src/components/app/layouts/event-grid/cursor.tsx similarity index 95% rename from src/components/app/templates/events/cursor.tsx rename to src/components/app/layouts/event-grid/cursor.tsx index bfcd0ec2..9c1fce89 100644 --- a/src/components/app/templates/events/cursor.tsx +++ b/src/components/app/layouts/event-grid/cursor.tsx @@ -10,7 +10,7 @@ interface Props { gridWidth: number; } function EventGridCursor({ gridWidth }: Props) { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { startBeat, endBeat } = useAppSelector((state) => selectEventEditorStartAndEndBeat(state, sid)); const cursorPositionInBeats = useAppSelector((state) => selectCursorPositionInBeats(state, sid)); diff --git a/src/components/app/templates/events/event.tsx b/src/components/app/layouts/event-grid/event.tsx similarity index 81% rename from src/components/app/templates/events/event.tsx rename to src/components/app/layouts/event-grid/event.tsx index cddba6fe..1dadc007 100644 --- a/src/components/app/templates/events/event.tsx +++ b/src/components/app/layouts/event-grid/event.tsx @@ -1,10 +1,11 @@ +import type { Assign } from "@ark-ui/react"; import { useParams } from "@tanstack/react-router"; -import { memo, type PointerEvent, useCallback, useMemo } from "react"; +import { type ComponentProps, type PointerEvent, useCallback, useMemo } from "react"; -import { useGlobalEventListener } from "$/components/hooks"; +import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; import { Button } from "$/components/ui/compositions"; import { resolveColorForItem } from "$/helpers/colors.helpers"; -import { isLightEvent, isValueEvent, resolveEventColor, resolveEventEffect } from "$/helpers/events.helpers"; +import { isLightEvent, resolveEventColor, resolveEventEffect } from "$/helpers/events.helpers"; import { useAppSelector } from "$/store/hooks"; import { selectColorScheme, selectEventEditorStartAndEndBeat, selectEventTracksForEnvironment } from "$/store/selectors"; import { App, type IEventTracks } from "$/types"; @@ -50,8 +51,8 @@ interface Props { onEventPointerOut?: (event: PointerEvent, data: App.IBasicEvent) => void; onEventWheel?: (event: WheelEvent, data: App.IBasicEvent) => void; } -function EventGridEventItem({ event: data, trackWidth, onEventPointerDown, onEventPointerUp, onEventPointerOver, onEventPointerOut, onEventWheel }: Props) { - const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid" }); +function EventGridEventItem({ children, event: data, trackWidth, onEventPointerDown, onEventPointerUp, onEventPointerOver, onEventPointerOut, onEventWheel }: Assign, Props>) { + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { startBeat, endBeat } = useAppSelector((state) => selectEventEditorStartAndEndBeat(state, sid)); const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); @@ -105,15 +106,14 @@ function EventGridEventItem({ event: data, trackWidth, onEventPointerDown, onEve useGlobalEventListener("wheel", handleWheel, { options: { passive: false } }); return ( - ev.stopPropagation()} onContextMenu={(ev) => ev.preventDefault()} onPointerDown={handlePointerDown} onPointerUp={handlePointerUp} onPointerOver={handlePointerOver} onPointerOut={handlePointerOut}> - {isLightEvent(data, tracks) && {data.value !== 0 ? data.floatValue : undefined}} - {isValueEvent(data, tracks) && {data.value}} + ); } -const Wrapper = styled(Button, { +const Wrapper = styled("div", { base: { width: "8px", height: "100%", @@ -145,4 +145,4 @@ const SelectedGlow = styled("div", { }, }); -export default memo(EventGridEventItem); +export default EventGridEventItem; diff --git a/src/components/app/layouts/event-grid/for.tsx b/src/components/app/layouts/event-grid/for.tsx new file mode 100644 index 00000000..93b4498c --- /dev/null +++ b/src/components/app/layouts/event-grid/for.tsx @@ -0,0 +1,32 @@ +import { useParams } from "@tanstack/react-router"; +import { type CSSProperties, type ReactNode, useMemo } from "react"; + +import { For } from "$/components/ui/atoms"; +import { isSideTrack } from "$/helpers/events.helpers"; +import { useAppSelector } from "$/store/hooks"; +import { selectEventsEditorMirrorLock, selectEventsEditorTrackHeight, selectEventTracksForEnvironment } from "$/store/selectors"; +import type { IEventTrack } from "$/types"; + +interface Props { + children: (track: IEventTrack, trackId: number, ctx: { disabled: boolean; style: CSSProperties }) => ReactNode; +} +function ForEventTracks({ children }: Props) { + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + + const areLasersLocked = useAppSelector(selectEventsEditorMirrorLock); + const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); + const style = useAppSelector((state) => { + return { height: selectEventsEditorTrackHeight(state) }; + }); + + const items = useMemo(() => { + return Object.entries(tracks).map(([id, track]) => { + const trackId = Number.parseInt(id, 10); + return { track, id: trackId, disabled: areLasersLocked && isSideTrack(trackId, "right", tracks) }; + }); + }, [tracks, areLasersLocked]); + + return {({ track, id, disabled }) => children(track, id, { disabled, style })}; +} + +export default ForEventTracks; diff --git a/src/components/app/layouts/event-grid/index.ts b/src/components/app/layouts/event-grid/index.ts new file mode 100644 index 00000000..cbdfc186 --- /dev/null +++ b/src/components/app/layouts/event-grid/index.ts @@ -0,0 +1,85 @@ +import { styled } from "$:styled-system/jsx"; +import { center, hstack, stack } from "$:styled-system/patterns"; + +export { default as BackgroundBox } from "./background-box"; +export { default as Cursor } from "./cursor"; +export { default as Event } from "./event"; +export { default as ForTracks } from "./for"; +export { default as Markers } from "./markers"; +export { default as Pointer } from "./pointer"; +export { default as Root } from "./root"; +export { default as SelectionBox } from "./selection-box"; +export { default as Timeline } from "./timeline"; +export { default as Track } from "./track"; + +export const Header = styled("div", { + base: hstack.raw({ + position: "sticky", + height: "32px", + top: 0, + gap: 0, + backdropFilter: "blur(4px)", + zIndex: 2, + }), +}); + +export const Body = styled("div", { + base: hstack.raw({ + gap: 0, + backdropFilter: "blur(4px)", + }), +}); + +export const Actions = styled("div", { + base: center.raw({ + minWidth: "170px", + height: "100%", + borderBottomWidth: "sm", + borderColor: "border.muted", + }), +}); + +export const PrefixGroup = styled("div", { + base: stack.raw({ + gap: 0, + }), +}); + +export const Prefix = styled("div", { + base: hstack.raw({ + width: "170px", + justify: "flex-end", + textAlign: "end", + paddingInline: 1, + position: "relative", + backgroundColor: { base: undefined, _disabled: "bg.disabled" }, + borderBlockWidth: { base: "sm", _lastOfType: 0 }, + borderRightWidth: "md", + borderColor: "border.muted", + opacity: { base: 1, _disabled: "disabled" }, + cursor: { base: undefined, _disabled: "not-allowed" }, + overflowX: "auto", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + _scrollbar: { display: "none" }, + }), +}); + +export const Control = styled("div", { + base: { + position: "relative", + flex: 1, + }, + variants: { + editMode: { + place: { cursor: "pointer" }, + select: { cursor: "crosshair" }, + }, + }, +}); + +export const Trigger = styled("div", { + base: { + position: "relative", + }, +}); diff --git a/src/components/app/layouts/event-grid/markers.tsx b/src/components/app/layouts/event-grid/markers.tsx new file mode 100644 index 00000000..ee01648a --- /dev/null +++ b/src/components/app/layouts/event-grid/markers.tsx @@ -0,0 +1,60 @@ +import type { Assign } from "@ark-ui/react"; +import { type ComponentProps, Fragment, useCallback, useMemo } from "react"; + +import { For } from "$/components/ui/atoms"; +import { useAppSelector } from "$/store/hooks"; +import { selectEventsEditorBeatsPerZoomLevel } from "$/store/selectors"; +import { range } from "$/utils"; +import { styled } from "$:styled-system/jsx"; +import { token } from "$:styled-system/tokens"; + +interface Props { + width: number; + height: number; + primaryDivisions: number; +} +function EventGridMarkers({ width, height, primaryDivisions, ...rest }: Assign, Props>) { + const numOfBeatsToShow = useAppSelector(selectEventsEditorBeatsPerZoomLevel); + + const segmentWidth = useMemo(() => width / numOfBeatsToShow, [width, numOfBeatsToShow]); + + const renderBeatLine = useCallback( + (beat: number) => { + // No line necessary for the right edge of the grid + if (beat === numOfBeatsToShow - 1) return null; + return ; + }, + [numOfBeatsToShow, height, segmentWidth], + ); + + const renderPrimaryLine = useCallback( + (beat: number, segmentIndex: number) => { + if (beat === 0) return null; + const subSegmentWidth = segmentWidth / primaryDivisions; + return ; + }, + [primaryDivisions, height, segmentWidth], + ); + + return ( + + + {(beat, index) => ( + + {renderBeatLine(beat)} + {(n) => renderPrimaryLine(n, index)} + + )} + + + ); +} + +const Wrapper = styled("svg", { + base: { + position: "absolute", + inset: 0, + }, +}); + +export default EventGridMarkers; diff --git a/src/components/app/layouts/event-grid/pointer.tsx b/src/components/app/layouts/event-grid/pointer.tsx new file mode 100644 index 00000000..ceaa5116 --- /dev/null +++ b/src/components/app/layouts/event-grid/pointer.tsx @@ -0,0 +1,40 @@ +import { useParams } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import { useAppSelector } from "$/store/hooks"; +import { selectEventEditorStartAndEndBeat, selectEventsEditorCursor } from "$/store/selectors"; +import { normalize } from "$/utils"; +import { styled } from "$:styled-system/jsx"; + +interface Props { + width: number; +} +function EventGridPointer({ width }: Props) { + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + + const { startBeat, numOfBeatsToShow } = useAppSelector((state) => selectEventEditorStartAndEndBeat(state, sid)); + const selectedBeat = useAppSelector(selectEventsEditorCursor); + + const styles = useMemo(() => { + const mousePositionInPx = selectedBeat !== null && selectedBeat - startBeat >= 0 ? normalize(selectedBeat - startBeat, 0, numOfBeatsToShow, 0, width) : 0; + return { left: mousePositionInPx }; + }, [selectedBeat, startBeat, numOfBeatsToShow, width]); + + return ; +} + +const Wrapper = styled("div", { + base: { + position: "absolute", + top: 0, + width: "3px", + height: "100%", + background: "fg.default", + borderWidth: "sm", + borderColor: "border.default", + pointerEvents: "none", + transform: "translateX(-2px)", + }, +}); + +export default EventGridPointer; diff --git a/src/components/app/layouts/event-grid/root.tsx b/src/components/app/layouts/event-grid/root.tsx new file mode 100644 index 00000000..f582f764 --- /dev/null +++ b/src/components/app/layouts/event-grid/root.tsx @@ -0,0 +1,22 @@ +import type { ComponentProps } from "react"; + +import { styled } from "$:styled-system/jsx"; +import { stack } from "$:styled-system/patterns"; + +function EventGridRoot({ children, ...rest }: ComponentProps) { + return {children}; +} + +const Wrapper = styled("div", { + base: stack.raw({ + gap: 0, + opacity: { base: 1, _loading: 0.25 }, + pointerEvents: { base: "auto", _loading: "none" }, + userSelect: "none", + overflowX: "clip", + overflowY: "auto", + _scrollbar: { display: "none" }, + }), +}); + +export default EventGridRoot; diff --git a/src/components/app/templates/events/selection-box.tsx b/src/components/app/layouts/event-grid/selection-box.tsx similarity index 88% rename from src/components/app/templates/events/selection-box.tsx rename to src/components/app/layouts/event-grid/selection-box.tsx index 39c91883..9dbf10d1 100644 --- a/src/components/app/templates/events/selection-box.tsx +++ b/src/components/app/layouts/event-grid/selection-box.tsx @@ -3,15 +3,20 @@ import { useMemo } from "react"; import { styled } from "$:styled-system/jsx"; interface Props { - box: DOMRect; + box: DOMRect | null; } function EventGridSelectionBox({ box }: Props) { const styles = useMemo(() => { + if (!box) return undefined; + const width = box.right - box.left; const height = box.bottom - box.top; + return { width, height, top: box.top, left: box.left }; }, [box]); + if (!box) return null; + return ; } diff --git a/src/components/app/templates/events/timeline.tsx b/src/components/app/layouts/event-grid/timeline.tsx similarity index 87% rename from src/components/app/templates/events/timeline.tsx rename to src/components/app/layouts/event-grid/timeline.tsx index 0bc00cf9..004f9095 100644 --- a/src/components/app/templates/events/timeline.tsx +++ b/src/components/app/layouts/event-grid/timeline.tsx @@ -1,6 +1,7 @@ import { useParams } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; +import { For } from "$/components/ui/atoms"; import { scrubEventsHeader } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectEventsEditorCursor } from "$/store/selectors"; @@ -11,7 +12,7 @@ interface Props { beatNums: number[]; } function EventGridTimeline({ beatNums }: Props) { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const selectedBeat = useAppSelector(selectEventsEditorCursor); @@ -44,17 +45,21 @@ function EventGridTimeline({ beatNums }: Props) { return (
- {beatNums.map((num) => ( - - {num} - - ))} + + {(num) => ( + + {num} + + )} +
); } const Header = styled("div", { base: { + position: "relative", + width: "100%", display: "flex", borderBottomWidth: "sm", borderColor: "border.muted", diff --git a/src/components/app/layouts/event-grid/track.tsx b/src/components/app/layouts/event-grid/track.tsx new file mode 100644 index 00000000..bd0fffcc --- /dev/null +++ b/src/components/app/layouts/event-grid/track.tsx @@ -0,0 +1,28 @@ +import type { Assign } from "@ark-ui/react"; +import type { ComponentProps } from "react"; + +import { styled } from "$:styled-system/jsx"; + +interface Props { + disabled: boolean; +} +function EventGridTrack({ children, disabled, ...rest }: Assign, Props>) { + return ( + ev.preventDefault()}> + {children} + + ); +} + +const Wrapper = styled("div", { + base: { + position: "relative", + backgroundColor: { base: undefined, _disabled: "bg.disabled" }, + borderBlockWidth: { base: "sm", _lastOfType: 0 }, + borderColor: "border.muted", + opacity: { base: 1, _disabled: "disabled" }, + cursor: { base: undefined, _disabled: "not-allowed" }, + }, +}); + +export default EventGridTrack; diff --git a/src/components/app/layouts/index.ts b/src/components/app/layouts/index.ts index 2561bac6..66a3f121 100644 --- a/src/components/app/layouts/index.ts +++ b/src/components/app/layouts/index.ts @@ -1,8 +1,8 @@ +export * as ActionPanel from "./action-panel"; export * as ActionPanelGroup from "./action-panel-group"; -export { default as ErrorBoundary } from "./error-boundary"; -export { default as AppPageLayout } from "./page"; -export { default as PendingBoundary } from "./pending"; +export * as EventGrid from "./event-grid"; +export * as NavigationPanel from "./navigation-panel"; +export * as Page from "./page"; export * as Sidebar from "./sidebar"; export * as StatusBar from "./status-bar"; -export * as EditorView from "./view"; export * as AudioVisualizer from "./visualizer"; diff --git a/src/components/app/layouts/navigation-panel/index.ts b/src/components/app/layouts/navigation-panel/index.ts new file mode 100644 index 00000000..6d4d3ac4 --- /dev/null +++ b/src/components/app/layouts/navigation-panel/index.ts @@ -0,0 +1,21 @@ +import { styled } from "$:styled-system/jsx"; +import { hstack } from "$:styled-system/patterns"; + +export { default as Root } from "./root"; + +export const Section = styled("div", { + base: hstack.raw({ + gap: 4, + justify: "space-between", + overflowX: "auto", + _scrollbar: { display: "none" }, + }), +}); + +export const Column = styled("div", { + base: hstack.raw({ + justify: { base: "center", _first: "flex-start", _last: "flex-end" }, + flex: 1, + gap: { base: 1, _first: 4, _last: 4 }, + }), +}); diff --git a/src/components/app/templates/editor/navigation-panel/index.tsx b/src/components/app/layouts/navigation-panel/root.tsx similarity index 63% rename from src/components/app/templates/editor/navigation-panel/index.tsx rename to src/components/app/layouts/navigation-panel/root.tsx index 5bcaff76..82d25b36 100644 --- a/src/components/app/templates/editor/navigation-panel/index.tsx +++ b/src/components/app/layouts/navigation-panel/root.tsx @@ -1,17 +1,13 @@ -import { EditorAudioVisualizer } from "$/components/app/templates/editor"; +import { Children, type PropsWithChildren } from "react"; + +import { For } from "$/components/ui/atoms"; import { styled } from "$:styled-system/jsx"; import { stack } from "$:styled-system/patterns"; -import EditorNavigationControls from "./playback"; -function EditorNavigationPanel() { +function NavigationPanelRoot({ children }: PropsWithChildren) { return ( - - - - - - + {(child) => {child}} ); } @@ -39,4 +35,4 @@ const SubWrapper = styled("div", { }, }); -export default EditorNavigationPanel; +export default NavigationPanelRoot; diff --git a/src/components/app/layouts/page/footer.tsx b/src/components/app/layouts/page/footer.tsx index 25aa443e..030fe4e3 100644 --- a/src/components/app/layouts/page/footer.tsx +++ b/src/components/app/layouts/page/footer.tsx @@ -2,9 +2,8 @@ import { DotIcon } from "lucide-react"; import { Logo } from "$/components/app/compositions"; import { Interleave } from "$/components/ui/atoms"; -import { Text } from "$/components/ui/compositions"; -import { AnchorLink, RouterLink } from "$/components/ui/styled"; -import { Container, HStack, styled } from "$:styled-system/jsx"; +import { AnchorLink, RouterLink } from "$/components/ui/compositions"; +import { Container, HStack, styled, Text } from "$:styled-system/jsx"; import { center, stack } from "$:styled-system/patterns"; function Footer() { @@ -14,7 +13,7 @@ function Footer() { - }> + }> Privacy @@ -29,7 +28,7 @@ function Footer() { A side-project by Josh Comeau. Maintained by BSMG.
© 2019-present, All rights reserved.
- + Not affiliated with Beat Games™ or Beat Saber™.
diff --git a/src/components/app/layouts/page/header.tsx b/src/components/app/layouts/page/header.tsx index 6cf8120d..3d6b5abc 100644 --- a/src/components/app/layouts/page/header.tsx +++ b/src/components/app/layouts/page/header.tsx @@ -1,13 +1,12 @@ -import { Link } from "@tanstack/react-router"; import { DotIcon } from "lucide-react"; import { Logo } from "$/components/app/compositions"; import { Interleave } from "$/components/ui/atoms"; -import { Text } from "$/components/ui/compositions"; +import { RouterLink } from "$/components/ui/compositions"; import { Container, styled } from "$:styled-system/jsx"; import { flex, stack } from "$:styled-system/patterns"; -function EditorPageHeader() { +function Header() { return ( @@ -15,12 +14,10 @@ function EditorPageHeader() { - }> - - - Documentation - - + }> + + Documentation + @@ -58,4 +55,4 @@ const SectionWrapper = styled("div", { }), }); -export default EditorPageHeader; +export default Header; diff --git a/src/components/app/layouts/page/index.ts b/src/components/app/layouts/page/index.ts new file mode 100644 index 00000000..d5e78cd3 --- /dev/null +++ b/src/components/app/layouts/page/index.ts @@ -0,0 +1,16 @@ +import { Fragment } from "react"; + +import { Container, styled } from "$:styled-system/jsx"; + +export { default as Footer } from "./footer"; +export { default as Header } from "./header"; + +export const Root = Fragment; + +export const Content = styled(Container, { + base: { + flex: 1, + minHeight: "calc(100vh - {sizes.header} - {sizes.footer})", + paddingBlock: 8, + }, +}); diff --git a/src/components/app/layouts/page/index.tsx b/src/components/app/layouts/page/index.tsx deleted file mode 100644 index 22fc33a7..00000000 --- a/src/components/app/layouts/page/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Fragment, type PropsWithChildren } from "react"; - -import { Container, styled } from "$:styled-system/jsx"; -import Footer from "./footer"; -import Header from "./header"; - -function AppPageLayout({ children }: PropsWithChildren) { - return ( - -
- {children} -
- - ); -} - -const MainContent = styled(Container, { - base: { - flex: 1, - minHeight: "calc(100vh - {sizes.header} - {sizes.footer})", - paddingBlock: 8, - }, -}); - -export default AppPageLayout; diff --git a/src/components/app/layouts/sidebar/item.tsx b/src/components/app/layouts/sidebar/item.tsx index ffd46bfa..33ee1872 100644 --- a/src/components/app/layouts/sidebar/item.tsx +++ b/src/components/app/layouts/sidebar/item.tsx @@ -1,8 +1,8 @@ import type { LucideProps } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; -import { Text, Tooltip } from "$/components/ui/compositions"; -import { styled } from "$:styled-system/jsx"; +import { Tooltip } from "$/components/ui/compositions"; +import { styled, Text } from "$:styled-system/jsx"; import { center, linkOverlay } from "$:styled-system/patterns"; const TOOLTIP_POSITIONING = { placement: "right" } as const; @@ -17,8 +17,8 @@ function AppSidebarNavItem({ icon: Icon, tooltip, active, children, ...delegated return ( {tooltip}} positioning={TOOLTIP_POSITIONING}> - - + + {children( @@ -49,7 +49,7 @@ const ActiveIndicator = styled("div", { borderRightRadius: "md", transitionProperty: "transform", transitionDuration: "fast", - transform: { base: "translateX(-4px)", _active: "translateX(0)" }, + transform: { base: "translateX(-4px)", _current: "translateX(0)" }, }, }); diff --git a/src/components/app/layouts/status-bar/icon.tsx b/src/components/app/layouts/status-bar/icon.tsx index bd5b7134..9d386ded 100644 --- a/src/components/app/layouts/status-bar/icon.tsx +++ b/src/components/app/layouts/status-bar/icon.tsx @@ -13,7 +13,7 @@ interface Props { } function StatusBarIcon({ icon: Icon, onClick, size = 16, disabled }: Props) { return ( - + ); diff --git a/src/components/app/layouts/status-bar/indicator.tsx b/src/components/app/layouts/status-bar/indicator.tsx index 82f928fe..592f658d 100644 --- a/src/components/app/layouts/status-bar/indicator.tsx +++ b/src/components/app/layouts/status-bar/indicator.tsx @@ -1,8 +1,8 @@ import type { LucideProps } from "lucide-react"; import type { ComponentType, PropsWithChildren } from "react"; -import { Text, Tooltip } from "$/components/ui/compositions"; -import { styled } from "$:styled-system/jsx"; +import { Tooltip } from "$/components/ui/compositions"; +import { styled, Text } from "$:styled-system/jsx"; import { hstack } from "$:styled-system/patterns"; interface Props extends PropsWithChildren { @@ -14,7 +14,7 @@ function StatusBarIndicator({ label, icon: Icon, children }: Props) { label}> - + {children} diff --git a/src/components/app/layouts/status-bar/range.tsx b/src/components/app/layouts/status-bar/range.tsx index 2dc5117c..34b16f4a 100644 --- a/src/components/app/layouts/status-bar/range.tsx +++ b/src/components/app/layouts/status-bar/range.tsx @@ -15,7 +15,7 @@ function StatusBarRangeControl({ label, minIcon, maxIcon, min, max, step, value, label}> onValueChange?.({ value: [min ?? 0] })} disabled={disabled} /> - + onValueChange?.({ value: [max ?? 100] })} disabled={disabled} /> diff --git a/src/components/app/layouts/status-bar/toggle.tsx b/src/components/app/layouts/status-bar/toggle.tsx index 055478c1..eb411777 100644 --- a/src/components/app/layouts/status-bar/toggle.tsx +++ b/src/components/app/layouts/status-bar/toggle.tsx @@ -15,7 +15,7 @@ function StatusBarToggleControl({ label, onIcon, offIcon, checked, onCheckedChan label}> onCheckedChange?.({ checked: false })} disabled={disabled} /> - + onCheckedChange?.({ checked: true })} disabled={disabled} /> diff --git a/src/components/app/layouts/view/index.ts b/src/components/app/layouts/view/index.ts deleted file mode 100644 index 73ee0c3b..00000000 --- a/src/components/app/layouts/view/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Root } from "./root"; -export { default as Scene } from "./scene"; diff --git a/src/components/app/layouts/view/root.tsx b/src/components/app/layouts/view/root.tsx deleted file mode 100644 index 95311390..00000000 --- a/src/components/app/layouts/view/root.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { PropsWithChildren } from "react"; - -import { styled } from "$:styled-system/jsx"; - -function EditorViewRoot({ children }: PropsWithChildren) { - return {children}; -} - -const Wrapper = styled("div", { - base: { - backgroundColor: "black", - boxSize: "100%", - }, -}); - -export default EditorViewRoot; diff --git a/src/components/app/layouts/view/scene.tsx b/src/components/app/layouts/view/scene.tsx deleted file mode 100644 index a878f3ee..00000000 --- a/src/components/app/layouts/view/scene.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { PropsWithChildren } from "react"; -import { Fragment } from "react/jsx-runtime"; - -import { EditorNavigationPanel, EditorSongInfo, EditorStatusBar } from "$/components/app/templates/editor"; -import { DefaultEditorShortcuts } from "$/components/app/templates/shortcuts"; - -interface Props extends PropsWithChildren { - showBeatmapPicker?: boolean; -} -function EditorViewScene({ showBeatmapPicker, children }: Props) { - return ( - - - {children} - - - - - ); -} - -export default EditorViewScene; diff --git a/src/components/app/templates/editor/visualizer/bookmark.tsx b/src/components/app/layouts/visualizer/bookmark.tsx similarity index 96% rename from src/components/app/templates/editor/visualizer/bookmark.tsx rename to src/components/app/layouts/visualizer/bookmark.tsx index 54c26ba1..5896c5eb 100644 --- a/src/components/app/templates/editor/visualizer/bookmark.tsx +++ b/src/components/app/layouts/visualizer/bookmark.tsx @@ -25,9 +25,10 @@ function EditorBookmark({ bookmark, offset, onMarkerClick, ...rest }: Props) { return ( - setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} @@ -44,7 +45,7 @@ function EditorBookmark({ bookmark, offset, onMarkerClick, ...rest }: Props) { - + ); } @@ -62,7 +63,7 @@ const ThinStrip = styled("div", { }, }); -const Flag = styled(Button, { +const Flag = styled("button", { base: { position: "absolute", zIndex: 2, diff --git a/src/components/app/layouts/visualizer/content.tsx b/src/components/app/layouts/visualizer/content.tsx index 9b6ae739..737f65c4 100644 --- a/src/components/app/layouts/visualizer/content.tsx +++ b/src/components/app/layouts/visualizer/content.tsx @@ -20,7 +20,7 @@ interface Props { cursorPosition: number; duration: number | null; onVisualizerClick?: (event: MouseEvent, time: number) => void; - children: (ref: RefObject) => ReactNode; + children: (ref: RefObject) => ReactNode; } function AudioVisualizerContent({ cursorPosition, duration, onVisualizerClick, children }: Props) { const wait = useAppSelector(selectPacerWait); @@ -64,14 +64,14 @@ function AudioVisualizerContent({ cursorPosition, duration, onVisualizerClick, c }; const progressStyles = useMemo(() => { - const ratioPlayed = duration ? cursorPosition / duration : 0; - return { transform: `scaleX(${1 - ratioPlayed})` }; + const ratioPlayed = duration ? (cursorPosition / duration) * 100 : 0; + return { clipPath: `inset(0 ${100 - ratioPlayed}% 0 0)` }; }, [cursorPosition, duration]); return ( - {children(canvasRef)} - + {children(canvasRef)} + {children(canvasRef)} ); } @@ -82,15 +82,15 @@ const Wrapper = styled("div", { }, }); -const ProgressRect = styled("div", { +const Layer = styled("div", { base: { position: "absolute", - zIndex: 2, inset: 0, - backgroundColor: "bg.translucent", - mixBlendMode: "darken", - transformOrigin: "center right", - pointerEvents: "none", + }, + variants: { + overlay: { + true: { opacity: 0.25 }, + }, }, }); diff --git a/src/components/app/layouts/visualizer/index.ts b/src/components/app/layouts/visualizer/index.ts index 1027484b..e2ae4b98 100644 --- a/src/components/app/layouts/visualizer/index.ts +++ b/src/components/app/layouts/visualizer/index.ts @@ -1,3 +1,4 @@ +export { default as Bookmark } from "./bookmark"; export { default as Content } from "./content"; export { default as Markers } from "./markers"; export { default as Root } from "./root"; diff --git a/src/components/app/layouts/visualizer/markers.tsx b/src/components/app/layouts/visualizer/markers.tsx index fd7964c3..3d4cddb1 100644 --- a/src/components/app/layouts/visualizer/markers.tsx +++ b/src/components/app/layouts/visualizer/markers.tsx @@ -1,5 +1,6 @@ import type { MouseEvent, ReactNode } from "react"; +import { For } from "$/components/ui/atoms"; import type { App } from "$/types"; interface Props { @@ -12,11 +13,15 @@ interface Props { function AudioVisualizerMarkers({ markers, duration, offset, onMarkerClick, children }: Props) { // Add the bookmarks in reverse. // This way, they stack from left to right, so earlier flags sit in front of later ones. This is important when hovering, to be able to see the flag name - return [...markers].reverse().map((bookmark) => { - const beatNumWithOffset = bookmark.time + (offset ?? 0); - const offsetPercentage = (beatNumWithOffset / duration) * 100; - return children(bookmark, { offset: offsetPercentage, onMarkerClick: onMarkerClick }); - }); + return ( + + {(bookmark) => { + const beatNumWithOffset = bookmark.time + (offset ?? 0); + const offsetPercentage = (beatNumWithOffset / duration) * 100; + return children(bookmark, { offset: offsetPercentage, onMarkerClick: onMarkerClick }); + }} + + ); } export default AudioVisualizerMarkers; diff --git a/src/components/app/layouts/visualizer/root.tsx b/src/components/app/layouts/visualizer/root.tsx index 29b6586b..2d0b9743 100644 --- a/src/components/app/layouts/visualizer/root.tsx +++ b/src/components/app/layouts/visualizer/root.tsx @@ -1,37 +1,28 @@ +import type { Assign } from "@ark-ui/react"; import { type ComponentProps, forwardRef } from "react"; +import { Show } from "$/components/ui/atoms"; import { Spinner } from "$/components/ui/compositions"; import { styled } from "$:styled-system/jsx"; import { center } from "$:styled-system/patterns"; -interface Props extends ComponentProps<"div"> { +interface Props { isLoading?: boolean; } -const AudioVisualizerRoot = forwardRef(({ isLoading, children, ...rest }, ref) => { +const AudioVisualizerRoot = forwardRef, Props>>(({ isLoading, children, ...rest }, ref) => { return ( - {isLoading && ( - - - - )} - {children} + }> + {children} + ); }); const Wrapper = styled("div", { - base: { + base: center.raw({ width: "100%", height: "60px", - }, -}); - -const SpinnerWrapper = styled("div", { - base: center.raw({ - position: "absolute", - inset: 0, - boxSize: "100%", }), }); diff --git a/src/components/app/templates/action-panel-groups/clipboard.tsx b/src/components/app/templates/action-panel-groups/clipboard.tsx deleted file mode 100644 index 5250bb52..00000000 --- a/src/components/app/templates/action-panel-groups/clipboard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Presence } from "@ark-ui/react/presence"; -import { useParams, useRouteContext } from "@tanstack/react-router"; - -import { ActionPanelGroup } from "$/components/app/layouts"; -import { Button } from "$/components/ui/compositions"; -import { copySelection, cutSelection, pasteSelection } from "$/store/actions"; -import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectAnySelectedObjects, selectClipboardHasObjects } from "$/store/selectors"; - -function ClipboardActionPanelActionGroup() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); - const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); - - const dispatch = useAppDispatch(); - const isAnythingSelected = useAppSelector(selectAnySelectedObjects); - const hasCopiedNotes = useAppSelector(selectClipboardHasObjects); - - return ( - - - - - - - - - - ); -} - -export default ClipboardActionPanelActionGroup; diff --git a/src/components/app/templates/action-panel-groups/default.tsx b/src/components/app/templates/action-panel-groups/default.tsx deleted file mode 100644 index 92c4c7db..00000000 --- a/src/components/app/templates/action-panel-groups/default.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useParams } from "@tanstack/react-router"; -import type { MouseEventHandler } from "react"; - -import { useAppPrompterContext } from "$/components/app/compositions"; -import { ActionPanelGroup } from "$/components/app/layouts"; -import ClipboardActionPanelActionGroup from "$/components/app/templates/action-panel-groups/clipboard"; -import { Button, Tooltip } from "$/components/ui/compositions"; -import { useAppSelector } from "$/store/hooks"; -import { selectModuleEnabled } from "$/store/selectors"; -import HistoryActionPanelActionGroup from "./history"; - -interface Props { - handleGridConfigClick: MouseEventHandler; -} -function DefaultActionPanelGroup({ handleGridConfigClick }: Props) { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); - - const mappingExtensionsEnabled = useAppSelector((state) => selectModuleEnabled(state, sid, "mappingExtensions")); - - const { openPrompt } = useAppPrompterContext(); - - return ( - - - - - "Select everything over a time period"}> - - - "Jump to a specific beat number"}> - - - {mappingExtensionsEnabled && ( - "Change the number of columns/rows"}> - - - )} - - - ); -} - -export default DefaultActionPanelGroup; diff --git a/src/components/app/templates/action-panel-groups/direction.tsx b/src/components/app/templates/action-panel-groups/direction.tsx deleted file mode 100644 index 950767a8..00000000 --- a/src/components/app/templates/action-panel-groups/direction.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { NoteDirection } from "bsmap"; -import { ArrowDownIcon, ArrowDownLeftIcon, ArrowDownRightIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, ArrowUpLeftIcon, ArrowUpRightIcon, CircleIcon } from "lucide-react"; -import { useMemo } from "react"; - -import { ActionPanelGroup } from "$/components/app/layouts"; -import { Button } from "$/components/ui/compositions"; -import { updateNotesEditorDirection } from "$/store/actions"; -import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectNotesEditorDirection, selectNotesEditorTool } from "$/store/selectors"; -import { ObjectTool } from "$/types"; -import { Grid } from "$:styled-system/jsx"; - -function NoteDirectionActionPanelGroup() { - const dispatch = useAppDispatch(); - const selectedDirection = useAppSelector(selectNotesEditorDirection); - const selectedNoteTool = useAppSelector(selectNotesEditorTool); - - const isDisabled = useMemo(() => selectedNoteTool !== ObjectTool.LEFT_NOTE && selectedNoteTool !== ObjectTool.RIGHT_NOTE, [selectedNoteTool]); - - return ( - - - - - - - - - - - - - - ); -} - -export default NoteDirectionActionPanelGroup; diff --git a/src/components/app/templates/action-panel-groups/history.tsx b/src/components/app/templates/action-panel-groups/history.tsx deleted file mode 100644 index 8354b7d5..00000000 --- a/src/components/app/templates/action-panel-groups/history.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useParams } from "@tanstack/react-router"; - -import { ActionPanelGroup } from "$/components/app/layouts"; -import { Button } from "$/components/ui/compositions"; -import { redoObjects, undoObjects } from "$/store/actions"; -import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectObjectsCanRedo, selectObjectsCanUndo } from "$/store/selectors"; - -function HistoryActionPanelActionGroup() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); - - const dispatch = useAppDispatch(); - const canUndo = useAppSelector(selectObjectsCanUndo); - const canRedo = useAppSelector(selectObjectsCanRedo); - - return ( - - - - - ); -} - -export default HistoryActionPanelActionGroup; diff --git a/src/components/app/templates/action-panel-groups/obstacles.tsx b/src/components/app/templates/action-panel-groups/obstacles.tsx deleted file mode 100644 index 93ee1eea..00000000 --- a/src/components/app/templates/action-panel-groups/obstacles.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useAppPrompterContext } from "$/components/app/compositions"; -import { ActionPanelGroup } from "$/components/app/layouts"; -import { Button } from "$/components/ui/compositions"; - -function ObstaclesActionPanelGroup() { - const { openPrompt } = useAppPrompterContext(); - - return ( - - - - - - ); -} - -export default ObstaclesActionPanelGroup; diff --git a/src/components/app/templates/details/custom-colors.tsx b/src/components/app/templates/details/custom-colors.tsx index 7b7d97d0..d959a5a0 100644 --- a/src/components/app/templates/details/custom-colors.tsx +++ b/src/components/app/templates/details/custom-colors.tsx @@ -2,13 +2,14 @@ import { parseColor } from "@ark-ui/react/color-picker"; import { useParams } from "@tanstack/react-router"; import { useDeferredValue, useEffect, useState } from "react"; +import { For } from "$/components/ui/atoms"; import { ColorPicker, Heading, Switch } from "$/components/ui/compositions"; import { updateCustomColor } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectColorScheme, selectCustomColors } from "$/store/selectors"; import { ColorSchemeKey } from "$/types"; import { styled, VStack } from "$:styled-system/jsx"; -import { vstack, wrap } from "$:styled-system/patterns"; +import { wrap } from "$:styled-system/patterns"; const BEATMAP_COLOR_KEY_RENAME = { [ColorSchemeKey.SABER_LEFT]: "Left Saber", @@ -22,8 +23,8 @@ const BEATMAP_COLOR_KEY_RENAME = { [ColorSchemeKey.BOOST_WHITE]: "Boost W", } as const; -function ElementControl({ element }: { element: ColorSchemeKey }) { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); +function CustomColorSwatch({ element }: { element: ColorSchemeKey }) { + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const customColors = useAppSelector((state) => selectCustomColors(state, sid)); @@ -39,22 +40,18 @@ function ElementControl({ element }: { element: ColorSchemeKey }) { }, [active, deferredColor, dispatch, sid, element]); return ( - - - setColor(`#${x.value.toHexInt().toString(16)}`)} /> - {BEATMAP_COLOR_KEY_RENAME[element]} - setActive(!!x.checked)} /> - - + + setColor(`#${x.value.toHexInt().toString(16)}`)} /> + {BEATMAP_COLOR_KEY_RENAME[element]} + setActive(!!x.checked)} /> + ); } function CustomColorSettings() { return ( - {Object.values(ColorSchemeKey).map((element) => { - return ; - })} + {(element) => } ); } @@ -62,13 +59,10 @@ function CustomColorSettings() { const Row = styled("div", { base: wrap.raw({ paddingBlock: 4, - }), -}); - -const Cell = styled("div", { - base: vstack.raw({ - gap: 3, - flex: 1, + "& > *": { + width: "100%", + flex: 1, + }, }), }); diff --git a/src/components/app/templates/details/index.tsx b/src/components/app/templates/details/index.tsx index dc75519c..43aaf880 100644 --- a/src/components/app/templates/details/index.tsx +++ b/src/components/app/templates/details/index.tsx @@ -1,94 +1,23 @@ -import { useDialog } from "@ark-ui/react/dialog"; -import { Link, useBlocker, useParams } from "@tanstack/react-router"; -import { EnvironmentNameSchema, EnvironmentV3NameSchema } from "bsmap"; -import { useCallback, useState } from "react"; -import { gtValue, minLength, number, object, pipe, string, transform, union } from "valibot"; +import { useParams } from "@tanstack/react-router"; -import { LocalFileUpload } from "$/components/app/compositions"; -import { APP_TOASTER, COVER_ART_FILE_ACCEPT_TYPE, ENVIRONMENT_COLLECTION, SONG_FILE_ACCEPT_TYPE } from "$/components/app/constants"; -import { UpdateBeatmapForm } from "$/components/app/forms"; -import { useMount } from "$/components/hooks"; -import { AlertDialogProvider, Field, Heading, Text, useAppForm } from "$/components/ui/compositions"; -import { BeatmapFilestore } from "$/services/file.service"; -import { filestore } from "$/setup"; -import { stopPlayback, updateModuleEnabled, updateSong } from "$/store/actions"; +import { UpdateBeatmapForm, UpdateSongForm } from "$/components/app/forms"; +import { useMount } from "$/components/hooks/use-mount"; +import { For } from "$/components/ui/atoms"; +import { Heading, RouterLink } from "$/components/ui/compositions"; +import { stopPlayback, updateModuleEnabled } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectBeatmapIds, selectEditorOffset, selectModuleEnabled, selectSongById } from "$/store/selectors"; +import { selectBeatmapIds, selectEditorOffset, selectModuleEnabled } from "$/store/selectors"; import { Stack, styled, Wrap } from "$:styled-system/jsx"; import CustomColorSettings from "./custom-colors"; import SongDetailsModule from "./module"; -const SCHEMA = object({ - name: pipe(string(), minLength(1)), - subName: pipe(string()), - artistName: pipe(string(), minLength(1)), - bpm: pipe(number(), gtValue(0)), - offset: pipe( - number(), - transform((input) => (Number.isNaN(input) ? undefined : input)), - ), - swingAmount: pipe( - number(), - transform(() => 0.5), - ), - swingPeriod: pipe( - number(), - transform(() => 0), - ), - previewStartTime: pipe(number()), - previewDuration: pipe(number()), - environment: union([EnvironmentNameSchema, EnvironmentV3NameSchema]), -}); - function SongDetails() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); - const song = useAppSelector((state) => selectSongById(state, sid)); const enabledCustomColors = useAppSelector((state) => selectModuleEnabled(state, sid, "customColors")); const enabledMappingExtensions = useAppSelector((state) => selectModuleEnabled(state, sid, "mappingExtensions")); - const [songFile, setSongFile] = useState(null); - const [coverArtFile, setCoverArtFile] = useState(null); - - const Form = useAppForm({ - defaultValues: { - name: song.name ?? "", - subName: song.subName ?? "", - artistName: song.artistName ?? "", - bpm: song.bpm ?? 120, - offset: song.offset ?? 0, - swingAmount: song.swingAmount ?? 0, - swingPeriod: song.swingPeriod ?? 0, - previewStartTime: song.previewStartTime ?? 12, - previewDuration: song.previewDuration ?? 10, - environment: song.environment, - }, - validators: { - onMount: SCHEMA, - onChange: SCHEMA, - onSubmit: SCHEMA, - }, - onSubmit: async ({ value, formApi }) => { - const newSongObject = { ...song, ...value }; - - if (coverArtFile) { - const { filename: coverArtFilename } = await filestore.saveCoverArtFile(sid, coverArtFile); - newSongObject.coverArtFilename = coverArtFilename; - } - - if (songFile) { - const { filename: songFilename } = await filestore.saveSongFile(sid, songFile); - newSongObject.songFilename = songFilename; - } - - // Update our redux state - dispatch(updateSong({ songId: sid, changes: newSongObject })); - - formApi.reset(value); - }, - }); - const beatmapIds = useAppSelector((state) => selectBeatmapIds(state, sid)); const offset = useAppSelector((state) => selectEditorOffset(state, sid)); @@ -98,89 +27,22 @@ function SongDetails() { dispatch(stopPlayback({ offset: offset })); }); - const handleAcceptSongFile = useCallback( - (file: File) => { - setSongFile(file); - filestore.saveSongFile(sid, file).then(() => { - APP_TOASTER.create({ - id: "song-file-accepted", - type: "success", - description: "Successfully updated song file!", - }); - }); - }, - [sid], - ); - - const handleAcceptCoverArtFile = useCallback( - (file: File) => { - setCoverArtFile(file); - filestore.saveCoverArtFile(sid, file).then(() => { - APP_TOASTER.create({ - id: "cover-file-accepted", - type: "success", - description: "Successfully updated cover art file!", - }); - }); - }, - [sid], - ); - - const { proceed, reset, status } = useBlocker({ - shouldBlockFn: () => Form.state.isDirty, - withResolver: true, - }); - - const isDirtyAlert = useDialog({ role: "alertdialog", open: status === "blocked" }); - return ( Song Details - - {status === "blocked" && You have unsaved changes! Are you sure you want to leave this page?} onSubmit={proceed} onCancel={reset} />} - - - - handleAcceptSongFile(details.files[0])}> - Audio File - - - - handleAcceptCoverArtFile(details.files[0])}> - Image File - - - - - {/* @ts-ignore */} - {(ctx) => } - {(ctx) => } - {(ctx) => } - - - {(ctx) => } - {(ctx) => } - {(ctx) => } - {(ctx) => } - - - {(ctx) => } - - Update song details - - + Beatmaps - {beatmapIds.map((beatmapId) => { - return ( + + {(beatmapId) => ( - ); - })} + )} + @@ -188,20 +50,16 @@ function SongDetails() { } checked={enabledCustomColors} onCheckedChange={() => dispatch(updateModuleEnabled({ songId: sid, key: "customColors" }))}> Override individual elements of a beatmap's color scheme.{" "} - - - Learn more - - + + Learn more + . null} checked={enabledMappingExtensions} onCheckedChange={() => dispatch(updateModuleEnabled({ songId: sid, key: "mappingExtensions" }))}> Allows you to customize size and shape of the grid, to place notes outside of the typical 4×3 grid.{" "} - - - Learn more - - + + Learn more + . diff --git a/src/components/app/templates/details/module.tsx b/src/components/app/templates/details/module.tsx index 5ee9731f..5e36c6f0 100644 --- a/src/components/app/templates/details/module.tsx +++ b/src/components/app/templates/details/module.tsx @@ -32,7 +32,7 @@ function SongDetailsModule({ label, render, checked: initialOpen, onCheckedChang {label} {children && ( children}> - + diff --git a/src/components/app/templates/download/index.tsx b/src/components/app/templates/download/index.tsx index 850b58f6..f12c1082 100644 --- a/src/components/app/templates/download/index.tsx +++ b/src/components/app/templates/download/index.tsx @@ -1,27 +1,17 @@ -import { Presence } from "@ark-ui/react/presence"; import { useParams } from "@tanstack/react-router"; import { useMemo } from "react"; -import { boolean, null_, object, picklist, union } from "valibot"; -import { VERSION_COLLECTION } from "$/components/app/constants"; -import { useMount } from "$/components/hooks"; -import { Heading, Text, useAppForm } from "$/components/ui/compositions"; -import { Panel } from "$/components/ui/styled"; -import type { ImplicitVersion } from "$/helpers/serialization.helpers"; +import { ExportMapForm } from "$/components/app/forms"; +import { useMount } from "$/components/hooks/use-mount"; +import { Show } from "$/components/ui/atoms"; +import { Heading } from "$/components/ui/compositions"; import { downloadMapFiles, pausePlayback } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectDemo, selectPlaying } from "$/store/selectors"; -import { Stack, styled, VStack } from "$:styled-system/jsx"; -import { vstack } from "$:styled-system/patterns"; - -const SCHEMA = object({ - version: union([picklist(["1", "2", "3", "4"]), null_()]), - minify: boolean(), - purgeZeros: boolean(), -}); +import { Stack, Text } from "$:styled-system/jsx"; function Download() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const isDemo = useAppSelector((state) => selectDemo(state, sid)); @@ -34,85 +24,16 @@ function Download() { } }); - const Form = useAppForm({ - defaultValues: { - version: null as "1" | "2" | "3" | "4" | null, - minify: false, - purgeZeros: false, - }, - validators: { - onMount: SCHEMA, - onChange: SCHEMA, - onSubmit: SCHEMA, - }, - onSubmit: ({ value }) => { - dispatch( - downloadMapFiles({ - songId: sid, - version: value.version ? (Number.parseInt(value.version, 10) as ImplicitVersion) : undefined, - options: { - format: value.minify ? 0 : 2, - optimize: { - purgeZeros: value.purgeZeros, - }, - }, - }), - ); - }, - }); - const demoBlocker = useMemo(() => import.meta.env.PROD && isDemo, [isDemo]); return ( - - - Download Map - - Unfortunately, the demo map is not available for download. - - - - - - - Click to download a .zip containing all of the files needed to transfer your map onto a device for testing, or to submit for uploading. - - Download map files - - - - - - Options - - - - {(ctx) => ( - - - - {ctx.state.value !== null ? null : "NOTE: If the version is left unset, the implicit version of your map will be used (derived from when the map was originally created/imported in the editor)."} - - - )} - - - - {(ctx) => } - {(ctx) => } - - - - - + + Download Map + Unfortunately, the demo map is not available for download.}> + dispatch(downloadMapFiles({ songId: sid, version, options }))} /> + + ); } -const Content = styled(Panel, { - base: vstack.raw({ - padding: 4, - textAlign: "center", - }), -}); - export default Download; diff --git a/src/components/app/templates/editor/action-panel-groups/default.tsx b/src/components/app/templates/editor/action-panel-groups/default.tsx new file mode 100644 index 00000000..0c2b305e --- /dev/null +++ b/src/components/app/templates/editor/action-panel-groups/default.tsx @@ -0,0 +1,83 @@ +import { useParams, useRouteContext } from "@tanstack/react-router"; +import type { MouseEventHandler } from "react"; + +import { createJumpToBeatPrompt, createQuickSelectPrompt } from "$/components/app/constants"; +import { ActionPanelGroup } from "$/components/app/layouts"; +import { Show } from "$/components/ui/atoms"; +import { Button, Tooltip, usePrompt } from "$/components/ui/compositions"; +import { copySelection, cutSelection, jumpToBeat, pasteSelection, redoObjects, selectAllEntitiesInRange, undoObjects } from "$/store/actions"; +import { useAppDispatch, useAppSelector } from "$/store/hooks"; +import { selectAnySelectedObjects, selectClipboardHasObjects, selectModuleEnabled, selectObjectsCanRedo, selectObjectsCanUndo } from "$/store/selectors"; + +interface Props { + handleGridConfigClick?: MouseEventHandler; +} +function DefaultActionPanelGroup({ handleGridConfigClick }: Props) { + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); + + const dispatch = useAppDispatch(); + const canUndo = useAppSelector(selectObjectsCanUndo); + const canRedo = useAppSelector(selectObjectsCanRedo); + const isAnythingSelected = useAppSelector(selectAnySelectedObjects); + const hasCopiedNotes = useAppSelector(selectClipboardHasObjects); + const mappingExtensionsEnabled = useAppSelector((state) => selectModuleEnabled(state, sid, "mappingExtensions")); + + const { trigger: triggerQuickSelect } = usePrompt( + createQuickSelectPrompt({ + render: ({ form }) => {(ctx) => }, + onSubmit: ({ value: { start, end } }) => dispatch(selectAllEntitiesInRange({ songId: sid, view: view, start, end })), + }), + ); + const { trigger: triggerJumpToBeat } = usePrompt( + createJumpToBeatPrompt({ + render: ({ form }) => {(ctx) => }, + onSubmit: ({ value: { beatNum } }) => dispatch(jumpToBeat({ songId: sid, pauseTrack: true, beatNum: beatNum })), + }), + ); + + return ( + + + + + + + + + + + + "Select everything over a time period"}> + + + "Jump to a specific beat number"}> + + + + "Change the number of columns/rows"}> + + + + + + ); +} + +export default DefaultActionPanelGroup; diff --git a/src/components/app/templates/editor/action-panel-groups/direction.tsx b/src/components/app/templates/editor/action-panel-groups/direction.tsx new file mode 100644 index 00000000..2775cf04 --- /dev/null +++ b/src/components/app/templates/editor/action-panel-groups/direction.tsx @@ -0,0 +1,55 @@ +import { NoteDirection } from "bsmap"; +import { ArrowDownIcon, ArrowDownLeftIcon, ArrowDownRightIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, ArrowUpLeftIcon, ArrowUpRightIcon, CircleIcon } from "lucide-react"; +import { useMemo } from "react"; + +import { ActionPanelGroup } from "$/components/app/layouts"; +import { Button } from "$/components/ui/compositions"; +import { updateNotesEditorDirection } from "$/store/actions"; +import { useAppDispatch, useAppSelector } from "$/store/hooks"; +import { selectNotesEditorDirection, selectNotesEditorTool } from "$/store/selectors"; +import { ObjectTool } from "$/types"; +import { Grid } from "$:styled-system/jsx"; + +function NoteDirectionActionPanelGroup() { + const dispatch = useAppDispatch(); + const selectedDirection = useAppSelector(selectNotesEditorDirection); + const selectedNoteTool = useAppSelector(selectNotesEditorTool); + + const isDisabled = useMemo(() => selectedNoteTool !== ObjectTool.LEFT_NOTE && selectedNoteTool !== ObjectTool.RIGHT_NOTE, [selectedNoteTool]); + + return ( + + + + + + + + + + + + + + ); +} + +export default NoteDirectionActionPanelGroup; diff --git a/src/components/app/templates/editor/action-panel-groups/grid-presets.tsx b/src/components/app/templates/editor/action-panel-groups/grid-presets.tsx new file mode 100644 index 00000000..65549731 --- /dev/null +++ b/src/components/app/templates/editor/action-panel-groups/grid-presets.tsx @@ -0,0 +1,44 @@ +import { createListCollection } from "@ark-ui/react/collection"; +import { useParams } from "@tanstack/react-router"; +import { ArrowUpFromDotIcon, TrashIcon } from "lucide-react"; +import { useState } from "react"; + +import { ActionPanelGroup } from "$/components/app/layouts"; +import { Button, Select, Tooltip } from "$/components/ui/compositions"; +import { loadGridPreset, removeGridPreset } from "$/store/actions"; +import { useAppDispatch, useAppSelector } from "$/store/hooks"; +import { selectGridPresets } from "$/store/selectors"; +import { isObjectEmpty } from "$/utils"; + +function GridPresetsActionPanelGroup() { + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + + const dispatch = useAppDispatch(); + const gridPresets = useAppSelector(selectGridPresets); + + const [slot, setSlot] = useState(""); + + if (isObjectEmpty(gridPresets)) return null; + + return ( + + + setSlot(x.value[0])} /> - - - "Load Grid Preset"}> - - - "Delete Grid Preset"}> - - - - - )} - - - - - ev.stopPropagation()} onValueChange={(details) => sid && dispatch(updateGridSize({ songId: sid, changes: { numCols: details.valueAsNumber } }))} /> - - - ev.stopPropagation()} onValueChange={(details) => sid && dispatch(updateGridSize({ songId: sid, changes: { numRows: details.valueAsNumber } }))} /> - - - ev.stopPropagation()} onValueChange={(details) => sid && dispatch(updateGridSize({ songId: sid, changes: { colWidth: details.valueAsNumber } }))} /> - - - ev.stopPropagation()} onValueChange={(details) => sid && dispatch(updateGridSize({ songId: sid, changes: { rowHeight: details.valueAsNumber } }))} /> - - - - - - - - - - ); -} - -export default GridActionPanel; diff --git a/src/components/app/templates/editor/action-panel/index.tsx b/src/components/app/templates/editor/action-panel/index.tsx deleted file mode 100644 index c00d1d12..00000000 --- a/src/components/app/templates/editor/action-panel/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Presence } from "@ark-ui/react/presence"; -import { useParams } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; - -import { useOnChange, useOnKeydown } from "$/components/hooks"; -import { useAppSelector } from "$/store/hooks"; -import { selectAllSelectedBombNotes, selectAllSelectedColorNotes, selectAllSelectedObstacles, selectPlacementMode } from "$/store/selectors"; -import { ObjectPlacementMode } from "$/types"; -import { styled } from "$:styled-system/jsx"; -import { center, vstack } from "$:styled-system/patterns"; -import DefaultActionPanel from "./default"; -import GridActionPanel from "./grid"; -import SelectionActionPanel from "./selection"; - -function EditorActionPanel() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); - - const mappingMode = useAppSelector((state) => selectPlacementMode(state, sid)); - const selectedBlocks = useAppSelector(selectAllSelectedColorNotes); - const selectedMines = useAppSelector(selectAllSelectedBombNotes); - const selectedObstacles = useAppSelector(selectAllSelectedObstacles); - - const isAnythingSelected = useMemo(() => selectedBlocks.length > 0 || selectedObstacles.length > 0 || selectedMines.length > 0, [selectedBlocks, selectedObstacles, selectedMines]); - - const [showGridConfig, setShowGridConfig] = useState(false); - - useOnChange( - () => { - if (showGridConfig && isAnythingSelected) { - // If the user selects something while the grid panel is open, switch to the selection panel - setShowGridConfig(false); - } - }, - selectedBlocks.length + selectedMines.length + selectedObstacles.length, - ); - - useOnKeydown("KeyG", () => { - if (mappingMode === ObjectPlacementMode.EXTENSIONS) { - setShowGridConfig((currentVal) => !currentVal); - } - }, [mappingMode]); - - return ( - ev.stopPropagation()}> - - - setShowGridConfig(true)} /> - - - - - - - - - - setShowGridConfig(false)} /> - - - - ); -} - -const OuterWrapper = styled("div", { - base: center.raw({ - position: "absolute", - top: 0, - bottom: "calc({sizes.navigationPanel} + {sizes.statusBar})", - right: 0, - width: "200px", - pointerEvents: "none", - }), -}); - -const Wrapper = styled("div", { - base: vstack.raw({ - height: "fit-content", - maxHeight: "100%", - justify: "start", - padding: 4, - gap: 4, - backgroundColor: "bg.translucent", - color: "fg.default", - borderLeftRadius: "md", - borderBlockWidth: "sm", - borderLeftWidth: "sm", - borderColor: "border.muted", - backdropFilter: "blur(8px)", - pointerEvents: "auto", - userSelect: "none", - overflowY: "auto", - _scrollbar: { display: "none" }, - }), -}); - -export default EditorActionPanel; diff --git a/src/components/app/templates/editor/action-panel/selection.tsx b/src/components/app/templates/editor/action-panel/selection.tsx deleted file mode 100644 index ac730d0d..00000000 --- a/src/components/app/templates/editor/action-panel/selection.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useParams, useRouteContext } from "@tanstack/react-router"; -import { ArrowDownToLineIcon, ArrowUpToLineIcon, DotIcon, FlipHorizontal2Icon, FlipVertical2Icon } from "lucide-react"; -import { Fragment, type MouseEventHandler, useMemo } from "react"; - -import { ActionPanelGroup } from "$/components/app/layouts"; -import { ClipboardActionPanelActionGroup, HistoryActionPanelActionGroup, ObstaclesActionPanelGroup } from "$/components/app/templates/action-panel-groups"; -import { Interleave } from "$/components/ui/atoms"; -import { Button, StrikethroughOnHover, Text, Tooltip } from "$/components/ui/compositions"; -import { deselectAllEntities, deselectAllEntitiesOfType, mirrorSelection, nudgeSelection } from "$/store/actions"; -import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectGridSize } from "$/store/selectors"; -import { ObjectType } from "$/types"; - -interface CountProps { - num: number; - label: string; - onClick: MouseEventHandler; -} -function SelectionCount({ num, label, onClick }: CountProps) { - const pluralizedLabel = useMemo(() => (num === 1 ? label : `${label}s`), [num, label]); - - return ( - - ); -} - -interface Props { - numOfSelectedBlocks: number; - numOfSelectedMines: number; - numOfSelectedObstacles: number; -} -function SelectionActionPanel({ numOfSelectedBlocks, numOfSelectedMines, numOfSelectedObstacles }: Props) { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); - const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); - - const dispatch = useAppDispatch(); - const grid = useAppSelector((state) => selectGridSize(state, sid)); - - const hasSelectedObstacles = useMemo(() => numOfSelectedObstacles >= 1, [numOfSelectedObstacles]); - - const numbers = []; - if (numOfSelectedBlocks) { - numbers.push( dispatch(deselectAllEntitiesOfType({ itemType: ObjectType.NOTE }))} />); - } - if (numOfSelectedMines) { - numbers.push( dispatch(deselectAllEntitiesOfType({ itemType: ObjectType.BOMB }))} />); - } - if (numOfSelectedObstacles) { - numbers.push( dispatch(deselectAllEntitiesOfType({ itemType: ObjectType.OBSTACLE }))} />); - } - - return ( - - - - }>{numbers} - - - {hasSelectedObstacles && } - - - "Mirror selection horizontally"}> - - - "Mirror selection vertically"}> - - - - - "Nudge selection forwards"}> - - - "Nudge selection backwards"}> - - - - - - - - - - - ); -} - -export default SelectionActionPanel; diff --git a/src/components/app/templates/editor/index.ts b/src/components/app/templates/editor/index.ts index dd544602..5106e986 100644 --- a/src/components/app/templates/editor/index.ts +++ b/src/components/app/templates/editor/index.ts @@ -1,6 +1,5 @@ export { default as EditorActionPanel } from "./action-panel"; -export { default as EditorNavigationPanel } from "./navigation-panel"; -export { default as EditorPrompts } from "./prompts"; +export { default as EditorNavigationControls } from "./navigation-controls"; export { default as EditorSidebar } from "./sidebar"; export { default as EditorSongInfo } from "./song-info"; export { default as EditorStatusBar } from "./status-bar"; diff --git a/src/components/app/templates/editor/navigation-controls.tsx b/src/components/app/templates/editor/navigation-controls.tsx new file mode 100644 index 00000000..917ee7a8 --- /dev/null +++ b/src/components/app/templates/editor/navigation-controls.tsx @@ -0,0 +1,69 @@ +import { useParams, useRouteContext } from "@tanstack/react-router"; +import { FastForwardIcon, PauseIcon, PlayIcon, RewindIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react"; + +import { SNAPPING_INCREMENT_LIST_COLLECTION } from "$/components/app/constants"; +import { NavigationPanel } from "$/components/app/layouts"; +import { Button, Select, Stat } from "$/components/ui/compositions"; +import { formatCursorPosition, formatCursorPositionInBeats } from "$/helpers/audio.helpers"; +import { jumpToEnd, jumpToStart, seekBackwards, seekForwards, togglePlaying, updateSnap } from "$/store/actions"; +import { useAppDispatch, useAppSelector } from "$/store/hooks"; +import { selectCursorPosition, selectCursorPositionInBeats, selectLoading, selectPlaying, selectSnap } from "$/store/selectors"; +import { roundToNearest } from "$/utils"; + +function EditorNavigationControls() { + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); + + const dispatch = useAppDispatch(); + const isPlaying = useAppSelector(selectPlaying); + const isLoadingSong = useAppSelector(selectLoading); + const snapTo = useAppSelector(selectSnap); + + const timeDisplayText = useAppSelector((state) => { + const cursorPosition = selectCursorPosition(state); + + return formatCursorPosition(cursorPosition); + }); + const beatDisplayText = useAppSelector((state) => { + const cursorPositionInBeats = selectCursorPositionInBeats(state, sid); + + if (cursorPositionInBeats === null) return "--"; + + // When the song is playing, this number will move incredibly quickly. It's a hot blurry mess. + // Instead of trying to debounce rendering, let's just round the value aggressively + const roundedCursorPosition = isPlaying ? roundToNearest(cursorPositionInBeats, 0.5) : cursorPositionInBeats; + + return formatCursorPositionInBeats(roundedCursorPosition); + }); + + return ( + + + dispatch(updateSnap({ value: Number.parseFloat(ev.value[0]) }))}> - Snap To - - - - - - - - - - - - - - - ); -} - -const Wrapper = styled("div", { - base: hstack.raw({ - gap: 4, - justify: "space-between", - overflowX: "auto", - _scrollbar: { display: "none" }, - }), -}); - -const Column = styled("div", { - base: hstack.raw({ - justify: { base: "center", _first: "flex-start", _last: "flex-end" }, - flex: 1, - gap: { base: 1, _first: 4, _last: 4 }, - }), -}); - -export default EditorNavigationControls; diff --git a/src/components/app/templates/editor/navigation-panel/stats.tsx b/src/components/app/templates/editor/navigation-panel/stats.tsx deleted file mode 100644 index c303017e..00000000 --- a/src/components/app/templates/editor/navigation-panel/stats.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useParams } from "@tanstack/react-router"; - -import { Stat } from "$/components/ui/compositions"; -import { formatCursorPosition, formatCursorPositionInBeats } from "$/helpers/audio.helpers"; -import { useAppSelector } from "$/store/hooks"; -import { selectCursorPosition, selectCursorPositionInBeats, selectPlaying } from "$/store/selectors"; -import { roundToNearest } from "$/utils"; - -export function EditorTimeStat() { - const displayString = useAppSelector((state) => { - const cursorPosition = selectCursorPosition(state); - return formatCursorPosition(cursorPosition); - }); - return {displayString}; -} - -export function EditorBeatStat() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); - - const displayString = useAppSelector((state) => { - const isPlaying = selectPlaying(state); - - let displayString = "--"; - const cursorPositionInBeats = selectCursorPositionInBeats(state, sid); - if (cursorPositionInBeats === null) return displayString; - - // When the song is playing, this number will move incredibly quickly. It's a hot blurry mess. - // Instead of trying to debounce rendering, let's just round the value aggressively - const roundedCursorPosition = isPlaying ? roundToNearest(cursorPositionInBeats, 0.5) : cursorPositionInBeats; - - displayString = formatCursorPositionInBeats(roundedCursorPosition); - - return displayString; - }); - return {displayString}; -} - -export default EditorBeatStat; diff --git a/src/components/app/templates/editor/prompts.tsx b/src/components/app/templates/editor/prompts.tsx deleted file mode 100644 index 12b7b895..00000000 --- a/src/components/app/templates/editor/prompts.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { EDITOR_TOASTER } from "$/components/app/constants"; -import { Toaster } from "$/components/ui/compositions"; - -function EditorPrompts() { - return ; -} - -export default EditorPrompts; diff --git a/src/components/app/templates/editor/sidebar.tsx b/src/components/app/templates/editor/sidebar.tsx index 7536e2cd..a9f5b5f3 100644 --- a/src/components/app/templates/editor/sidebar.tsx +++ b/src/components/app/templates/editor/sidebar.tsx @@ -7,7 +7,7 @@ import { Dialog } from "$/components/ui/compositions"; import type { View } from "$/types"; function EditorSidebar() { - const params = useParams({ from: "/_/edit/$sid/$bid" }); + const params = useParams({ from: "/_/edit/$sid/$bid/_" }); const matchRoute = useMatchRoute(); const isView = (to: View) => { @@ -59,7 +59,7 @@ function EditorSidebar() { {(children) => ( }> - {children} + {children} )} diff --git a/src/components/app/templates/editor/song-info.tsx b/src/components/app/templates/editor/song-info.tsx index c6090a23..3f648cc4 100644 --- a/src/components/app/templates/editor/song-info.tsx +++ b/src/components/app/templates/editor/song-info.tsx @@ -4,15 +4,16 @@ import type { CharacteristicName, DifficultyName } from "bsmap/types"; import { PlusIcon } from "lucide-react"; import { memo, useCallback, useMemo } from "react"; -import { CoverArtFilePreview } from "$/components/app/compositions"; +import { CoverArtFile } from "$/components/app/compositions"; import { createBeatmapListCollection } from "$/components/app/constants"; import { CreateBeatmapForm } from "$/components/app/forms"; -import { Button, Dialog, Select, Text } from "$/components/ui/compositions"; +import { Button, Dialog, Select } from "$/components/ui/compositions"; +import { BeatmapFilestore } from "$/services/file.service"; import { addBeatmap, updateSelectedBeatmap } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectBeatmapIds, selectSelectedBeatmap, selectSongMetadata, selectUsername } from "$/store/selectors"; import type { BeatmapId } from "$/types"; -import { HStack, Stack, styled } from "$:styled-system/jsx"; +import { HStack, Stack, styled, Text } from "$:styled-system/jsx"; const COVER_ART_SIZES = { medium: 75, @@ -23,7 +24,7 @@ interface Props { showDifficultySelector: boolean; } function EditorSongInfo({ showDifficultySelector }: Props) { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); @@ -52,25 +53,25 @@ function EditorSongInfo({ showDifficultySelector }: Props) { return ( - + - + {metadata.title} - + {metadata.artist} {showDifficultySelector && ( - ( - {() => "Create beatmap"} + Create beatmap )} > diff --git a/src/components/app/templates/editor/status-bar.tsx b/src/components/app/templates/editor/status-bar.tsx index 4525643d..3619e14e 100644 --- a/src/components/app/templates/editor/status-bar.tsx +++ b/src/components/app/templates/editor/status-bar.tsx @@ -1,8 +1,8 @@ -import { Presence } from "@ark-ui/react/presence"; import { useRouteContext } from "@tanstack/react-router"; import { BellIcon, BellOffIcon, BoxIcon, CuboidIcon, EyeClosedIcon, EyeIcon, FastForwardIcon, GaugeIcon, GlobeIcon, Maximize2Icon, Minimize2Icon, RewindIcon, Volume2Icon, VolumeXIcon, ZapIcon, ZapOffIcon } from "lucide-react"; import { StatusBar } from "$/components/app/layouts"; +import { Show } from "$/components/ui/atoms"; import { updateBeatDepth, updateEventsEditorPreview, updateEventsEditorTrackHeight, updateEventsEditorTrackOpacity, updatePlaybackRate, updateSongVolume, updateTickVolume } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectBeatDepth, selectEventsEditorPreview, selectEventsEditorTrackHeight, selectEventsEditorTrackOpacity, selectedTotalBombNotes, selectLoading, selectNoteDensity, selectPlaybackRate, selectSongVolume, selectTickVolume, selectTotalColorNotes, selectTotalObstacles } from "$/store/selectors"; @@ -29,8 +29,8 @@ function EditorStatusBar() { return ( ev.stopPropagation()}> - - + + {numOfBlocks} @@ -47,20 +47,16 @@ function EditorStatusBar() { dispatch(updateBeatDepth({ value: details.value[0] }))} /> dispatch(updateTickVolume({ value: details.value[0] }))} /> - - - - + + dispatch(updateEventsEditorPreview())} /> dispatch(updateEventsEditorTrackHeight({ newHeight: details.value[0] }))} /> dispatch(updateEventsEditorTrackOpacity({ newOpacity: details.value[0] }))} /> - - - - + + dispatch(updateTickVolume({ value: details.value[0] }))} /> - - + + dispatch(updatePlaybackRate({ value: details.value[0] }))} /> dispatch(updateSongVolume({ value: details.value[0] }))} /> diff --git a/src/components/app/templates/editor/visualizer/index.tsx b/src/components/app/templates/editor/visualizer.tsx similarity index 91% rename from src/components/app/templates/editor/visualizer/index.tsx rename to src/components/app/templates/editor/visualizer.tsx index 7edd5bf9..8bcb07da 100644 --- a/src/components/app/templates/editor/visualizer/index.tsx +++ b/src/components/app/templates/editor/visualizer.tsx @@ -9,10 +9,9 @@ import { jumpToBeat, removeBookmark, scrubVisualizer } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectAllBookmarks, selectCursorPosition, selectDuration, selectDurationInBeats, selectEditorOffsetInBeats, selectLoading, selectRenderScale, selectWaveformData } from "$/store/selectors"; import { roundToNearest } from "$/utils"; -import EditorBookmark from "./bookmark"; function EditorAudioVisualizer() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const waveformData = useAppSelector(selectWaveformData); @@ -23,7 +22,8 @@ function EditorAudioVisualizer() { const bookmarks = useAppSelector(selectAllBookmarks); const durationInBeats = useAppSelector((state) => selectDurationInBeats(state, sid)); const offsetInBeats = useAppSelector((state) => selectEditorOffsetInBeats(state, sid)); - const [dimensions, container] = useParentDimensions(); + + const [container, dimensions] = useParentDimensions(); // Updating this waveform is surprisingly expensive! We'll defer its rendered value and round the cursor position based on the render scale. const roundedCursorPosition = useDeferredValue(roundToNearest(cursorPosition, Math.min(1 / renderScale, 15) * 15)); @@ -57,7 +57,7 @@ function EditorAudioVisualizer() { {!isLoadingSong && durationInBeats && ( - {(bookmark, rest) => } + {(bookmark, rest) => } )} diff --git a/src/components/app/layouts/error-boundary.tsx b/src/components/app/templates/error-boundary.tsx similarity index 57% rename from src/components/app/layouts/error-boundary.tsx rename to src/components/app/templates/error-boundary.tsx index 396143ed..7d2807a0 100644 --- a/src/components/app/layouts/error-boundary.tsx +++ b/src/components/app/templates/error-boundary.tsx @@ -1,10 +1,9 @@ -import { Presence } from "@ark-ui/react/presence"; import { type ErrorComponentProps, useRouter } from "@tanstack/react-router"; -import { Button, Heading, Text } from "$/components/ui/compositions"; -import { Clipboard } from "$/components/ui/compositions/clipboard"; -import { AnchorLink } from "$/components/ui/styled"; -import { Container, Stack, styled, Wrap } from "$:styled-system/jsx"; +import { Show } from "$/components/ui/atoms"; +import { AnchorLink, Button, Clipboard, Heading } from "$/components/ui/compositions"; +import { css } from "$:styled-system/css"; +import { Container, Float, Stack, styled, Text, Wrap } from "$:styled-system/jsx"; interface Props extends ErrorComponentProps { interactive?: boolean; @@ -18,20 +17,29 @@ function ErrorBoundary({ error, interactive = true, reset }: Props) { {error.name} - {error.message} + {error.message} Stack Trace {error.stack && ( - - {error.stack} - + + {error.stack} + + + {(Indicator) => ( + + )} + + + )} - + - If this error was a false positive, you can click the following buttons to revalidate the route and retry any loader operations. + If this error was a false positive, you can click the following buttons to revalidate the route and retry any loader operations. - + If you're still encountering issues, please fill out a bug report on the repository. - + @@ -60,6 +68,8 @@ const Wrapper = styled("div", { const StackWrapper = styled("pre", { base: { + position: "relative", + display: "flex", padding: 2, colorPalette: "red", layerStyle: "fill.surface", diff --git a/src/components/app/templates/events/track.tsx b/src/components/app/templates/events/basic-track.tsx similarity index 74% rename from src/components/app/templates/events/track.tsx rename to src/components/app/templates/events/basic-track.tsx index a43729e4..d38e7c21 100644 --- a/src/components/app/templates/events/track.tsx +++ b/src/components/app/templates/events/basic-track.tsx @@ -1,9 +1,12 @@ +import type { Assign } from "@ark-ui/react"; import { useParams } from "@tanstack/react-router"; import { createBasicEvent, type EventType } from "bsmap"; -import { type ComponentProps, memo, type PointerEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { type ComponentProps, type PointerEvent, useCallback, useEffect, useMemo, useState } from "react"; -import { useGlobalEventListener } from "$/components/hooks"; -import { resolveEventId, resolveEventValue, resolveTrackType } from "$/helpers/events.helpers"; +import { EventGrid } from "$/components/app/layouts"; +import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; +import { For } from "$/components/ui/atoms"; +import { isLightEvent, isValueEvent, resolveEventId, resolveEventValue, resolveTrackType } from "$/helpers/events.helpers"; import { bulkAddBasicEvent } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { @@ -21,23 +24,19 @@ import { } from "$/store/selectors"; import { type Accept, App, EventEditMode, TrackType } from "$/types"; import { clamp, normalize } from "$/utils"; -import { styled } from "$:styled-system/jsx"; -import EventGridBackgroundBox from "./background-box"; -import EventGridEventItem from "./event"; import { createBackgroundBoxes } from "./track.helpers"; -interface Props extends ComponentProps { +interface Props { trackId: Accept; width: number; - height: number; disabled: boolean; onEventPointerDown?: (event: PointerEvent, data: App.IBasicEvent) => void; onEventPointerOut?: (event: PointerEvent, data: App.IBasicEvent) => void; onEventPointerOver?: (event: PointerEvent, data: App.IBasicEvent) => void; onEventWheel?: (event: WheelEvent, data: App.IBasicEvent) => void; } -function EventGridTrack({ trackId, width, height, disabled, onEventPointerDown, onEventPointerOver, onEventPointerOut, onEventWheel, ...rest }: Props) { - const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid" }); +function BasicEventTrack({ trackId, width, disabled, onEventPointerDown, onEventPointerOver, onEventPointerOut, onEventWheel, ...rest }: Assign, Props>) { + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const duration = useAppSelector((state) => selectDurationInBeats(state, sid)); @@ -60,8 +59,6 @@ function EventGridTrack({ trackId, width, height, disabled, onEventPointerDown, return createBackgroundBoxes(events, trackId, { initialColor: color ?? null, initialBrightness: brightness ?? null, startBeat, numOfBeatsToShow, tracks }); }, [events, trackId, initialTrackLightingState, startBeat, numOfBeatsToShow, tracks]); - const styles = useMemo(() => ({ height }), [height]); - const handlePointerUp = useCallback(() => { setMouseButtonDepressed(null); setNorm(null); @@ -129,26 +126,18 @@ function EventGridTrack({ trackId, width, height, disabled, onEventPointerDown, }, [dispatch, resolveEventData, cursorAtBeat, norm, duration, offsetInBeats, mouseButtonDepressed, selectedEditMode]); return ( - ev.preventDefault()}> - {backgroundBoxes.map((box) => ( - - ))} - {events.map((event) => { - return ; - })} - + ev.preventDefault()}> + {(box) => } + + {(event) => ( + + {isLightEvent(event, tracks) && event.value !== 0 ? event.floatValue : undefined} + {isValueEvent(event, tracks) && event.value} + + )} + + ); } -const Wrapper = styled("div", { - base: { - position: "relative", - backgroundColor: { base: undefined, _disabled: "bg.disabled" }, - borderBlockWidth: { base: "sm", _lastOfType: 0 }, - borderColor: "border.muted", - opacity: { base: 1, _disabled: "disabled" }, - cursor: { base: undefined, _disabled: "not-allowed" }, - }, -}); - -export default memo(EventGridTrack); +export default BasicEventTrack; diff --git a/src/components/app/templates/events/controls.tsx b/src/components/app/templates/events/controls.tsx index 398e5036..247e5fea 100644 --- a/src/components/app/templates/events/controls.tsx +++ b/src/components/app/templates/events/controls.tsx @@ -40,7 +40,7 @@ function createEventEffectListCollection({ selectedColor, colorScheme }: EventLi } function EventGridControls({ ...rest }: ComponentProps) { - const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const colorScheme = useAppSelector((state) => selectColorScheme(state, sid, bid)); @@ -58,23 +58,23 @@ function EventGridControls({ ...rest }: ComponentProps) { - details.value.length > 0 && dispatch(updateEventsEditorEditMode({ editMode: details.value[0] as EventEditMode }))} /> + details.value.length > 0 && dispatch(updateEventsEditorEditMode({ editMode: details.value[0] as EventEditMode }))} /> - details.value.length > 0 && dispatch(updateEventsEditorColor({ color: details.value[0] as EventColor }))} /> + details.value.length > 0 && dispatch(updateEventsEditorColor({ color: details.value[0] as EventColor }))} /> - details.value.length > 0 && dispatch(updateEventsEditorTool({ tool: details.value[0] as EventTool }))} /> + details.value.length > 0 && dispatch(updateEventsEditorTool({ tool: details.value[0] as EventTool }))} /> "Loop playback within the current event window (L)"}> - dispatch(updateEventsEditorWindowLock())}> + dispatch(updateEventsEditorWindowLock())}> "Pair side lasers for symmetrical left/right events"}> - dispatch(updateEventsEditorMirrorLock())}> + dispatch(updateEventsEditorMirrorLock())}> @@ -84,10 +84,10 @@ function EventGridControls({ ...rest }: ComponentProps) { - - diff --git a/src/components/app/templates/events/grid.tsx b/src/components/app/templates/events/grid.tsx index b4779803..b853db7f 100644 --- a/src/components/app/templates/events/grid.tsx +++ b/src/components/app/templates/events/grid.tsx @@ -2,23 +2,17 @@ import { useParams } from "@tanstack/react-router"; import type { EventType } from "bsmap"; import { type ComponentProps, type PointerEvent, type PointerEventHandler, useCallback, useMemo, useRef, useState } from "react"; -import { useGlobalEventListener, useMousePositionOverElement, useParentDimensions } from "$/components/hooks"; -import { isSideTrack, resolveEventType } from "$/helpers/events.helpers"; +import { EventGrid } from "$/components/app/layouts"; +import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; +import { useMousePositionOverElement } from "$/components/hooks/use-mouse-position-over-element"; +import { useParentDimensions } from "$/components/hooks/use-parent-dimensions"; +import { resolveEventType } from "$/helpers/events.helpers"; import { bulkRemoveEvent, deselectEvent, drawEventSelectionBox, mirrorBasicEvent, removeEvent, selectEvent, updateBasicEvent, updateEventsEditorCursor } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectDurationInBeats, selectEditorOffsetInBeats, selectEventEditorStartAndEndBeat, selectEventsEditorCursor, selectEventsEditorEditMode, selectEventsEditorMirrorLock, selectEventsEditorTrackHeight, selectEventTracksForEnvironment, selectLoading, selectSnap } from "$/store/selectors"; +import { selectDurationInBeats, selectEditorOffsetInBeats, selectEventEditorStartAndEndBeat, selectEventsEditorCursor, selectEventsEditorEditMode, selectEventsEditorMirrorLock, selectEventsEditorTrackHeight, selectEventTracksForEnvironment, selectLoading, selectPacerWait, selectSnap } from "$/store/selectors"; import { type Accept, type App, EventEditMode, type ISelectionBoxInBeats, TrackType } from "$/types"; import { clamp, isMetaKeyPressed, normalize, range, roundToNearest } from "$/utils"; -import { styled } from "$:styled-system/jsx"; -import { center, hstack, stack } from "$:styled-system/patterns"; -import EventGridCursor from "./cursor"; -import EventGridMarkers from "./markers"; -import EventGridSelectionBox from "./selection-box"; -import EventGridTimeline from "./timeline"; -import EventGridTrack from "./track"; - -const PREFIX_WIDTH = 170; -const HEADER_HEIGHT = 32; +import BasicEventTrack from "./basic-track"; function convertMousePositionToBeatNum(x: number, innerGridWidth: number, beatNums: number[], startBeat: number, snapTo?: number) { const positionInBeats = normalize(x, 0, innerGridWidth, 0, beatNums.length); @@ -31,12 +25,12 @@ function convertMousePositionToBeatNum(x: number, innerGridWidth: number, beatNu return roundedPositionInBeats + startBeat; } -function EventGridEditor({ ...rest }: ComponentProps) { - const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid" }); +function EventGridEditor({ ...rest }: ComponentProps) { + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); + const wait = useAppSelector(selectPacerWait); const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); - const allTracks = useMemo(() => Object.entries(tracks), [tracks]); const duration = useAppSelector((state) => selectDurationInBeats(state, sid)); const { startBeat, endBeat } = useAppSelector((state) => selectEventEditorStartAndEndBeat(state, sid)); const selectedEditMode = useAppSelector(selectEventsEditorEditMode); @@ -52,7 +46,8 @@ function EventGridEditor({ ...rest }: ComponentProps) { const beatNums = useMemo(() => Array.from(range(Math.floor(startBeat), Math.ceil(endBeat - 1))), [startBeat, endBeat]); - const [dimensions, container] = useParentDimensions(); + const [container, dimensions] = useParentDimensions(); + const [mouseDownAt, setMouseDownAt] = useState<{ x: number; y: number } | null>(null); const [hoveredTrack, setHoveredTrack] = useState(null); @@ -75,78 +70,59 @@ function EventGridEditor({ ...rest }: ComponentProps) { shouldFire: selectedEditMode === EventEditMode.SELECT, }); - const tracksScrollContainer = useRef(null); - - const tracksSelectionBoxRef = useMousePositionOverElement( - tracksScrollContainer, - (ref, x, y, event) => { - const currentMousePosition = { x, y }; - mousePositionRef.current = currentMousePosition; - - const offset = { - x: -PREFIX_WIDTH, // prefix width - y: ref.scrollTop - HEADER_HEIGHT, - }; - - const hoveringOverBeatNum = convertMousePositionToBeatNum(x + offset.x, dimensions.width, beatNums, startBeat, snapTo); - - if (selectedEditMode === EventEditMode.SELECT && mouseDownAt && mouseButtonDepressed.current === 0) { - const newSelectionBox = { - top: Math.min(mouseDownAt.y, currentMousePosition.y) + offset.y, - left: Math.min(mouseDownAt.x, currentMousePosition.x) + offset.x, - right: Math.max(mouseDownAt.x, currentMousePosition.x) + offset.x, - bottom: Math.max(mouseDownAt.y, currentMousePosition.y) + offset.y, - } as DOMRect; - - setSelectionBox(newSelectionBox); + const [tracksSelectionBoxRef] = useMousePositionOverElement( + { + debouncerOptions: { wait }, + onMouseMove: (event, { x, y }) => { + mousePositionRef.current = { x, y }; + + if (selectedEditMode === EventEditMode.SELECT && mouseDownAt && mouseButtonDepressed.current === 0) { + const newSelectionBox = { + left: Math.min(mouseDownAt.x, x), + right: Math.max(mouseDownAt.x, x), + top: Math.min(mouseDownAt.y, y), + bottom: Math.max(mouseDownAt.y, y), + } as DOMRect; + + setSelectionBox(newSelectionBox); + + // Selection boxes need to include their cartesian values, in pixels, but we should also encode the values in business terms: start/end beat, and start/end track + setSelectionBoxInBeats({ + startTrackIndex: Math.floor(newSelectionBox.top / rowHeight), + endTrackIndex: Math.floor(newSelectionBox.bottom / rowHeight), + startBeat: convertMousePositionToBeatNum(newSelectionBox.left, dimensions.width, beatNums, startBeat), + endBeat: convertMousePositionToBeatNum(newSelectionBox.right, dimensions.width, beatNums, startBeat), + // we should also track whether we want the selection box to preserve the existing selection + withPrevious: isMetaKeyPressed(event), + }); + } - // Selection boxes need to include their cartesian values, in pixels, but we should also encode the values in business terms: start/end beat, and start/end track - setSelectionBoxInBeats({ - startTrackIndex: Math.floor(newSelectionBox.top / rowHeight), - endTrackIndex: Math.floor(newSelectionBox.bottom / rowHeight), - startBeat: convertMousePositionToBeatNum(newSelectionBox.left, dimensions.width, beatNums, startBeat), - endBeat: convertMousePositionToBeatNum(newSelectionBox.right, dimensions.width, beatNums, startBeat), - // we should also track whether we want the selection box to preserve the existing selection - withPrevious: isMetaKeyPressed(event), - }); - } + const hoveringOverBeatNum = convertMousePositionToBeatNum(x, dimensions.width, beatNums, startBeat, snapTo); - if (hoveringOverBeatNum !== selectedBeat) dispatch(updateEventsEditorCursor({ selectedBeat: hoveringOverBeatNum })); - }, - { - boxDependencies: [rowHeight], + if (hoveringOverBeatNum !== selectedBeat) { + dispatch(updateEventsEditorCursor({ selectedBeat: hoveringOverBeatNum })); + } + }, }, + [rowHeight], ); - const mousePositionInPx = useMemo(() => { - return selectedBeat !== null && selectedBeat - startBeat >= 0 ? normalize(selectedBeat - startBeat, 0, beatNums.length, 0, dimensions.width) : 0; - }, [selectedBeat, startBeat, beatNums, dimensions.width]); - - const handlePointerDown = useCallback((ev) => { + const handleTriggerPointerDown = useCallback((ev) => { mouseButtonDepressed.current = ev.button; setMouseDownAt(mousePositionRef.current); }, []); - - const handlePointerUp = useCallback((_) => { + const handleTriggerPointerUp = useCallback((_) => { mouseButtonDepressed.current = null; setMouseDownAt(null); }, []); - const handlePointerOver = useCallback((_: PointerEvent, trackId: Accept) => { + const handleTrackPointerOver = useCallback((_event: PointerEvent, trackId: Accept) => { setHoveredTrack(trackId); }, []); - const handlePointerOut = useCallback((_: PointerEvent) => { + const handleTrackPointerOut = useCallback((_event: PointerEvent, _trackId: Accept) => { setHoveredTrack(null); }, []); - const isTrackDisabled = useCallback( - (trackId: Accept) => { - if (!areLasersLocked) return false; - return isSideTrack(trackId, "right", tracks); - }, - [tracks, areLasersLocked], - ); - const handleEventPointerDown = useCallback( (event: PointerEvent, data: App.IBasicEvent) => { // When in "select" mode, clicking the grid creates a selection box. We don't want to do that when the user clicks directly on a block. @@ -214,164 +190,48 @@ function EventGridEditor({ ...rest }: ComponentProps) { ); return ( - - ev.preventDefault()}> - - - - - - - ev.stopPropagation()}> - {allTracks.map(([id, { label }]) => ( - ev.preventDefault()}> - {label} - - ))} - - - - - - - {allTracks.map(([id]) => { - const isDisabled = isTrackDisabled(Number.parseInt(id, 10)); - return ( - + ev.preventDefault()}> + + + + + ev.stopPropagation()}> + + {(track, id, { disabled, style }) => ( + ev.preventDefault()}> + {track.label} + + )} + + + + + + + {(_, id, { disabled, style }) => ( + handlePointerOver(ev, Number.parseInt(id, 10))} - onPointerOut={handlePointerOut} + style={style} + disabled={disabled} + onPointerOver={(ev) => handleTrackPointerOver(ev, id)} + onPointerOut={(ev) => handleTrackPointerOut(ev, id)} onEventPointerDown={handleEventPointerDown} onEventPointerOver={handleEventPointerOver} onEventWheel={handleEventWheel} /> - ); - })} - - {selectionBox && } - - {typeof mousePositionInPx === "number" && } - - - + )} + + + + + + + + ); } -const Wrapper = styled("div", { - base: stack.raw({ - gap: 0, - opacity: { base: 1, _loading: 0.25 }, - pointerEvents: { base: "auto", _loading: "none" }, - userSelect: "none", - overflowX: "clip", - overflowY: "auto", - _scrollbar: { display: "none" }, - }), -}); - -const HeaderWrapper = styled("div", { - base: hstack.raw({ - position: "sticky", - height: "32px", - top: 0, - gap: 0, - backdropFilter: "blur(4px)", - zIndex: 2, - }), -}); - -const MainWrapper = styled("div", { - base: hstack.raw({ - gap: 0, - backdropFilter: "blur(4px)", - }), -}); - -const ActionsWrapper = styled("div", { - base: center.raw({ - minWidth: "170px", - height: "100%", - borderBottomWidth: "sm", - borderColor: "border.muted", - }), -}); - -const TimelineWrapper = styled("div", { - base: { - position: "relative", - height: "100%", - flex: 1, - }, -}); - -const PrefixWrapper = styled("div", { - base: stack.raw({ - gap: 0, - }), -}); - -const Prefix = styled("div", { - base: hstack.raw({ - width: "170px", - justify: "flex-end", - textAlign: "end", - paddingInline: 1, - position: "relative", - backgroundColor: { base: undefined, _disabled: "bg.disabled" }, - borderBlockWidth: { base: "sm", _lastOfType: 0 }, - borderRightWidth: "md", - borderColor: "border.muted", - opacity: { base: 1, _disabled: "disabled" }, - cursor: { base: undefined, _disabled: "not-allowed" }, - overflowX: "auto", - whiteSpace: "nowrap", - textOverflow: "ellipsis", - _scrollbar: { display: "none" }, - }), -}); - -const TracksWrapper = styled("div", { - base: { - position: "relative", - flex: 1, - }, - variants: { - editMode: { - place: { cursor: "pointer" }, - select: { cursor: "crosshair" }, - }, - }, -}); - -const TrackMarkersWrapper = styled("div", { - base: { - position: "absolute", - inset: 0, - }, -}); - -const TrackContentsWrapper = styled("div", { - base: { - position: "relative", - }, -}); - -const MouseCursor = styled("div", { - base: { - position: "absolute", - top: 0, - width: "3px", - height: "100%", - background: "fg.default", - borderWidth: "sm", - borderColor: "border.default", - pointerEvents: "none", - transform: "translateX(-1px)", - }, -}); - export default EventGridEditor; diff --git a/src/components/app/templates/events/markers.tsx b/src/components/app/templates/events/markers.tsx deleted file mode 100644 index ab70e62d..00000000 --- a/src/components/app/templates/events/markers.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useMemo } from "react"; - -import { useAppSelector } from "$/store/hooks"; -import { selectEventsEditorBeatsPerZoomLevel } from "$/store/selectors"; -import { range } from "$/utils"; -import { token } from "$:styled-system/tokens"; - -interface Props { - width: number; - height: number; - primaryDivisions: number; -} -function EventGridMarkers({ width, height, primaryDivisions }: Props) { - const numOfBeatsToShow = useAppSelector(selectEventsEditorBeatsPerZoomLevel); - - const segmentWidth = useMemo(() => width / numOfBeatsToShow, [width, numOfBeatsToShow]); - - const beatLines = useMemo(() => { - return Array.from(range(numOfBeatsToShow)).map((i) => { - // No line necessary for the right edge of the grid - if (i === numOfBeatsToShow - 1) return null; - return ; - }); - }, [numOfBeatsToShow, height, segmentWidth]); - - const primaryLines = useMemo(() => { - return beatLines.map((_, segmentIndex) => { - return Array.from(range(primaryDivisions)).map((i) => { - if (i === 0) return null; - const subSegmentWidth = segmentWidth / primaryDivisions; - return ; - }); - }); - }, [beatLines, primaryDivisions, height, segmentWidth]); - - return ( - - {beatLines} - {primaryLines} - - ); -} - -export default EventGridMarkers; diff --git a/src/components/app/templates/home/first-time.tsx b/src/components/app/templates/home/first-time.tsx index 88133c41..2ac8a170 100644 --- a/src/components/app/templates/home/first-time.tsx +++ b/src/components/app/templates/home/first-time.tsx @@ -4,13 +4,15 @@ import { useCallback, useState } from "react"; import { heroVideo } from "$/assets"; import { CreateMapForm, ImportMapForm } from "$/components/app/forms"; import { Button, Dialog, Heading } from "$/components/ui/compositions"; -import { loadDemoMap } from "$/store/actions"; -import { useAppDispatch } from "$/store/hooks"; +import { addSongFromFile, loadDemoMap } from "$/store/actions"; +import { useAppDispatch, useAppSelector } from "$/store/hooks"; +import { selectSongIds } from "$/store/selectors"; import { styled, VStack, Wrap } from "$:styled-system/jsx"; import OptionColumn from "./option"; function FirstTimeHome() { const dispatch = useAppDispatch(); + const songIds = useAppSelector(selectSongIds); const [isLoadingDemo, setIsLoadingDemo] = useState(false); @@ -41,7 +43,7 @@ function FirstTimeHome() { - }> + dispatch(addSongFromFile({ file, options: { currentSongIds: songIds } }))} />}> diff --git a/src/components/app/templates/home/option.tsx b/src/components/app/templates/home/option.tsx index 804a0f7d..848c8da1 100644 --- a/src/components/app/templates/home/option.tsx +++ b/src/components/app/templates/home/option.tsx @@ -1,8 +1,8 @@ import type { LucideProps } from "lucide-react"; import type { ComponentType, PropsWithChildren } from "react"; -import { Heading, Text } from "$/components/ui/compositions"; -import { styled, VStack } from "$:styled-system/jsx"; +import { Heading } from "$/components/ui/compositions"; +import { styled, Text, VStack } from "$:styled-system/jsx"; import { vstack } from "$:styled-system/patterns"; interface Props extends PropsWithChildren { @@ -16,7 +16,7 @@ function OptionColumn({ title, description, icon: Icon, children }: Props) { {title} - {description} + {description} {children} diff --git a/src/components/app/templates/home/returning.tsx b/src/components/app/templates/home/returning.tsx index c1fc8ac9..bdffc7a7 100644 --- a/src/components/app/templates/home/returning.tsx +++ b/src/components/app/templates/home/returning.tsx @@ -1,10 +1,16 @@ import { CreateMapForm, ImportMapForm } from "$/components/app/forms"; import { SongsDataTable } from "$/components/app/templates/tables"; import { Button, Dialog, Heading } from "$/components/ui/compositions"; +import { addSongFromFile } from "$/store/actions"; +import { useAppDispatch, useAppSelector } from "$/store/hooks"; +import { selectSongIds } from "$/store/selectors"; import { styled } from "$:styled-system/jsx"; import { stack, vstack } from "$:styled-system/patterns"; function ReturningHome() { + const dispatch = useAppDispatch(); + const songIds = useAppSelector(selectSongIds); + return ( Select map to edit @@ -18,7 +24,7 @@ function ReturningHome() { Create new map - }> + dispatch(addSongFromFile({ file, options: { currentSongIds: songIds } }))} />}> diff --git a/src/components/app/layouts/pending.tsx b/src/components/app/templates/pending-boundary.tsx similarity index 100% rename from src/components/app/layouts/pending.tsx rename to src/components/app/templates/pending-boundary.tsx diff --git a/src/components/app/templates/shortcuts/default.tsx b/src/components/app/templates/shortcuts/default.tsx index 8d942bed..04e46eab 100644 --- a/src/components/app/templates/shortcuts/default.tsx +++ b/src/components/app/templates/shortcuts/default.tsx @@ -2,11 +2,12 @@ import { useThrottledCallback } from "@tanstack/react-pacer/throttler"; import { useParams, useRouteContext } from "@tanstack/react-router"; import { useCallback, useRef } from "react"; -import { useAppPrompterContext } from "$/components/app/compositions"; -import { APP_TOASTER } from "$/components/app/constants"; -import { useGlobalEventListener } from "$/components/hooks"; +import { APP_TOASTER, createAddBookmarkPrompt, createJumpToBeatPrompt, createQuickSelectPrompt } from "$/components/app/constants"; +import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; +import { usePrompt, usePrompter } from "$/components/ui/compositions"; import { SNAPPING_INCREMENTS } from "$/constants"; import { + addBookmark, copySelection, cutSelection, cycleToNextTool, @@ -17,6 +18,7 @@ import { downloadMapFiles, incrementPlaybackRate, incrementSnap, + jumpToBeat, jumpToEnd, jumpToStart, nudgeSelection, @@ -30,6 +32,7 @@ import { scrollThroughSong, seekBackwards, seekForwards, + selectAllEntitiesInRange, togglePlaying, undoEvents, undoObjects, @@ -41,7 +44,7 @@ import { View } from "$/types"; import { isMetaKeyPressed } from "$/utils"; function DefaultEditorShortcuts() { - const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); @@ -49,7 +52,26 @@ function DefaultEditorShortcuts() { const isDemo = useAppSelector((state) => selectDemo(state, sid)); const wait = useAppSelector(selectPacerWait); - const { active: activePrompt, openPrompt } = useAppPrompterContext(); + const { trigger: triggerQuickSelect } = usePrompt( + createQuickSelectPrompt({ + render: ({ form }) => {(ctx) => }, + onSubmit: ({ value: { start, end } }) => dispatch(selectAllEntitiesInRange({ songId: sid, view: view, start, end })), + }), + ); + const { trigger: triggerJumpToBeat } = usePrompt( + createJumpToBeatPrompt({ + render: ({ form }) => {(ctx) => }, + onSubmit: ({ value: { beatNum } }) => dispatch(jumpToBeat({ songId: sid, pauseTrack: true, beatNum: beatNum })), + }), + ); + const { trigger: triggerAddBookmark } = usePrompt( + createAddBookmarkPrompt({ + render: ({ form }) => {(ctx) => }, + onSubmit: ({ value }) => dispatch(addBookmark({ songId: sid, view, name: value.name })), + }), + ); + + const { isPromptActive } = usePrompter(); const keysDepressed = useRef({ space: false, @@ -82,7 +104,7 @@ function DefaultEditorShortcuts() { (ev: KeyboardEvent) => { if (isLoading) return; if (!view) return; - if (activePrompt) return; + if (isPromptActive) return; const metaKeyPressed = isMetaKeyPressed(ev, navigator); // If the control key and a number is pressed, we want to update snapping. @@ -170,12 +192,12 @@ function DefaultEditorShortcuts() { } case "KeyJ": { ev.preventDefault(); - return openPrompt("JUMP_TO_BEAT"); + return triggerJumpToBeat(); } case "KeyB": { if (!metaKeyPressed) return; ev.preventDefault(); - return openPrompt("ADD_BOOKMARK"); + return triggerAddBookmark(); } case "KeyZ": { if (!metaKeyPressed) return; @@ -207,21 +229,21 @@ function DefaultEditorShortcuts() { } case "KeyQ": { ev.preventDefault(); - return openPrompt("QUICK_SELECT"); + return triggerQuickSelect(); } default: { return; } } }, - [isLoading, view, activePrompt, dispatch, sid, bid, isDemo, handleScroll, openPrompt], + [isLoading, view, dispatch, sid, bid, isDemo, handleScroll, isPromptActive, triggerQuickSelect, triggerJumpToBeat, triggerAddBookmark], ); const handleKeyUp = useCallback( (ev: KeyboardEvent) => { if (isLoading) return; if (!view) return; - if (activePrompt) return; + if (isPromptActive) return; switch (ev.code) { case "Space": { @@ -232,7 +254,7 @@ function DefaultEditorShortcuts() { return; } }, - [isLoading, view, activePrompt], + [isLoading, view, isPromptActive], ); const handleWheel = useCallback( @@ -240,13 +262,13 @@ function DefaultEditorShortcuts() { ev.preventDefault(); if (isLoading) return; if (!view) return; - if (activePrompt) return; + if (isPromptActive) return; if (ev.altKey) return; const direction = ev.deltaY > 0 ? "backwards" : "forwards"; handleScroll(direction, ev); }, - [isLoading, view, activePrompt, handleScroll], + [isLoading, view, isPromptActive, handleScroll], ); useGlobalEventListener("keydown", handleKeyDown); diff --git a/src/components/app/templates/shortcuts/events.tsx b/src/components/app/templates/shortcuts/events.tsx index 3f08dfe4..c7a787e6 100644 --- a/src/components/app/templates/shortcuts/events.tsx +++ b/src/components/app/templates/shortcuts/events.tsx @@ -1,8 +1,8 @@ import { useParams, useRouteContext } from "@tanstack/react-router"; import { useCallback } from "react"; -import { useAppPrompterContext } from "$/components/app/compositions"; -import { useGlobalEventListener } from "$/components/hooks"; +import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; +import { usePrompter } from "$/components/ui/compositions"; import { decrementEventsEditorZoom, incrementEventsEditorZoom, toggleSelectAllEntities, updateEventsEditorColor, updateEventsEditorEditMode, updateEventsEditorMirrorLock, updateEventsEditorTool, updateEventsEditorWindowLock } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectLoading } from "$/store/selectors"; @@ -10,18 +10,18 @@ import { EventColor, EventEditMode, EventTool } from "$/types"; import { isMetaKeyPressed } from "$/utils"; function EventsEditorShortcuts() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const isLoading = useAppSelector(selectLoading); - const { active: activePrompt } = useAppPrompterContext(); + const { isPromptActive } = usePrompter(); const handleKeyDown = useCallback( (ev: KeyboardEvent) => { if (isLoading) return; - if (activePrompt) return; + if (isPromptActive) return; const metaKeyPressed = isMetaKeyPressed(ev, navigator); switch (ev.code) { @@ -83,7 +83,7 @@ function EventsEditorShortcuts() { } } }, - [isLoading, activePrompt, dispatch, sid, view], + [isLoading, isPromptActive, dispatch, sid, view], ); useGlobalEventListener("keydown", handleKeyDown); diff --git a/src/components/app/templates/shortcuts/notes.tsx b/src/components/app/templates/shortcuts/notes.tsx index 2eaa1d9f..cadd5033 100644 --- a/src/components/app/templates/shortcuts/notes.tsx +++ b/src/components/app/templates/shortcuts/notes.tsx @@ -2,8 +2,8 @@ import { useParams, useRouteContext } from "@tanstack/react-router"; import { NoteDirection } from "bsmap"; import { useCallback, useRef } from "react"; -import { useAppPrompterContext } from "$/components/app/compositions"; -import { useGlobalEventListener } from "$/components/hooks"; +import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; +import { usePrompter } from "$/components/ui/compositions"; import { mirrorSelection, toggleSelectAllEntities, updateNotesEditorDirection, updateNotesEditorTool } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectGridSize, selectLoading } from "$/store/selectors"; @@ -11,14 +11,14 @@ import { ObjectTool } from "$/types"; import { isMetaKeyPressed } from "$/utils"; function NotesEditorShortcuts() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid" }); + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const isLoading = useAppSelector(selectLoading); const grid = useAppSelector((state) => selectGridSize(state, sid)); - const { active: activePrompt } = useAppPrompterContext(); + const { isPromptActive } = usePrompter(); const keysDepressed = useRef({ w: false, @@ -30,7 +30,7 @@ function NotesEditorShortcuts() { const handleKeyDown = useCallback( (ev: KeyboardEvent) => { if (isLoading) return; - if (activePrompt) return; + if (isPromptActive) return; const metaKeyPressed = isMetaKeyPressed(ev, navigator); switch (ev.code) { @@ -153,13 +153,13 @@ function NotesEditorShortcuts() { } } }, - [isLoading, activePrompt, dispatch, sid, view, grid], + [isLoading, isPromptActive, dispatch, sid, view, grid], ); const handleKeyUp = useCallback( (ev: KeyboardEvent) => { if (isLoading) return; - if (activePrompt) return; + if (isPromptActive) return; const metaKeyPressed = isMetaKeyPressed(ev, navigator); @@ -186,7 +186,7 @@ function NotesEditorShortcuts() { return; } }, - [isLoading, activePrompt], + [isLoading, isPromptActive], ); useGlobalEventListener("keydown", handleKeyDown); diff --git a/src/components/app/templates/tables/songs/actions.tsx b/src/components/app/templates/tables/songs/actions.tsx index d312d65f..74501e4c 100644 --- a/src/components/app/templates/tables/songs/actions.tsx +++ b/src/components/app/templates/tables/songs/actions.tsx @@ -1,16 +1,16 @@ import { createListCollection } from "@ark-ui/react/collection"; import { useDialog } from "@ark-ui/react/dialog"; import type { MenuSelectionDetails } from "@ark-ui/react/menu"; -import { ChevronDownIcon } from "lucide-react"; import { Fragment, useCallback, useMemo } from "react"; import { APP_TOASTER } from "$/components/app/constants"; -import { AlertDialogProvider, Button, Menu, Text } from "$/components/ui/compositions"; +import { AlertDialogProvider, Button, Menu } from "$/components/ui/compositions"; import { isSongReadonly } from "$/helpers/song.helpers"; import { downloadMapFiles, removeSong } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectBeatmapIds, selectSongById } from "$/store/selectors"; import type { App, SongId } from "$/types"; +import { Text } from "$:styled-system/jsx"; interface SongActionListCollection { song: App.ISong; @@ -63,11 +63,13 @@ function SongsDataTableActions({ sid }: Props) { return ( - + {(Indicator) => ( + + )} - Are you sure? This action cannot be undone 😱} onSubmit={handleDeleteAction} /> + Are you sure? This action cannot be undone 😱} onSubmit={handleDeleteAction} /> ); } diff --git a/src/components/app/templates/tables/songs/index.tsx b/src/components/app/templates/tables/songs/index.tsx index 641e6494..c70313ad 100644 --- a/src/components/app/templates/tables/songs/index.tsx +++ b/src/components/app/templates/tables/songs/index.tsx @@ -3,10 +3,11 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/re import { ArrowRightToLineIcon } from "lucide-react"; import { useMemo } from "react"; -import { CoverArtFilePreview } from "$/components/app/compositions"; +import { CoverArtFile } from "$/components/app/compositions"; import { createBeatmapListCollection } from "$/components/app/constants"; import { Button, DataTable, Select, Spinner } from "$/components/ui/compositions"; import { getBeatmapIds, getSongMetadata, isSongReadonly, resolveSongId } from "$/helpers/song.helpers"; +import { BeatmapFilestore } from "$/services/file.service"; import { updateSelectedBeatmap } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectAllSongs, selectProcessingImport, selectSelectedBeatmap } from "$/store/selectors"; @@ -24,7 +25,7 @@ const SONG_TABLE = [ header: () => null, cell: (ctx) => { const [sid] = ctx.getValue(); - return ; + return ; }, }), helper.accessor((data) => [getSongMetadata(data), isSongReadonly(data)] as const, { @@ -89,7 +90,7 @@ function SongsDataTable() { return ( - + {isProcessingImport && ( diff --git a/src/components/devtools/index.tsx b/src/components/devtools/index.tsx new file mode 100644 index 00000000..a85e8c1c --- /dev/null +++ b/src/components/devtools/index.tsx @@ -0,0 +1,30 @@ +import { TanStackDevtools, type TanStackDevtoolsReactInit } from "@tanstack/react-devtools"; +import { FormDevtoolsPanel } from "@tanstack/react-form-devtools"; +import { PacerDevtoolsPanel } from "@tanstack/react-pacer-devtools"; +import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; +import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; + +const PLUGINS = [ + { + name: "TanStack Form", + render: , + }, + { + name: "TanStack Pacer", + render: , + }, + { + name: "TanStack Query", + render: , + }, + { + name: "TanStack Router", + render: , + }, +]; + +function Devtools({ ...config }: TanStackDevtoolsReactInit["config"]) { + return ; +} + +export default Devtools; diff --git a/src/components/docs/compositions/index.ts b/src/components/docs/compositions/index.ts deleted file mode 100644 index 9f71fc4b..00000000 --- a/src/components/docs/compositions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default as DocsNavigationBlock } from "./navigation-block"; -export { default as DocsProse } from "./prose"; -export { default as DocsThemeToggle } from "./theme-toggle"; -export { default as DocsTableOfContents } from "./toc"; diff --git a/src/components/docs/compositions/navigation-block.tsx b/src/components/docs/compositions/navigation-block.tsx deleted file mode 100644 index be682b3b..00000000 --- a/src/components/docs/compositions/navigation-block.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { ark } from "@ark-ui/react/factory"; -import { Link } from "@tanstack/react-router"; -import { useMemo } from "react"; - -import { Text } from "$/components/ui/compositions"; -import { docs } from "$:content"; -import { HStack, Stack, styled } from "$:styled-system/jsx"; - -interface NavProps { - direction: "previous" | "next"; - item?: { id: string; title: string }; -} -function DocsNavigationBlock({ direction, item }: NavProps) { - const formattedSubtitle = useMemo(() => (direction === "previous" ? "« PREVIOUS" : "NEXT »"), [direction]); - - return ( - - - {item && formattedSubtitle} - - - - {item?.title} - - - - ); -} - -const LinkWrapper = styled(ark.span, { - base: { - textStyle: "link", - fontSize: "20px", - fontWeight: "bold", - colorPalette: "blue", - color: { _light: "colorPalette.700", _dark: "colorPalette.300" }, - }, -}); - -interface Props { - prev?: string; - next?: string; -} -function PreviousNextBar({ prev: prevId, next: nextId }: Props) { - const previous = useMemo(() => docs.find((page) => page.id === prevId), [prevId]); - const next = useMemo(() => docs.find((page) => page.id === nextId), [nextId]); - - return ( - - - - - - - - ); -} - -const Divider = styled("hr", { - base: { - borderColor: "border.muted", - }, -}); - -export default PreviousNextBar; diff --git a/src/components/docs/content/keyboard-shortcuts.tsx b/src/components/docs/content/keyboard-shortcuts.tsx index 6c16b49d..32072ae6 100644 --- a/src/components/docs/content/keyboard-shortcuts.tsx +++ b/src/components/docs/content/keyboard-shortcuts.tsx @@ -1,9 +1,9 @@ import { PlusIcon } from "lucide-react"; -import { type PropsWithChildren, useMemo } from "react"; +import type { PropsWithChildren, ReactNode } from "react"; -import { Shortcut } from "$/components/app/compositions"; -import { Text } from "$/components/ui/compositions"; -import { styled } from "$:styled-system/jsx"; +import { For } from "$/components/ui/atoms"; +import { Shortcut } from "$/components/ui/compositions"; +import { styled, Text } from "$:styled-system/jsx"; import { grid, stack, wrap } from "$:styled-system/patterns"; const IconRow = styled("span", { @@ -27,7 +27,9 @@ function Or({ children = "or" }) { return — {children} —; } -function Row({ row, separator }: { row?: string[]; separator?: string }) { +function Row({ row }: { row: string[] }): ReactNode; +function Row({ separator }: { separator: string | undefined }): ReactNode; +function Row({ row, separator }: { row?: string[]; separator?: string | undefined }): ReactNode { if (!row || separator) { return ( @@ -37,21 +39,13 @@ function Row({ row, separator }: { row?: string[]; separator?: string }) { } return ( - {row.map((code, index) => { - const separator = code === "+" ? " " : "+"; - if (index > 0) - return [ - , - - {code} - , - ]; - return ( - + }> + {(code, index) => ( + {code} - ); - })} + )} + ); } @@ -74,19 +68,15 @@ interface Props extends PropsWithChildren { separator?: string; } export function ShortcutItem({ title, keys, separator, children }: Props) { - const rows = useMemo( - () => - keys.map((row, index) => { - if (index > 0) return [, ]; - return ; - }), - [keys, separator], - ); return ( - {rows} + + }> + {(row, index) => } + + - + {title} {children} diff --git a/src/components/docs/layouts/index.ts b/src/components/docs/layouts/index.ts index 18c6d8c8..0809988c 100644 --- a/src/components/docs/layouts/index.ts +++ b/src/components/docs/layouts/index.ts @@ -1 +1,2 @@ export * as Sidebar from "./sidebar"; +export * as Toc from "./toc"; diff --git a/src/components/docs/layouts/sidebar/index.ts b/src/components/docs/layouts/sidebar/index.ts index 3104bbc7..34e2cca9 100644 --- a/src/components/docs/layouts/sidebar/index.ts +++ b/src/components/docs/layouts/sidebar/index.ts @@ -3,6 +3,7 @@ import { stack } from "$:styled-system/patterns"; export { default as NavItem } from "./item"; export { default as Root } from "./root"; +export { default as DocsThemeToggle } from "./theme-toggle"; export const NavGroup = styled("div", { base: stack.raw({ diff --git a/src/components/docs/layouts/sidebar/item.tsx b/src/components/docs/layouts/sidebar/item.tsx index 187907c8..98bc81f1 100644 --- a/src/components/docs/layouts/sidebar/item.tsx +++ b/src/components/docs/layouts/sidebar/item.tsx @@ -1,6 +1,4 @@ -import { Link } from "@tanstack/react-router"; - -import { Text } from "$/components/ui/compositions"; +import { RouterLink } from "$/components/ui/compositions"; import type { Doc } from "$:content"; import { styled } from "$:styled-system/jsx"; @@ -9,21 +7,17 @@ interface Props { } function DocsSidebarNavItem({ entry }: Props) { return ( - window.scrollTo({ top: 0 })}> - - {entry.title} - - + window.scrollTo({ top: 0 })}> + {entry.title} + ); } -const NavLinkWrapper = styled(Text, { +const NavLinkWrapper = styled("a", { base: { height: "35px", textStyle: "link", colorPalette: "pink", - color: { base: "fg.muted", _hover: "fg.default", _active: { _light: "colorPalette.700", _dark: "colorPalette.300" } }, - fontWeight: 500, fontSize: "16px", }, }); diff --git a/src/components/docs/layouts/sidebar/root.tsx b/src/components/docs/layouts/sidebar/root.tsx index c0525bdc..5f00e022 100644 --- a/src/components/docs/layouts/sidebar/root.tsx +++ b/src/components/docs/layouts/sidebar/root.tsx @@ -1,9 +1,9 @@ import type { PropsWithChildren } from "react"; import { Logo } from "$/components/app/compositions"; -import { DocsThemeToggle } from "$/components/docs/compositions"; import { styled } from "$:styled-system/jsx"; import { center, stack } from "$:styled-system/patterns"; +import ThemeToggle from "./theme-toggle"; function DocsSidebarRoot({ children }: PropsWithChildren) { return ( @@ -13,7 +13,7 @@ function DocsSidebarRoot({ children }: PropsWithChildren) {
{children}
- +
); diff --git a/src/components/docs/compositions/theme-toggle.tsx b/src/components/docs/layouts/sidebar/theme-toggle.tsx similarity index 83% rename from src/components/docs/compositions/theme-toggle.tsx rename to src/components/docs/layouts/sidebar/theme-toggle.tsx index 53f3b732..8b07cefd 100644 --- a/src/components/docs/compositions/theme-toggle.tsx +++ b/src/components/docs/layouts/sidebar/theme-toggle.tsx @@ -3,8 +3,7 @@ import { MoonIcon, SunIcon } from "lucide-react"; import { useEffect } from "react"; import { Switch } from "$/components/ui/compositions"; -import { styled } from "$:styled-system/jsx"; -import { hstack } from "$:styled-system/patterns"; +import { HStack, styled } from "$:styled-system/jsx"; function ThemeToggle() { const ctx = useSwitch({ defaultChecked: localStorage.getItem("dark") === "true" }); @@ -23,10 +22,10 @@ function ThemeToggle() { ); } -const Wrapper = styled("div", { - base: hstack.raw({ +const Wrapper = styled(HStack, { + base: { _icon: { cursor: "pointer" }, - }), + }, }); export default ThemeToggle; diff --git a/src/components/docs/layouts/toc/context.ts b/src/components/docs/layouts/toc/context.ts new file mode 100644 index 00000000..3e9a69d0 --- /dev/null +++ b/src/components/docs/layouts/toc/context.ts @@ -0,0 +1,10 @@ +import { createContext, type Consumer as ReactConsumer } from "react"; + +interface ITocContext { + activeHeadingId: string | null; +} + +export const Context = createContext(null); + +export const Provider = Context.Provider; +export const Consumer = Context.Consumer as ReactConsumer>; diff --git a/src/components/docs/layouts/toc/index.ts b/src/components/docs/layouts/toc/index.ts new file mode 100644 index 00000000..8f8c6913 --- /dev/null +++ b/src/components/docs/layouts/toc/index.ts @@ -0,0 +1,32 @@ +import { styled } from "$:styled-system/jsx"; + +export { Consumer as Context } from "./context"; +export { default as Root } from "./root"; + +export const Header = styled("div", { + base: { + borderBottomWidth: "sm", + borderColor: "border.default", + paddingBottom: 1, + marginBottom: 1, + fontWeight: "bold", + }, +}); + +export const Footer = styled("div", { + base: { + borderTopWidth: "sm", + borderColor: "border.default", + paddingTop: 1, + marginTop: 1, + fontWeight: "bold", + }, +}); + +export const Item = styled("a", { + base: { + textStyle: "link", + colorPalette: "pink", + paddingBlock: 1, + }, +}); diff --git a/src/components/docs/compositions/toc.tsx b/src/components/docs/layouts/toc/root.tsx similarity index 62% rename from src/components/docs/compositions/toc.tsx rename to src/components/docs/layouts/toc/root.tsx index f735607d..3a7914f2 100644 --- a/src/components/docs/compositions/toc.tsx +++ b/src/components/docs/layouts/toc/root.tsx @@ -1,22 +1,23 @@ -import { ExternalLinkIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import type { Assign } from "@ark-ui/react"; +import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from "react"; import type { Member } from "$/types"; import type { Doc } from "$:content"; -import { HStack, styled } from "$:styled-system/jsx"; -import { hstack, stack } from "$:styled-system/patterns"; +import { styled } from "$:styled-system/jsx"; +import { stack } from "$:styled-system/patterns"; +import { Provider } from "./context"; type TocEntry = Member; -// TODO: fix this container hell -function useActiveHeading(headings: TocEntry[], containerElement: HTMLElement | null) { +function useToc(headings: TocEntry[], containerElement: HTMLElement | null) { const scrollContainerRef = useRef(null); - const [activeHeadingId, setActiveHeading] = useState(null); const headingElementsRef = useRef<{ id: string; element: HTMLElement | null }[]>([]); + const [activeHeadingId, setActiveHeading] = useState(null); + useEffect(() => { headingElementsRef.current = headings.map((entry) => ({ - id: entry.url.replace("#", ""), + id: entry.url, element: document.querySelector(entry.url), })); }, [headings]); @@ -37,7 +38,7 @@ function useActiveHeading(headings: TocEntry[], containerElement: HTMLElement | // If neither condition is met, I'll assume I'm still in the intro, although this would have to be a VERY long intro to ever be true. const headingBoxes = headings.map((entry) => { const elem = document.querySelector(entry.url); - return { id: entry.url.replace("#", ""), box: elem?.getBoundingClientRect() }; + return { id: entry.url, box: elem?.getBoundingClientRect() }; }); // The first heading within the viewport is the one we want to highlight. @@ -85,37 +86,20 @@ function useActiveHeading(headings: TocEntry[], containerElement: HTMLElement | }; }, [containerElement, handleScroll]); - return activeHeadingId; + return { activeHeadingId }; } interface Props { - container: HTMLElement | null; toc: TocEntry[]; + container: HTMLElement | null; } -function DocsTableOfContents({ container, toc }: Props) { - const activeHeadingId = useActiveHeading(toc, container); +function DocsTocRoot({ toc, container, children }: Assign) { + const tocContext = useToc(toc, container); return ( - - Table of Contents - container?.scrollTo({ top: 0 })}> - Introduction - - {toc.map((entry) => { - const id = entry.url.replace("#", ""); - return ( - - {entry.title} - - ); - })} - - - Suggest an edit - - - - + + {children} + ); } @@ -131,35 +115,4 @@ const Wrapper = styled("div", { }), }); -const Title = styled("h4", { - base: { - fontWeight: "bold", - borderBottomWidth: "sm", - borderColor: "border.default", - paddingBottom: 1, - marginBottom: 1, - }, -}); - -const HeadingLink = styled("a", { - base: { - textStyle: "link", - colorPalette: "pink", - color: { base: "fg.muted", _hover: "fg.default", _active: { _light: "colorPalette.700", _dark: "colorPalette.300" } }, - paddingBlock: 1, - }, -}); - -const GithubLink = styled("a", { - base: hstack.raw({ - textStyle: "link", - fontWeight: "bold", - color: "fg.default", - borderTopWidth: "sm", - borderColor: "border.default", - paddingTop: 1, - marginTop: 1, - }), -}); - -export default DocsTableOfContents; +export default DocsTocRoot; diff --git a/src/components/docs/templates/page.tsx b/src/components/docs/templates/page/index.tsx similarity index 73% rename from src/components/docs/templates/page.tsx rename to src/components/docs/templates/page/index.tsx index 65bae370..5a9758a5 100644 --- a/src/components/docs/templates/page.tsx +++ b/src/components/docs/templates/page/index.tsx @@ -1,9 +1,11 @@ import { type PropsWithChildren, useMemo } from "react"; -import { DocsNavigationBlock, DocsProse, DocsTableOfContents } from "$/components/docs/compositions"; +import DocsTableOfContents from "$/components/docs/templates/toc"; import { docs } from "$:content"; -import { Stack, styled } from "$:styled-system/jsx"; +import { Divider, Stack, styled } from "$:styled-system/jsx"; import { stack } from "$:styled-system/patterns"; +import DocsNavigation from "./navigation"; +import DocsProse from "./prose"; interface Props extends PropsWithChildren { id: string; @@ -12,6 +14,7 @@ interface Props extends PropsWithChildren { function DocsPageLayout({ id, container }: Props) { const entry = useMemo(() => docs.find((x) => x.id === id), [id]); + if (!entry) { throw new Error("No doc found at this route."); } @@ -24,12 +27,10 @@ function DocsPageLayout({ id, container }: Props) {
- - - + - {(entry.prev || entry.next) && } + {(entry.prev || entry.next) && } ); } @@ -61,12 +62,6 @@ const Subtitle = styled("div", { }, }); -const Divider = styled("hr", { - base: { - borderColor: "border.muted", - }, -}); - const ContentWrapper = styled("div", { base: stack.raw({ align: "start", @@ -76,11 +71,4 @@ const ContentWrapper = styled("div", { }), }); -const MainContent = styled("div", { - base: { - width: "100%", - flex: 1, - }, -}); - export default DocsPageLayout; diff --git a/src/components/docs/compositions/media.tsx b/src/components/docs/templates/page/media.tsx similarity index 100% rename from src/components/docs/compositions/media.tsx rename to src/components/docs/templates/page/media.tsx diff --git a/src/components/docs/templates/page/navigation.tsx b/src/components/docs/templates/page/navigation.tsx new file mode 100644 index 00000000..e0d0eaf1 --- /dev/null +++ b/src/components/docs/templates/page/navigation.tsx @@ -0,0 +1,56 @@ +import { useMemo } from "react"; + +import { RouterLink } from "$/components/ui/compositions"; +import { docs } from "$:content"; +import { Divider, HStack, LinkOverlay, Stack, styled, Text } from "$:styled-system/jsx"; + +interface NavProps { + direction: "previous" | "next"; + item?: { id: string; title: string }; +} +function NavigationBlock({ direction, item }: NavProps) { + const formattedSubtitle = useMemo(() => (direction === "previous" ? "« PREVIOUS" : "NEXT »"), [direction]); + + return ( + + + {item && formattedSubtitle} + + + {item?.title} + + + ); +} + +const Wrapper = styled(Stack, { + base: { + position: "relative", + fontSize: "20px", + colorPalette: "blue", + layerStyle: "fill.ghost", + padding: 1, + borderRadius: "sm", + }, +}); + +interface Props { + prev?: string; + next?: string; +} +function DocsNavigation({ prev: prevId, next: nextId }: Props) { + const previous = useMemo(() => docs.find((page) => page.id === prevId), [prevId]); + const next = useMemo(() => docs.find((page) => page.id === nextId), [nextId]); + + return ( + + + + + + + + ); +} + +export default DocsNavigation; diff --git a/src/components/docs/compositions/prose.tsx b/src/components/docs/templates/page/prose.tsx similarity index 71% rename from src/components/docs/compositions/prose.tsx rename to src/components/docs/templates/page/prose.tsx index f19daa44..b66f5efd 100644 --- a/src/components/docs/compositions/prose.tsx +++ b/src/components/docs/templates/page/prose.tsx @@ -1,38 +1,20 @@ import type { MDXComponents } from "mdx/types"; -import type { ComponentProps } from "react"; +import { type ComponentProps, forwardRef } from "react"; -import { Shortcut } from "$/components/app/compositions"; import * as ContentComponents from "$/components/docs/content"; -import { MDXContent } from "$/components/ui/atoms"; -import { Text } from "$/components/ui/compositions"; -import { KBD } from "$/components/ui/styled"; +import { MDX } from "$/components/ui/atoms"; +import { AnchorLink, Shortcut } from "$/components/ui/compositions"; import { styled } from "$:styled-system/jsx"; import DocsMedia from "./media"; -const Subtle = styled("span", { - base: { - fontStyle: "italic", - color: "fg.subtle", - }, -}); - -const sharedComponents: MDXComponents = { - a: ({ ...props }) => ( - - - - ), - img: ({ alt, title, ...rest }) => ( +const PROSE_MDX_COMPONENTS: MDXComponents = { + a: forwardRef(({ ...rest }, ref) => ), + img: forwardRef(({ alt, title, ...rest }, ref) => ( - {alt} + {alt} - ), - Key: ({ children }) => {children}, - Subtle: Subtle, - Shortcut: ({ separator, children }) => {children}, - YoutubeEmbed: ({ title, width = 560, height = 315, src }) => { - return