Compare commits

...

2 Commits

Author SHA1 Message Date
MasterGordon e8d1a8afde moved to motion.dev 2025-06-14 04:37:10 +02:00
MasterGordon 781d80f5e5 added basic profile 2025-06-14 03:21:36 +02:00
23 changed files with 274 additions and 102 deletions

View File

@ -20,7 +20,7 @@ import { getWeight, lootboxes } from "../../shared/lootboxes";
import { weightedPickRandom } from "../../shared/utils";
import { emit } from "../events";
import { Game } from "../schema";
import { and, eq, gt } from "drizzle-orm";
import { and, count, eq, gt, max, not, sum } from "drizzle-orm";
import dayjs from "dayjs";
const secret = process.env.SECRET!;
@ -185,11 +185,7 @@ export const userController = createController({
}),
async ({ id }, { db }) => {
const now = dayjs();
const firstOfYear = now
.set("day", 0)
.set("month", 0)
.set("hour", 0)
.set("minute", 0);
const firstOfYear = now.startOf("year");
const gamesOfUser = await db.query.Game.findMany({
where: and(eq(Game.user, id), gt(Game.finished, firstOfYear.valueOf())),
});
@ -203,4 +199,34 @@ export const userController = createController({
return heat;
},
),
getProfile: createEndpoint(
z.object({
id: z.string(),
}),
async ({ id }, { db }) => {
const [{ value: totalGames }] = await db
.select({
value: count(),
})
.from(Game)
.where(and(eq(Game.user, id), not(eq(Game.finished, 0))));
const [{ value: highestStage }] = await db
.select({
value: max(Game.stage),
})
.from(Game)
.where(and(eq(Game.user, id), not(eq(Game.finished, 0))));
const [{ value: totalStages }] = await db
.select({
value: sum(Game.stage),
})
.from(Game)
.where(and(eq(Game.user, id), not(eq(Game.finished, 0))));
return {
totalGames,
highestStage,
averageStage: Number(totalStages) / totalGames,
};
},
),
});

1
bad
View File

@ -1 +0,0 @@
f7360d42-dcc1-4e1e-a90b-ef2295298872

BIN
bun.lockb

Binary file not shown.

View File

@ -23,6 +23,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.59.11",
"@tanstack/react-query-devtools": "^5.59.11",
"@tsparticles/engine": "^3.5.0",
@ -34,9 +35,9 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"drizzle-orm": "0.33.0",
"framer-motion": "^11.11.8",
"jotai": "^2.10.0",
"lucide-react": "^0.452.0",
"motion": "^12.18.1",
"pixi-viewport": "^5.0.3",
"pixi.js": "^7.0.0",
"pixi.js-legacy": "^7.4.2",
@ -68,6 +69,7 @@
"typescript": "^5.6.3",
"typescript-eslint": "^8.8.1",
"vite": "^5.4.8",
"vite-bundle-analyzer": "^0.22.3",
"vite-imagetools": "^7.0.4"
}
}

View File

