Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
259 changes: 16 additions & 243 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion e2e/full_journey.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { assertZipMatchesFixture } from './helpers/zip';
async function favoriteMap(page: Page) {
// The favorite button only renders once the map's userProjection has loaded; wait for it so a
// missing projection fails loudly here rather than as an opaque click timeout.
const favorite = page.getByRole('button', { name: '' });
const favorite = page.getByRole('button', { name: 'Favorite' });
await expect(favorite).toBeVisible();
await Promise.all([
page.waitForResponse(
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@aws-sdk/client-s3": "^3.958.0",
"@aws-sdk/s3-request-presigner": "^3.958.0",
"@axiomhq/js": "^1.3.1",
"@base-ui/react": "^1.5.0",
"@flags-sdk/edge-config": "^0.1.2",
"@logtape/logtape": "^1.3.5",
"@mdx-js/loader": "^3.1.1",
Expand All @@ -41,6 +42,7 @@
"eslint": "^9",
"eslint-config-next": "16.0.3",
"flags": "^4.0.2",
"lucide-react": "^1.17.0",
"mobx": "^6.15.0",
"mobx-react": "^9.2.1",
"mobx-react-lite": "^4.1.1",
Expand All @@ -50,7 +52,6 @@
"postcss-modules-values": "^4.0.0",
"qs": "^6.11.2",
"react": "19.2.0",
"react-aria-components": "^1.8.0",
"react-dom": "19.2.0",
"sanitize-html": "^2.11.0",
"server-only": "^0.0.1",
Expand Down
7 changes: 4 additions & 3 deletions src/app/filter_builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
setFieldValue,
toSimpleFilter,
} from 'app/filter_modes';
import { X } from 'lucide-react';
import { action } from 'mobx';
import { observer } from 'mobx-react-lite';
import {
Expand Down Expand Up @@ -148,7 +149,7 @@ const Pill = (props: { label: string; onRemove: () => void }) => (
aria-label={`Remove filter: ${props.label}`}
onClick={props.onRemove}
>
<X />
</button>
</div>
);
Expand Down Expand Up @@ -225,7 +226,7 @@ const GroupEditor = (props: {
aria-label="Remove group"
onClick={onRemove}
>
<X />
</button>
)}
</div>
Expand Down Expand Up @@ -338,7 +339,7 @@ const CmpEditor = (props: {
aria-label="Remove condition"
onClick={onRemove}
>
<X />
</button>
</div>
);
Expand Down
27 changes: 15 additions & 12 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApiProvider } from 'app/api/api_provider';
import { MaintenanceBanner } from 'app/maintenance_banner';
import { SkeletonProvider } from 'app/skeleton_provider';
import { LucideProvider } from 'lucide-react';
import type { Metadata } from 'next';
import { Flags } from 'services/flags/flag_definitions';
import { getUserSession } from 'services/session/session';
Expand Down Expand Up @@ -29,18 +30,20 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>
<ApiProvider>
<SessionProvider session={session}>
<SkeletonProvider className={styles.skeleton}>
{showBanner ? <MaintenanceBanner message={bannerMessage} /> : null}
<NavBar />
<div className={styles.content}>{children}</div>
<ToastProvider />
</SkeletonProvider>
</SessionProvider>
</ApiProvider>
</ThemeProvider>
<LucideProvider size={16}>
<ThemeProvider>
<ApiProvider>
<SessionProvider session={session}>
<SkeletonProvider className={styles.skeleton}>
{showBanner ? <MaintenanceBanner message={bannerMessage} /> : null}
<NavBar />
<div className={styles.content}>{children}</div>
<ToastProvider />
</SkeletonProvider>
</SessionProvider>
</ApiProvider>
</ThemeProvider>
</LucideProvider>
</body>
</html>
);
Expand Down
4 changes: 3 additions & 1 deletion src/app/map/[id]/map_actions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useApi } from 'app/api/api_provider';
import { Heart } from 'lucide-react';
import { observer, useLocalObservable } from 'mobx-react-lite';
import { PDMap } from 'schema/maps';
import { Button } from 'ui/base/button/button';
Expand All @@ -24,11 +25,12 @@ export const MapActions = observer((props: { map: PDMap }) => {
<div className={styles.actions}>
{session && isFavorited != null && (
<Button
aria-label="Favorite"
onClick={presenter.onToggleFavorite}
loading={store.updatingFavorite}
style={isFavorited ? 'active' : 'regular'}
>
{store.updatingFavorite ? '' : '❤'}
{store.updatingFavorite ? '' : <Heart fill={isFavorited ? 'currentColor' : 'none'} />}
</Button>
)}
{downloadLink && <Button link={downloadLink}>Download</Button>}
Expand Down
2 changes: 1 addition & 1 deletion src/app/map_list_presenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class MapListStore {

constructor(
query: string,
readonly tableSortStore: TableSortStore<PDMap, 7>,
readonly tableSortStore: TableSortStore<PDMap, 8>,
initialFilter?: FilterNode
) {
this.query = query;
Expand Down
28 changes: 15 additions & 13 deletions src/app/page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,28 @@
box-shadow: 0 0 0 1px var(--colorGreyA15);
}

.bulkSelectEnabled .mapListRow::after {
content: '';
display: flex;
/* Pin the selection column to a fixed width (scoped to bulk-select mode so it collapses
to nothing when off) and centre the checkbox in it, rather than letting the table's
slack flow into the column and leave empty space beside the checkbox. */
.bulkSelectEnabled .mapListTable td:last-child,
.bulkSelectEnabled .mapListTable th:last-child {
width: calc(var(--gridBaseline) * 8);
text-align: center;
}

.selectBox {
display: inline-flex;
justify-content: center;
align-items: center;
position: absolute;
top: 50%;
right: var(--gridBaseline);
transform: translateY(-50%);
vertical-align: middle;
width: calc(var(--gridBaseline) * 3);
height: calc(var(--gridBaseline) * 3);
border: 1px solid var(--colorGreyA15);
pointer-events: none;
color: var(--colorAccent);
cursor: pointer;
}

.bulkSelectEnabled .mapListRow.mapListRowSelected::after {
content: '✔';
color: var(--colorAccent);
font-weight: bolder;
font-size: 20px;
.selectBoxChecked {
border: 2px solid var(--colorAccent);
}

Expand Down
20 changes: 17 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useApi } from 'app/api/api_provider';
import { MapListPresenter, MapListStore } from 'app/map_list_presenter';
import { useSkeletonRef } from 'app/skeleton_provider';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import { action, computed, observable, reaction } from 'mobx';
import { observer, useLocalObservable } from 'mobx-react-lite';
import { useSearchParams } from 'next/navigation';
Expand Down Expand Up @@ -60,6 +61,8 @@ const Home = observer(() => {
sortLabel: 'submissionDate',
style: { width: `${metrics.gridBaseline * 20}px` },
},
// Trailing selection column; only shows a checkbox when bulk select is enabled.
{ content: <div></div> },
],
6,
'desc'
Expand Down Expand Up @@ -140,8 +143,9 @@ const MapListTable = observer((props: { store: MapListStore; presenter: MapListP

const [tableStore] = useState(() => new TableStore(computed(() => store.maps)));

const getRow = (map: PDMap): Row<7> => {
const onSelect = action((e: React.MouseEvent<HTMLAnchorElement>) => {
const getRow = (map: PDMap): Row<8> => {
const selected = presenter.isSelected(map.id);
const onSelect = action((e: React.MouseEvent) => {
if (!store.enableBulkSelect) {
return;
}
Expand All @@ -160,7 +164,7 @@ const MapListTable = observer((props: { store: MapListStore; presenter: MapListP
</RouteLink>
);
return {
className: classNames({ [styles.mapListRowSelected]: presenter.isSelected(map.id) }),
className: classNames({ [styles.mapListRowSelected]: selected }),
Cells: [
React.memo(() => <DifficultyColorPills difficulties={map.difficulties} />),
React.memo(() => wrapWithMapRoute(<T.Small>{map.title}</T.Small>)),
Expand All @@ -173,6 +177,16 @@ const MapListTable = observer((props: { store: MapListStore; presenter: MapListP
React.memo(() =>
wrapWithMapRoute(<T.Small>{formatDate(map.submissionDate)}</T.Small>, styles.centeredCell)
),
React.memo(() =>
store.enableBulkSelect ? (
<div
className={classNames(styles.selectBox, { [styles.selectBoxChecked]: selected })}
onClick={onSelect}
>
{selected && <Check />}
</div>
) : null
),
],
};
};
Expand Down
13 changes: 8 additions & 5 deletions src/app/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
import { ActiveFilterPills, FilterBuilder } from 'app/filter_builder';
import { MapListPresenter, MapListStore } from 'app/map_list_presenter';
import classNames from 'classnames';
import { Download, Filter, Search as SearchIcon } from 'lucide-react';
import { action } from 'mobx';
import { observer } from 'mobx-react-lite';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { encodeFilter } from 'schema/map_filter';
import { Button } from 'ui/base/button/button';
import { filterIcon } from 'ui/base/icons/filter_icon';
import { searchIcon } from 'ui/base/icons/search_icon';
import { T } from 'ui/base/text/text';
import { Textbox } from 'ui/base/textbox/textbox';
import styles from './search.module.css';
Expand Down Expand Up @@ -50,14 +49,16 @@ export const Search = observer((props: { store: MapListStore; presenter: MapList
})}
onClick={action(() => (store.filtersExpanded = !store.filtersExpanded))}
>
{filterIcon}
<Filter />
</button>
);

const BulkSelectActions = observer(() => {
return store.enableBulkSelect ? (
<>
<Button onClick={presenter.onClickBulkDownload}>⭳ {store.selectedMaps.size}</Button>
<Button onClick={presenter.onClickBulkDownload}>
<Download /> {store.selectedMaps.size}
</Button>
<Button onClick={presenter.onClickCancelBulkSelect}>Cancel</Button>
</>
) : (
Expand All @@ -79,7 +80,9 @@ export const Search = observer((props: { store: MapListStore; presenter: MapList
onSubmit={onSearch}
trailing={filterToggle}
/>
<Button onClick={onSearch}>{searchIcon} Search</Button>
<Button onClick={onSearch}>
<SearchIcon /> Search
</Button>
<BulkSelectActions />
</div>
{!store.filtersExpanded && <ActiveFilterPills store={store} onSearch={onSearch} />}
Expand Down
4 changes: 4 additions & 0 deletions src/ui/base/button/button.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,7 @@
.trailingIcon {
margin-left: var(--gridBaseline);
}

.successCheck {
vertical-align: middle;
}
29 changes: 20 additions & 9 deletions src/ui/base/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
'use client';

import { Button as BaseButton } from '@base-ui/react/button';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import React from 'react';
import { Button as AriaButton } from 'react-aria-components';
import { T } from 'ui/base/text/text';
import styles from './button.module.css';
import loadingStyles from './loading.module.css';

type ButtonStyle = 'regular' | 'active' | 'error' | 'success';

export type ButtonProps = {
export type ButtonProps = React.AriaAttributes & {
className?: string;
style?: ButtonStyle;
link?: string;
Expand All @@ -28,7 +29,16 @@ const styleClassname: Record<ButtonStyle, string> = {
};

export const Button = (props: ButtonProps) => {
const { className, style = 'regular', link, loading, disabled, onClick, children } = props;
const {
className,
style = 'regular',
link,
loading,
disabled,
onClick,
children,
...rest
} = props;
const isDisabled = disabled || loading || false;

return link ? (
Expand All @@ -44,22 +54,23 @@ export const Button = (props: ButtonProps) => {
target="_blank"
>
<T.Medium>
{style === 'success' ? '✔' : null} {children}
{style === 'success' ? <Check className={styles.successCheck} /> : null} {children}
</T.Medium>
</a>
</div>
) : (
<AriaButton
isDisabled={isDisabled}
onPress={() => onClick?.()}
<BaseButton
{...rest}
disabled={isDisabled}
onClick={() => onClick?.()}
className={classNames(className, styleClassname[style], styles.button, {
[styles.disabled]: isDisabled,
[styles.loading]: loading,
})}
>
<T.Medium className={styles.buttonText}>
{children}
{style === 'success' ? ' ✔' : null}
{style === 'success' ? <Check className={styles.successCheck} /> : null}
</T.Medium>
{loading ? (
<div className={classNames(loadingStyles.laBallPulse, loadingStyles.laSm)}>
Expand All @@ -69,6 +80,6 @@ export const Button = (props: ButtonProps) => {
<div></div>
</div>
) : undefined}
</AriaButton>
</BaseButton>
);
};
9 changes: 5 additions & 4 deletions src/ui/base/dialog/dialog.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
bottom: 0;

background-color: var(--colorDialogOverlay);
display: flex;
justify-content: center;
align-items: center;
}

.dialog {
position: relative;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);

background-color: var(--colorBackground);
padding: calc(var(--gridBaseline) * 2);
border: 1px dotted var(--colorForeground);
Expand Down
Loading
Loading