Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,58 @@
.badges {
display: flex;
min-width: 20rem;
max-width: 20rem;
min-height: 12rem;
align-items: center;
padding: var(--s-2);
border-radius: var(--radius-box);
background-color: var(--color-bg-badge);
margin-inline: auto;
}
.badgesCompact {
min-width: 0;
max-width: 100%;
min-height: 0;
flex-wrap: wrap;
justify-content: center;
}
.smallBadges {
display: grid;
place-items: center;
grid-template-columns: repeat(3, 1fr);
margin: 0 auto;
cursor: pointer;
gap: var(--s-3);
}
.badgeExplanation {
color: var(--color-text-high);
font-size: var(--font-xs);
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2);
margin-block-end: var(--s-1);
}
.smallBadgeContainer {
position: relative;
}
.smallBadgeCount {
position: absolute;
top: 0;
right: 0;
margin-top: -8px;
margin-right: auto;
margin-left: auto;
color: var(--color-accent-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
}
.pagination {
margin-block-start: var(--s-2);
}

View File

@@ -0,0 +1,127 @@
import clsx from "clsx";
import { Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "~/components/Badge";
import { DotPagination } from "~/components/DotPagination";
import { SendouButton } from "~/components/elements/Button";
import type { Tables } from "~/db/tables";
import { BADGE } from "~/features/badges/badges-constants";
import { usePagination } from "~/hooks/usePagination";
import type { Unpacked } from "~/utils/types";
import { badgeExplanationText } from "../badges-utils";
import styles from "./BadgeDisplay.module.css";
export interface BadgeDisplayProps {
badges: Array<Omit<Tables["Badge"], "authorId"> & { count?: number }>;
onChange?: (badgeIds: number[]) => void;
children?: React.ReactNode;
showText?: boolean;
/** Fit inside tight containers (e.g. a popover) instead of the fixed 20rem box */
compact?: boolean;
className?: string;
}
export function BadgeDisplay({
badges: _badges,
onChange,
children,
showText = true,
compact = false,
className,
}: BadgeDisplayProps) {
const { t } = useTranslation("badges");
const [badges, setBadges] = React.useState(_badges);
const [bigBadge, ...smallBadges] = badges;
const isPaginated = !onChange;
const {
itemsToDisplay,
everythingVisible,
currentPage,
pagesCount,
setPage,
} = usePagination({
items: smallBadges,
pageSize: isPaginated ? BADGE.SMALL_BADGES_PER_DISPLAY_PAGE : 1000,
scrollToTop: false,
});
if (!bigBadge) return null;
const setBadgeFirst = (badge: Unpacked<BadgeDisplayProps["badges"]>) => {
const newBadges = badges.map((b, i) => {
if (i === 0) return badge;
if (b.id === badge.id) return badges[0];
return b;
});
setBadges(newBadges);
onChange?.(newBadges.map((b) => b.id));
};
return (
<div data-testid="badge-display">
{isPaginated && showText ? (
<div className={styles.badgeExplanation}>
{badgeExplanationText(t, bigBadge)}
</div>
) : null}
<div
className={clsx(className, styles.badges, {
[styles.badgesCompact]: compact,
"justify-center": smallBadges.length === 0,
})}
>
<Badge badge={bigBadge} size={125} isAnimated />
{!children && smallBadges.length > 0 ? (
<div className={styles.smallBadges}>
{itemsToDisplay.map((badge) => (
<div key={badge.id} className={styles.smallBadgeContainer}>
<Badge
badge={badge}
onClick={() => setBadgeFirst(badge)}
size={48}
isAnimated
/>
{badge.count && badge.count > 1 ? (
<div className={styles.smallBadgeCount}>×{badge.count}</div>
) : null}
</div>
))}
</div>
) : null}
{children}
</div>
{!isPaginated ? (
<div className={styles.badgeExplanation}>
{badgeExplanationText(t, bigBadge)}
{onChange ? (
<SendouButton
icon={<Trash />}
variant="minimal-destructive"
onPress={() =>
onChange(
badges.filter((b) => b.id !== bigBadge.id).map((b) => b.id),
)
}
/>
) : null}
</div>
) : null}
{!everythingVisible ? (
<DotPagination
pagesCount={pagesCount}
currentPage={currentPage}
setPage={setPage}
ariaLabelPrefix="Badges"
data-testid="badge-pagination-button"
className={styles.pagination}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useTranslation } from "react-i18next";
import {
BadgeDisplay,
type BadgeDisplayProps,
} from "~/features/badges/components/BadgeDisplay";
export function BadgesSelector({
options,
selectedBadges,
onChange,
onBlur,
children,
maxCount,
showSelect = true,
disabled,
}: {
options: BadgeDisplayProps["badges"];
selectedBadges: number[];
onChange: (newBadges: number[]) => void;
onBlur?: () => void;
children?: React.ReactNode;
maxCount?: number;
showSelect?: boolean;
disabled?: boolean;
}) {
const { t } = useTranslation(["common"]);
return (
<div className="stack md">
{selectedBadges.length > 0 ? (
<BadgeDisplay
badges={options
.filter((badge) => selectedBadges.includes(badge.id))
.sort((a, b) => {
const aIdx = selectedBadges.indexOf(a.id);
const bIdx = selectedBadges.indexOf(b.id);
return aIdx - bIdx;
})}
onChange={disabled ? undefined : onChange}
key={selectedBadges.join(",")}
>
{children}
</BadgeDisplay>
) : (
<div className="text-lighter text-md font-bold">
{t("common:badges.selector.none")}
</div>
)}
{showSelect ? (
options.length === 0 ? (
<div className="text-warning text-xs">
{t("common:badges.selector.noneAvailable")}
</div>
) : (
<select
onBlur={() => onBlur?.()}
onChange={(e) =>
onChange([...selectedBadges, Number(e.target.value)])
}
disabled={
disabled || Boolean(maxCount && selectedBadges.length >= maxCount)
}
data-testid="badges-selector"
>
<option>{t("common:badges.selector.select")}</option>
{options
.filter((badge) => !selectedBadges.includes(badge.id))
.map((badge) => (
<option key={badge.id} value={badge.id}>
{badge.displayName}
</option>
))}
</select>
)
) : null}
</div>
);
}