mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
feat: start of popup
This commit is contained in:
1 parent
b50c47b744
commit
79b4f05809
8 files changed
+225
-19
No files matched your search
@@ -49,6 +49,7 @@
|
||||
},
|
||||
|
||||
"avatars": {
|
||||
"search": "Search avatars..."
|
||||
"search": "Search avatars...",
|
||||
"no_description": "Avatar has no description."
|
||||
}
|
||||
}
|
||||
+37
-4
@@ -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 };
|
||||
Reference in new issue
Block a user