Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a73a11e
refactor storage middleware to strategy-based enhancers
officialMECH Jul 14, 2026
e50b7e2
normalize real time calculation to seconds
officialMECH Jul 15, 2026
ac93270
compute audio latency directly from audio context
officialMECH Jul 15, 2026
20b3364
add shallow apis (and explicit types) for thunk action creators
officialMECH Jul 16, 2026
710c7ba
add dedicated apis for matched route selectors
officialMECH Jul 16, 2026
a23afaa
simplify payloads for atomic update actions/hydrators
officialMECH Jul 20, 2026
33d9a89
refactor incremental thunk action payload creators
officialMECH Jul 20, 2026
33128e9
add declarative types and implicit route accessors for listener middl…
officialMECH Jul 22, 2026
9a13c7d
untangle circular references for storage enhancers
officialMECH Jul 31, 2026
f9d64ce
consolidate beatmap entity slices with colocated history adapters
officialMECH Aug 4, 2026
fc7cd85
normalize reducer paths for slices
officialMECH Aug 6, 2026
abd5974
rework file structure for store modules and setup methods
officialMECH Aug 6, 2026
377728c
improve type inference for thunk utils
officialMECH Aug 6, 2026
ec93839
move route selectors into store helpers
officialMECH Aug 7, 2026
029d691
improve dependency injection patterns for middleware/thunks
officialMECH Aug 7, 2026
33230f4
rework singleton apis to decouple setup modules
officialMECH Aug 8, 2026
ddf5949
improve type inference for thunk arguments
officialMECH Aug 10, 2026
36518f4
add shared utility for meta property action matching
officialMECH Aug 11, 2026
e135504
make filestore source of truth for songs hydration step
officialMECH Aug 11, 2026
45de027
fix regression in property resolution for object placement logic
officialMECH Aug 12, 2026
3cf8baf
move cross-slice selectors out of slice definitions
officialMECH Aug 17, 2026
71e79d5
reconcile stateful thunk payloads using listener middleware
officialMECH Aug 17, 2026
2f0c6f2
consolidate editor lifecycle logic into imperative actions
officialMECH Aug 18, 2026
a65aae7
revert back to original songs hydrator (for now)
officialMECH Aug 18, 2026
61a4b14
consolidate audio sample effects into imperative actions
officialMECH Aug 18, 2026
a573d3c
migrate remaining cross-slice extra reducers into listener middleware…
officialMECH Aug 19, 2026
bb07f3d
untangle circular references for same-site actions and selectors
officialMECH Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"date-fns": "^4.1.0",
"fflate": "^0.8.2",
"file-saver": "^2.0.5",
"history-adapter": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^1.0.1",
"postprocessing": "^6.39.0",
Expand Down
23 changes: 23 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 11 additions & 20 deletions src/setup.ts → src/_setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,16 @@ import { createToaster } from "@ark-ui/react/toast";
import { typeByExtension } from "@std/media-types/type-by-extension";
import { extname } from "@std/path/extname";
import { toPascalCase } from "@std/text/to-pascal-case";
import { createBeatmap, loadDifficulty, loadInfo, setupLogger } from "bsmap";
import { createStorage, type Driver, type StorageValue } from "unstorage";
import { createBeatmap, loadDifficulty, loadInfo } from "bsmap";
import { type CreateStorageOptions, createStorage, type StorageValue } from "unstorage";

import { BeatmapFilestore } from "./services/file.service";
import { createDriver, type LegacyStorageSchema } from "./services/storage.service";
import { createAppStore } from "./store/setup";
import { createAppStore } from "./store/_setup";
import type { App } from "./types";
import { createLazySingleton } from "./utils";
import { createSingleton } from "./utils";

if (import.meta.env.DEV) {
setupLogger();
}

