feat: start of popup

This commit is contained in:
TrèsAbhi committed 2026-01-17 13:36:17 -06:00
1 parent b50c47b744
commit 79b4f05809
8 files changed
+225 -19

No files matched your search

+2 -1
View File
@@ -49,6 +49,7 @@
},
"avatars": {
"search": "Search avatars..."
"search": "Search avatars...",
"no_description": "Avatar has no description."
}
}
@@ -1,15 +1,15 @@
.avatar-card {
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
.avatar-series {
.series {
position: relative;
width: 100%;
aspect-ratio: 7 / 8;
.avatar-entry {
.entry {
position: absolute;
border-radius: var(--space-3);
@@ -30,10 +30,12 @@
position: absolute;
bottom: var(--space-3);
right: var(--space-3);
box-shadow: var(--shadow-aggressive);
}
}
.avatar-label {
.label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
@@ -45,3 +47,34 @@
}
}
}
.details {
display: flex;
gap: var(--space-4);
align-items: center;
.preview {
display: flex;
flex-direction: column;
align-items: center;
.image {
aspect-ratio: 7 / 8;
width: 8rem;
background-size: cover;
background-position: center;
}
.grade {
margin-top: calc(var(--space-3) * -1);
}
}
.description {
display: flex;
flex-direction: column;
max-width: 20rem;
}
}
@@ -1,12 +1,15 @@
import { literals } from "@blitzkit/i18n";
import { Grade } from "@protos/blitz_static_standard_grades_enum";
import { useGameStrings } from "../../hooks/useGameStrings";
import { useStrings } from "../../hooks/useStrings";
import type { Avatar } from "../../protos/avatar";
import type { PropsWithSkeleton } from "../../types/propsWithSkeleton";
import { Badge } from "../Badge";
import { Dialog } from "../Dialog";
import { InlineSkeleton } from "../InlineSkeleton";
import { Skeleton } from "../Skeleton";
import { Text } from "../Text";
import "./index.css";
import styles from "./index.module.css";
interface Props {
name: string;
@@ -17,15 +20,68 @@ const MAX_CARDS = 3;
const CARD_TRIM = "var(--space-3)";
export function AvatarCard(props: PropsWithSkeleton<Props>) {
return (
<div className="avatar-card">
const gameStrings = useGameStrings("ProfileAvatarEntity");
const strings = useStrings();
const card = (
<div className={styles.card}>
<Series skeleton={props.skeleton} {...props} />
<Text align="center" className="avatar-label">
<Text align="center" className={styles.label}>
{props.skeleton ? <InlineSkeleton /> : props.name}
</Text>
</div>
);
if (props.skeleton) return card;
return (
<Dialog.Root>
<Dialog.Trigger>{card}</Dialog.Trigger>
<Dialog.Content>
<Text size="major" weight="bold">
{props.name}
</Text>
{[...props.avatars].reverse().map((avatar) => {
const description = gameStrings[avatar.stuff_ui!.description];
const obtaining = gameStrings[avatar.stuff_ui!.obtaining_methods];
const hasDescription = description || obtaining;
return (
<div className={styles.details}>
<div
className={styles.preview}
data-grade={avatar.stuff_ui!.grade}
>
<div
className={styles.image}
style={{
backgroundImage: `url(/api/avatars/${avatar.name}.webp)`,
}}
/>
<Badge className={styles.grade}>
{Grade[avatar.stuff_ui!.grade]}
</Badge>
</div>
<div className={styles.description}>
<Text>
{hasDescription ? (
<>
{description} {obtaining}
</>
) : (
strings.avatars.no_description
)}
</Text>
</div>
</div>
);
})}
</Dialog.Content>
</Dialog.Root>
);
}
interface SeriesProps {
@@ -37,12 +93,12 @@ function Series(props: PropsWithSkeleton<SeriesProps>) {
const strings = useStrings();
return (
<div className="avatar-series">
<div className={styles.series}>
{!props.skeleton &&
props.avatars.slice(-MAX_CARDS).map((avatar, index) => (
<div
key={avatar.name}
className="avatar-entry"
className={styles.entry}
style={{
filter: `brightness(${(index + 1) / count})`,
top: `calc(${index} * ${CARD_TRIM})`,
@@ -55,12 +111,12 @@ function Series(props: PropsWithSkeleton<SeriesProps>) {
))}
{!props.skeleton && props.avatars.length > 1 && (
<Badge color="gray" highContrast className="count">
<Badge color="gray" highContrast className={styles.count}>
{literals(strings.units.x, { value: props.avatars.length })}
</Badge>
)}
{props.skeleton && <Skeleton className="avatar-entry" />}
{props.skeleton && <Skeleton className={styles.entry} />}
</div>
);
}
@@ -1,5 +1,9 @@
.avatars-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
gap: var(--space-5) var(--space-4);
grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
@media (min-width: 32rem) {
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
}
}
@@ -18,13 +18,13 @@ export function AvatarsList() {
const locale = useLocale();
const search = Avatars.use((state) => state.search);
const { avatars } = useAwait(() => api.avatars(), "avatars");
const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity");
const gameStrings = useGameStrings("ProfileAvatarEntity");
const groups = useMemo(() => {
const groups = new Map<string, Avatar[]>();
for (const avatar of avatars) {
const name =
profileAvatarEntityStrings[avatar.stuff_ui!.display_name] ??
gameStrings[avatar.stuff_ui!.display_name] ??
avatar.stuff_ui!.display_name;
if (groups.has(name)) {
@@ -75,8 +75,8 @@ export function AvatarsList() {
return (
<div className="avatars-list">
<IncrementalLoader
initial={9 * 5}
intermediate={9 * 3}
initial={7 * 5}
intermediate={7 * 3}
data={filtered}
Component={AvatarCard}
/>
@@ -1,6 +1,5 @@
.badge {
border-radius: var(--max-size);
box-shadow: var(--shadow-aggressive);
padding: var(--space-2) var(--space-3);
@@ -0,0 +1,33 @@
.trigger {
display: contents;
cursor: pointer;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100dvh;
background-color: var(--black-a9);
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
.content {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 25rem;
background-color: var(--gray-3);
padding: var(--space-4);
box-shadow: var(--shadow-interactive), var(--shadow-aggressive);
border-radius: var(--space-3);
}
}
@@ -0,0 +1,80 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import styles from "./index.module.css";
const DialogContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
function useDialog() {
const context = useContext(DialogContext);
if (context === null) {
throw new Error("Dialog components must be used within a Dialog.Root");
}
return context;
}
interface RootProps {
children: ReactNode;
}
function Root({ children }: RootProps) {
const [open, setOpen] = useState(false);
return (
<DialogContext.Provider value={{ open, setOpen }}>
{children}
</DialogContext.Provider>
);
}
interface TriggerProps {
children: ReactNode;
}
function Trigger({ children }: TriggerProps) {
const { setOpen } = useDialog();
return (
<div
className={styles.trigger}
onClick={() => {
setOpen(true);
}}
>
{children}
</div>
);
}
interface ContentProps {
children: ReactNode;
}
function Content({ children }: ContentProps) {
const { open, setOpen } = useDialog();
if (!open) return null;
return (
<div
className={styles.overlay}
onClick={() => {
setOpen(false);
}}
>
<div
className={styles.content}
onClick={(event) => {
event.stopPropagation();
}}
>
{children}
</div>
</div>
);
}
export const Dialog = { Root, Trigger, Content };