@ -1,6 +1,6 @@
import { type PropsWithChildren, useEffect, useRef, useState } from "react";
import { Button } from "./components/Button";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import {
GitBranch,
History,

View File

@ -10,8 +10,8 @@ import {
type LoadedTexture,
type LoadedTheme,
type Theme,
useTheme,
} from "../themes/Theme";
import { useTheme } from "../themes/useTheme";
import { Container, Sprite, Stage, useTick } from "@pixi/react";
import Viewport from "./pixi/PixiViewport";
import type { Viewport as PixiViewport } from "pixi-viewport";

View File

@ -1,4 +1,4 @@
import { animate, motion } from "framer-motion";
import { animate, motion } from "motion/react";
import { useRef } from "react";
const BounceImg = ({ src, className }: { src: string; className?: string }) => {

View File

@ -1,4 +1,4 @@
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { useAtom } from "jotai";
import { feedItemsAtom, lootboxResultAtom } from "../../atoms";
import FeedItemElement from "./FeedItem";

View File

@ -1,4 +1,4 @@
import { motion } from "framer-motion";
import { motion } from "motion/react";
import type { PropsWithChildren } from "react";
import { formatTimeSpan } from "../../../shared/time";
import GemsIcon from "../GemIcon";

View File

@ -0,0 +1,3 @@
import { lazy } from "react";
export const Board = lazy(() => import("./Board"));

View File

@ -9,8 +9,8 @@ interface PastMatchProps {
const PastMatch = ({ game }: PastMatchProps) => {
return (
<div className="flex flex-col gap-4 items-center w-full">
<div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-4 p-4">
<div className="flex flex-col gap-4 items-center w-full @container">
<div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-4 @max-xl:grid-cols-3 p-4">
<div className="flex-col flex">
<div className="text-white/90 text-lg">Endless</div>
<div className="text-white/50 text-lg">
@ -24,7 +24,7 @@ const PastMatch = ({ game }: PastMatchProps) => {
{game.minesCount - game.isFlagged.flat().filter((f) => f).length}
</div>
</div>
<div className="text-white/80 text-lg">
<div className="text-white/80 text-lg @max-xl:hidden">
<div>Duration: {formatTimeSpan(game.finished - game.started)}</div>
</div>
<div className="flex justify-end">

View File

@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "../lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -1,20 +1,36 @@
@import "tailwindcss";
@theme {
--color-primary: #D9AFD9;
--color-primary: #d9afd9;
--color-input: color-mix(in srgb, var(--color-white, #fff) 20%, transparent);
--color-background: black;
--color-common: var(--color-sky-500);
--color-uncommon: var(--color-green-400);
--color-rare: var(--color-red-500);
--color-legendary: var(--color-amber-500);
--bg-brand: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
rgb(21, 198, 251)) 0% 0% / 100% 300%;
--bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%;
--bg-brand: -webkit-linear-gradient(
225deg,
rgb(251, 175, 21),
rgb(251, 21, 242),
rgb(21, 198, 251)
)
0% 0% / 100% 300%;
--bg-secondary: linear-gradient(90deg, #d9afd9 0%, #97d9e1 100%) 0% 0% / 100%
300%;
--animate-gradientmove: gradientmove 1s ease 0s 1 normal forwards;
@keyframes gradientmove {
0%{background-position: 0% 0%}
100%{background-position: 0% 100%}
0% {
background-position: 0% 0%;
}
100% {
background-position: 0% 100%;
}
}
}
@layer components {
.bg-brand {
background: var(--bg-brand);
}
}
@ -22,7 +38,7 @@ button {
cursor: pointer;
}
.grid-border-b div:not(:nth-last-child(-n+3)) {
.grid-border-b div:not(:nth-last-child(-n + 3)) {
@apply border-b border-white/10;
}

View File

@ -12,7 +12,7 @@ import Home from "./views/home/Home.tsx";
import Settings from "./views/settings/Settings.tsx";
import MatchHistory from "./views/match-history/MatchHistory.tsx";
import Collection from "./views/collection/Collection.tsx";
import { AnimatePresence } from "framer-motion";
import { AnimatePresence } from "motion/react";
import Store from "./views/store/Store.tsx";
import Profile from "./views/profile/Profile.tsx";

View File

@ -1,10 +1,9 @@
import { Assets, Texture } from "pixi.js";
import { useEffect, useState } from "react";
import type { Texture } from "pixi.js";
type Png = typeof import("*.png");
type LazySprite = () => Promise<Png>;
interface WeightedLazySprites {
export interface WeightedLazySprites {
weight: number;
sprite: LazySprite;
}
@ -54,36 +53,3 @@ export const mainWithSpecials = (
...specials.map((sprite) => ({ weight: 0.05, sprite })),
];
};
export const useTheme = (theme: Theme) => {
const [loadedTheme, setLoadedTheme] = useState<LoadedTheme | undefined>(
undefined
);
useEffect(() => {
const loadTheme = async () => {
const loadedEntries = await Promise.all(
Object.entries(theme).map(async ([key, value]) => {
let loaded = value;
if (typeof value === "function") {
loaded = await Assets.load((await value()).default);
}
if (Array.isArray(value)) {
loaded = await Promise.all(
loaded.map(async (sprite: WeightedLazySprites) => {
return {
weight: sprite.weight,
sprite: await Assets.load((await sprite.sprite()).default),
};
})
);
}
return [key, loaded] as const;
})
);
setLoadedTheme(Object.fromEntries(loadedEntries) as LoadedTheme);
};
loadTheme();
}, [theme]);
return loadedTheme;
};

36
src/themes/useTheme.ts Normal file
View File

@ -0,0 +1,36 @@
import { Assets } from "pixi.js";
import { useState, useEffect } from "react";
import type { Theme, LoadedTheme, WeightedLazySprites } from "./Theme";
export const useTheme = (theme: Theme) => {
const [loadedTheme, setLoadedTheme] = useState<LoadedTheme | undefined>(
undefined,
);
useEffect(() => {
const loadTheme = async () => {
const loadedEntries = await Promise.all(
Object.entries(theme).map(async ([key, value]) => {
let loaded = value;
if (typeof value === "function") {
loaded = await Assets.load((await value()).default);
}
if (Array.isArray(value)) {
loaded = await Promise.all(
loaded.map(async (sprite: WeightedLazySprites) => {
return {
weight: sprite.weight,
sprite: await Assets.load((await sprite.sprite()).default),
};
}),
);
}
return [key, loaded] as const;
}),
);
setLoadedTheme(Object.fromEntries(loadedEntries) as LoadedTheme);
};
loadTheme();
}, [theme]);
return loadedTheme;
};

View File

@ -1,6 +1,6 @@
import { Ellipsis } from "lucide-react";
import { testBoard } from "../../../shared/testBoard";
import Board from "../../components/Board";
import { Board } from "../../components/LazyBoard";
import { Button } from "../../components/Button";
import { themes } from "../../themes";
import {

View File

@ -1,10 +1,10 @@
import Board from "../../components/Board";
import { useWSMutation, useWSQuery } from "../../hooks";
import { useAtom } from "jotai";
import { gameIdAtom } from "../../atoms";
import { Button } from "../../components/Button";
import LeaderboardButton from "../../components/LeaderboardButton";
import { Fragment, useEffect } from "react";
import { Board } from "../../components/LazyBoard";
interface EndlessProps {
gameId?: string;

View File

@ -1,4 +1,4 @@
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { useEffect } from "react";
import { useWSQuery } from "../../hooks";
import { Tag } from "../../components/Tag";

View File

@ -4,7 +4,7 @@ import {
useMotionTemplate,
useScroll,
useTransform,
} from "framer-motion";
} from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "../../lib/utils";
@ -48,8 +48,10 @@ const Section = ({ text, image, left }: SectionProps) => {
className="md:w-[50%] h-90"
// float up and down
animate={{
translateY: [0, 10, 0],
translateX: [0, 5, 0],
// translate: ["0 0", "5 10", "0 0"],
// transform: ["translate"]
x: [0, 10, 0],
y: [0, 5, 0],
}}
transition={{
repeat: Infinity,
@ -71,7 +73,7 @@ const Section = ({ text, image, left }: SectionProps) => {
translateY,
}}
transition={{
type: "just",
type: "spring",
delay: 0.5,
}}
srcSet={image.map((i) => `${i.src} ${i.width}w`).join(", ")}

View File

@ -1,36 +1,92 @@
import { useMemo } from "react";
import { useWSQuery } from "../../hooks";
import dayjs from "dayjs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../../components/Tooltip";
import PastMatch from "../../components/PastMatch";
const Profile: React.FC = () => {
const { data: heatmap } = useWSQuery("user.getHeatmap", { id: "Gordon" });
const now = useMemo(() => dayjs(), []);
const firstOfYear = useMemo(
() => now.set("day", 0).set("month", 0).set("hour", 0).set("minute", 0),
[now],
const { data: username } = useWSQuery("user.getSelf", null);
const { data: heatmap } = useWSQuery(
"user.getHeatmap",
{ id: username! },
!!username,
);
const { data: profile } = useWSQuery(
"user.getProfile",
{ id: username! },
!!username,
);
const { data: pastGames } = useWSQuery(
"game.getGames",
{
user: username!,
page: 0,
},
!!username,
);
const now = useMemo(() => dayjs(), []);
const firstOfYear = useMemo(() => now.startOf("year"), [now]);
const weeks = now.diff(firstOfYear, "weeks") + 1;
const maxHeat = heatmap ? Math.max(...heatmap) : 0;
return (
<div>
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6">
<div className="m-8 text-white flex self-center">
<div className="p-2 flex items-center text-2xl">{username}</div>
<div className="border-l-white border-l p-2 text-lg">
<p>Total Games: {profile?.totalGames}</p>
<p>Highest Stage: {profile?.highestStage}</p>
<p>
Average Stage: {Math.round(profile?.averageStage ?? 1 * 100) / 100}
</p>
</div>
</div>
<div className="flex flex-col gap-4">
{pastGames?.data
.slice(0, 4)
.map((game) => <PastMatch key={game.uuid} game={game} />)}
</div>
{heatmap && (
<div className="flex gap-2">
<div className="col-span-full">
<h2 className="text-white text-2xl font-semibold mb-4">Activity</h2>
<div className="flex gap-2 ">
{Array.from({ length: weeks }).map((_, w) => (
<div key={w} className="w-4 flex gap-2 flex-col">
{Array.from({ length: 7 }).map((_, d) => (
<div key={d} className="w-4 h-4 border border-white">
<div key={w} className="w-6 flex gap-2 flex-col">
{Array.from({ length: 7 }).map((_, d) => {
const index = w * 7 + d;
if (index >= heatmap.length) return;
return (
<Tooltip key={d}>
<TooltipTrigger>
<div className="w-5 h-5 border border-white">
<div
className="w-4 h-4 bg-purple-600 -m-px"
className="w-5 h-5 bg-brand -m-px"
style={{
opacity: heatmap[w * 7 + d] / maxHeat,
opacity: heatmap[index] / maxHeat,
}}
/>
</div>
))}
</TooltipTrigger>
<TooltipContent>
<p>
{firstOfYear
.clone()
.add(index, "days")
.format("DD/MM/YYYY")}
</p>
<p>{heatmap[index]} Games Played</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
</div>
</div>
)}
</div>
);

View File

@ -16,9 +16,7 @@ import { lootboxResultAtom } from "../../atoms";
import { useAtom } from "jotai";
import { useEffect } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import { loadSeaAnemonePreset } from "@tsparticles/preset-sea-anemone";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import BounceImg from "../../components/BounceImg";
const Store = () => {
@ -29,6 +27,11 @@ const Store = () => {
// this should be run only once per application lifetime
useEffect(() => {
const cb = async () => {
const { loadSlim } = await import("@tsparticles/slim");
const { loadSeaAnemonePreset } = await import(
"@tsparticles/preset-sea-anemone"
);
initParticlesEngine(async (engine) => {
// you can initiate the tsParticles instance (engine) here, adding custom shapes or presets
// this loads the tsparticles package bundle, it's the easiest method for getting everything ready
@ -40,6 +43,8 @@ const Store = () => {
//await loadBasic(engine);
});
};
cb();
}, []);
return (

View File

@ -2,11 +2,12 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
import { imagetools } from "vite-imagetools";
import { analyzer } from "vite-bundle-analyzer";
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 3003,
},
plugins: [react(), tailwindcss(), imagetools()],
plugins: [react(), tailwindcss(), imagetools(), analyzer()],
});