const appFileDriver = createDriver<LegacyStorageSchema & { entries: { key: string; value: StorageValue } }>({
export const createAppFileStorageDriver = createDriver<LegacyStorageSchema & { entries: { key: string; value: StorageValue } }>({
name: "beat-mapper-files",
version: 4,
async upgrade(idb, _current, next, tx) {
Expand Down Expand Up @@ -75,21 +71,16 @@ const appFileDriver = createDriver<LegacyStorageSchema & { entries: { key: strin
},
});

export const { get: getAudioContext, setup: setupAudioContext } = createLazySingleton(() => {
return new AudioContext({ latencyHint: "playback" });
export const AppAudioContext = createSingleton((options: Omit<AudioContextOptions, "latencyHint">) => {
return new AudioContext({ ...options, latencyHint: "playback" });
});

export const { get: getAppBeatmapFilestore, setup: setupAppBeatmapFilestore } = createLazySingleton((driver?: Driver) => {
export const AppFilestore = createSingleton((options: CreateStorageOptions) => {
return new BeatmapFilestore({
storage: createStorage({
driver: driver ?? appFileDriver({ name: "entries" }),
}),
storage: createStorage(options),
});
});

export const { get: getAppToaster, setup: setupAppToaster } = createLazySingleton((toaster?: ReturnType<typeof createToaster>): NonNullable<typeof toaster> | null => {
if (toaster !== undefined) return toaster ?? null;
return createToaster({ placement: "bottom-end", overlap: true, max: 8 });
});
export const AppToaster = createSingleton(createToaster);

export const { get: getAppStore, setup: setupAppStore } = createLazySingleton(createAppStore);
export const AppStore = createSingleton(createAppStore);
7 changes: 3 additions & 4 deletions src/components/app/forms/create-beatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { PropsWithChildren, ReactNode } from "react";
import { object } from "valibot";

import { createBeatmapCharacteristicListCollection, createBeatmapDifficultyListCollection } from "$/components/app/constants";
import { useSetupContext } from "$/components/context";
import { useToaster } from "$/components/context";
import { useAppForm } from "$/components/ui/compositions";
import { type createAppBeatmap, resolveBeatmapId } from "$/helpers/song.helpers";
import { useAppSelector } from "$/store/hooks";
Expand All @@ -30,8 +30,7 @@ function CreateBeatmapForm({ dialog, onSubmit, children }: Assign<PropsWithChild
const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" });

const navigate = useNavigate();

const { toaster } = useSetupContext();
const toaster = useToaster();

const username = useAppSelector(selectUsername);
const beatmaps = useAppSelector((state) => selectAllBeatmaps(state, sid));
Expand All @@ -54,7 +53,7 @@ function CreateBeatmapForm({ dialog, onSubmit, children }: Assign<PropsWithChild
if (dialog) dialog.setOpen(false);
navigate({ to: `/edit/$sid/$bid/${view}`, params: { sid: sid.toString(), bid: beatmapId } });
} catch (error) {
toaster?.error({ description: `Could not create beatmap: ${error instanceof Error ? error.message : "See console for more info."}` });
toaster.error({ description: `Could not create beatmap: ${error instanceof Error ? error.message : "See console for more info."}` });
return console.error(error);
}
},
Expand Down
6 changes: 3 additions & 3 deletions src/components/app/forms/create-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useState } from "react";
import { array, endsWith, file, type GenericSchema, gtValue, length, maxLength, minLength, minValue, number, object, pipe, string, transform } from "valibot";

import { CHARACTERISTIC_COLLECTION, COVER_ART_FILE_ACCEPT_TYPE, DIFFICULTY_COLLECTION, ENVIRONMENT_COLLECTION, SONG_FILE_ACCEPT_TYPE } from "$/components/app/constants";
import { useSetupContext } from "$/components/context";
import { useToaster } from "$/components/context";
import { Show } from "$/components/ui/atoms";
import { Audio, Switch, useAppForm } from "$/components/ui/compositions";
import { createPlaceholderImageFile, remuxImageToSquare } from "$/helpers/file.helpers";
Expand Down Expand Up @@ -42,7 +42,7 @@ function CreateMapForm({ dialog }: Props) {
const username = useAppSelector(selectUsername);
const volume = useAppSelector(selectSongVolume);

const { toaster } = useSetupContext();
const toaster = useToaster();

const Form = useAppForm({
defaultValues: {
Expand Down Expand Up @@ -104,7 +104,7 @@ function CreateMapForm({ dialog }: Props) {

if (dialog) dialog.setOpen(false);
} catch (error) {
toaster?.error({ description: `Could not create map: ${error instanceof Error ? error.message : "See console for more information."}` });
toaster.error({ description: `Could not create map: ${error instanceof Error ? error.message : "See console for more information."}` });
return console.error(error);
}
},
Expand Down
6 changes: 3 additions & 3 deletions src/components/app/forms/export-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { InferBeatmapVersion } from "bsmap";
import type { ComponentProps } from "react";
import { boolean, null_, object, picklist, union } from "valibot";

import { useSetupContext } from "$/components/context";
import { useToaster } from "$/components/context";
import { Heading, useAppForm } from "$/components/ui/compositions";
import type { SubmitButton } from "$/components/ui/compositions/button";
import type { ExportMapArchiveOptions } from "$/services/packaging.service";
Expand All @@ -20,7 +20,7 @@ interface Props {
onSubmit: (ctx: ExportMapArchiveOptions) => void;
}
function ExportMapForm({ onSubmit, ...rest }: Assign<ComponentProps<typeof SubmitButton>, Props>) {
const { toaster } = useSetupContext();
const toaster = useToaster();

const { collection: serialVersionCollection } = useListCollection({
initialItems: ["4", "3", "2", "1"],
Expand All @@ -46,7 +46,7 @@ function ExportMapForm({ onSubmit, ...rest }: Assign<ComponentProps<typeof Submi
saveOptions: { format: value.minify ? 0 : 2, optimize: { purgeZeros: value.purgeZeros } },
});
} catch (error) {
toaster?.error({ description: `Could not export map: ${error instanceof Error ? error.message : "See console for more info."}` });
toaster.error({ description: `Could not export map: ${error instanceof Error ? error.message : "See console for more info."}` });
return console.error(error);
}
},
Expand Down
6 changes: 3 additions & 3 deletions src/components/app/forms/import-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type ComponentProps, useCallback } from "react";
import { Fragment } from "react/jsx-runtime";

import { MAP_ARCHIVE_FILE_ACCEPT_TYPE } from "$/components/app/constants";
import { useSetupContext } from "$/components/context";
import { useToaster } from "$/components/context";
import { FileUpload, List } from "$/components/ui/compositions";
import { Stack, Text } from "$:styled-system/jsx";

Expand All @@ -14,7 +14,7 @@ interface Props {
onAccept: (files: File[]) => void;
}
function ImportMapForm({ dialog, onAccept, ...rest }: Assign<ComponentProps<typeof FileUpload>, Props>) {
const { toaster } = useSetupContext();
const toaster = useToaster();

const handleFileChange = useCallback(
(details: FileUploadFileChangeDetails) => {
Expand All @@ -23,7 +23,7 @@ function ImportMapForm({ dialog, onAccept, ...rest }: Assign<ComponentProps<type
try {
onAccept(details.acceptedFiles);
} catch (error) {
toaster?.error({ description: `Could not import map: ${error instanceof Error ? error.message : "See console for more info."}` });
toaster.error({ description: `Could not import map: ${error instanceof Error ? error.message : "See console for more info."}` });
return console.error(error);
}
},
Expand Down
12 changes: 4 additions & 8 deletions src/components/app/forms/settings/audio.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createListCollection } from "@ark-ui/react/collection";

import { Field, FieldInput, FieldSelectGroup } from "$/components/ui/compositions";
import { updateProcessingDelay, updateTickType } from "$/store/actions";
import { Field, FieldSelectGroup } from "$/components/ui/compositions";
import { updateTickType } from "$/store/actions";
import { useAppDispatch, useAppSelector } from "$/store/hooks";
import { selectAudioProcessingDelay, selectTickType } from "$/store/selectors";
import { selectTickType } from "$/store/selectors";
import { Stack, Wrap } from "$:styled-system/jsx";

const TICK_MAP = ["woodblock", "switch"];
Expand All @@ -14,17 +14,13 @@ const NOTE_TICK_COLLECTION = createListCollection({

function AppAudioSettings() {
const dispatch = useAppDispatch();
const processingDelay = useAppSelector(selectAudioProcessingDelay);
const tickType = useAppSelector(selectTickType);

return (
<Stack gap={4}>
<Wrap gap={2}>
<Field label="Note tick type" helperText="Change the sound effect played when simulating a hitsound during playback.">
<FieldSelectGroup collection={NOTE_TICK_COLLECTION} value={TICK_MAP[tickType]} onValueChange={(details) => dispatch(updateTickType({ value: TICK_MAP.indexOf(details.valueAsString) }))} />
</Field>
<Field label="Processing delay" helperText="Tweak the amount of time, in milliseconds, that the audio should be offset by, for it to seem synchronized. Slower machines should experiment with larger numbers.">
<FieldInput type="number" value={processingDelay} onValueChange={(details) => dispatch(updateProcessingDelay({ value: details.valueAsNumber }))} />
<FieldSelectGroup collection={NOTE_TICK_COLLECTION} value={TICK_MAP[tickType]} onValueChange={(details) => dispatch(updateTickType(TICK_MAP.indexOf(details.valueAsString)))} />
</Field>
</Wrap>
</Stack>
Expand Down
2 changes: 1 addition & 1 deletion src/components/app/forms/settings/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function AppControlsSettings() {
<Stack gap={4}>
<Wrap gap={2}>
<Field label="Obstacle Placement Mode" helperText="Determines the behavior of how obstacles are placed along the grid. [Learn more](/docs/manual/notes#obstacle-placement-modes)">
<RadioGroup collection={OBSTACLE_PLACEMENT_MODE_COLLECTION} value={obstaclePlacementMode} onValueChange={(x) => dispatch(updateObstaclePlacementMode({ value: x.value as ObstaclePlacementMode }))} />
<RadioGroup collection={OBSTACLE_PLACEMENT_MODE_COLLECTION} value={obstaclePlacementMode} onValueChange={(x) => dispatch(updateObstaclePlacementMode(x.value as ObstaclePlacementMode))} />
</Field>
</Wrap>
</Stack>
Expand Down
4 changes: 2 additions & 2 deletions src/components/app/forms/settings/graphics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ function AppGraphicsSettings() {
<Stack gap={4}>
<Wrap gap={2}>
<Field cosmetic label="Render Scale" helperText="Reduces the number of components for computationally intensive objects within the view.">
<Slider name="render-scale" stretch value={[renderScale]} step={0.05} min={0} max={1} onValueChange={(details) => dispatch(updateRenderScale({ value: details.value[0] }))} />
<Slider name="render-scale" stretch value={[renderScale]} step={0.05} min={0} max={1} onValueChange={(details) => dispatch(updateRenderScale(details.value[0]))} />
</Field>
<Field cosmetic label="Enable Bloom Effect" helperText="Enables/disables the bloom post-processing effect for lighting events.">
<Switch name="enable-bloom" checked={isBloomEnabled} onCheckedChange={(details) => dispatch(updateBloomEnabled({ checked: details.checked }))} />
<Switch name="enable-bloom" checked={isBloomEnabled} onCheckedChange={(details) => dispatch(updateBloomEnabled(details.checked))} />
</Field>
</Wrap>
</Stack>
Expand Down
2 changes: 1 addition & 1 deletion src/components/app/forms/settings/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function AppUserSettings() {
<Stack gap={4}>
<Wrap gap={2}>
<Field label="Username" helperText="Will autopopulate mappers/lighters fields for any new maps/beatmaps that are created.">
<FieldInput value={username} onValueChange={(details) => dispatch(updateUsername({ value: details.valueAsString }))} />
<FieldInput value={username} onValueChange={(details) => dispatch(updateUsername(details.valueAsString))} />
</Field>
</Wrap>
</Stack>
Expand Down
10 changes: 5 additions & 5 deletions src/components/app/forms/update-beatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { array, endsWith, type GenericSchema, gtValue, null_, number, object, pi

import { ENVIRONMENT_OVERRIDE_COLLECTION } from "$/components/app/constants";
import { CreateBeatmapForm } from "$/components/app/forms";
import { useSetupContext } from "$/components/context";
import { useToaster } from "$/components/context";
import { Interleave } from "$/components/ui/atoms";
import { AlertDialogProvider, Button, Collapsible, Dialog, Heading, RouterLink, Stat, useAppForm } from "$/components/ui/compositions";
import { addColorScheme, copyBeatmap, removeBeatmap, updateBeatmap } from "$/store/actions";
Expand Down Expand Up @@ -40,10 +40,10 @@ function UpdateBeatmapForm({ bid }: Props) {
const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" });
const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" });

const { toaster } = useSetupContext();
const navigate = useNavigate();
const toaster = useToaster();

const dispatch = useAppDispatch();
const navigate = useNavigate();
const bpm = useAppSelector((state) => selectBpm(state, sid));
const beatmaps = useAppSelector((state) => selectBeatmaps(state, sid));
const lightshowIds = useAppSelector((state) => selectLightshowIds(state, sid));
Expand Down Expand Up @@ -86,7 +86,7 @@ function UpdateBeatmapForm({ bid }: Props) {

formApi.reset(value);
} catch (error) {
toaster?.error({ description: `Could not update beatmap: ${error instanceof Error ? error.message : "See console for more information."}` });
toaster.error({ description: `Could not update beatmap: ${error instanceof Error ? error.message : "See console for more information."}` });
return console.error(error);
}
},
Expand All @@ -109,7 +109,7 @@ function UpdateBeatmapForm({ bid }: Props) {
// Don't let the user delete the last difficulty!
const remainingDifficultyIds = Object.keys(mutableDifficultiesCopy);
if (remainingDifficultyIds.length === 0) {
return toaster?.error({
return toaster.error({
id: "last-difficulty",
description: "Sorry, you cannot delete the only remaining difficulty! Please create another difficulty first.",
});
Expand Down
30 changes: 9 additions & 21 deletions src/components/app/forms/update-song.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { endsWith, type GenericSchema, gtValue, minValue, number, object, pipe,

import { 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 { useSetupContext } from "$/components/context";
import { useToaster } from "$/components/context";
import { Audio, Field, FileUpload, useAppForm } from "$/components/ui/compositions";
import { remuxImageToSquare } from "$/helpers/file.helpers";
import { BeatmapFilestore } from "$/services/file.service";
import { updateSong } from "$/store/actions";
import { loadSongFile, updateSong } from "$/store/actions";
import { useAppDispatch, useAppSelector } from "$/store/hooks";
import { selectSongById, selectSongVolume } from "$/store/selectors";

Expand Down Expand Up @@ -39,7 +39,7 @@ const SCHEMA = object({
function UpdateSongForm() {
const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" });

const { filestore, toaster } = useSetupContext();
const toaster = useToaster();

const dispatch = useAppDispatch();
const song = useAppSelector((state) => selectSongById(state, sid));
Expand All @@ -56,12 +56,14 @@ function UpdateSongForm() {

const { mutate: handleAcceptSongFile } = useLocalFileMutation(BeatmapFilestore.resolveFilename(sid, "song", {}), {
onSuccess: () => {
toaster?.success({ id: "song-file-accepted", description: "Successfully updated song file!" });
toaster.success({ id: "song-file-accepted", description: "Successfully updated song file!" });
// reload the song file to reconcile the redux store
dispatch(loadSongFile({ songId: sid }));
},
});
const { mutate: handleAcceptCoverArtFile } = useLocalFileMutation(BeatmapFilestore.resolveFilename(sid, "cover", {}), {
onSuccess: () => {
toaster?.success({ id: "cover-art-file-accepted", description: "Successfully updated cover art file!" });
toaster.success({ id: "cover-art-file-accepted", description: "Successfully updated cover art file!" });
},
});

Expand All @@ -85,24 +87,10 @@ function UpdateSongForm() {
},
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, songFile: acceptedSongFile?.[0], changes: newSongObject }));

dispatch(updateSong({ songId: sid, changes: value }));
formApi.reset(value);
} catch (error) {
toaster?.error({ description: `Could not update song: ${error instanceof Error ? error.message : "See console for more information."}` });
toaster.error({ description: `Could not update song: ${error instanceof Error ? error.message : "See console for more information."}` });
return console.error(error);
}
},
Expand Down
Loading
Loading