glitch effect avatar

This commit is contained in:
yohlo
2025-10-04 18:41:46 -05:00
parent 1ef786ea79
commit 95a50ee7a7
10 changed files with 303 additions and 23 deletions

View File

@@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(10, new Field({
"hidden": false,
"id": "file538556518",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "glitch_logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("file538556518")
return app.save(collection)
})

View File

@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// update field
collection.fields.addAt(10, new Field({
"hidden": false,
"id": "file538556518",
"maxSelect": 1,
"maxSize": 6000000,
"mimeTypes": [],
"name": "glitch_logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// update field
collection.fields.addAt(10, new Field({
"hidden": false,
"id": "file538556518",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "glitch_logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
})

View File

@@ -0,0 +1,179 @@
import { useState, useEffect, useRef } from "react";
import { Paper, Box } from "@mantine/core";
import {
Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps,
} from "@mantine/core";
interface GlitchAvatarProps
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
name: string;
src?: string;
glitchSrc?: string;
size?: number;
radius?: string | number;
withBorder?: boolean;
contain?: boolean;
children?: React.ReactNode;
px?: string | number;
}
const GlitchAvatar = ({
name,
src,
glitchSrc,
size = 35,
radius = "100%",
withBorder = true,
contain = false,
children,
px,
...props
}: GlitchAvatarProps) => {
const [showGlitch, setShowGlitch] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (!glitchSrc) return;
const scheduleNextGlitch = () => {
const delay = Math.random() * 10000 + 5000;
return setTimeout(() => {
setShowGlitch(true);
setIsPlaying(true);
setTimeout(() => {
setShowGlitch(false);
setIsPlaying(false);
scheduleNextGlitch();
}, 4000);
}, delay);
};
const timeoutId = scheduleNextGlitch();
return () => clearTimeout(timeoutId);
}, [glitchSrc]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleEnded = () => {
setShowGlitch(false);
setIsPlaying(false);
};
video.addEventListener("ended", handleEnded);
return () => video.removeEventListener("ended", handleEnded);
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.load();
}, [glitchSrc]);
useEffect(() => {
const video = videoRef.current;
if (!video || !showGlitch || !isPlaying) return;
video.currentTime = 0;
video.play().catch((err) => {
console.error("Failed to play glitch", err);
});
}, [showGlitch, isPlaying]);
return (
<Box
style={{
padding: "8px",
borderRadius:
typeof radius === "number"
? `${radius + 8}px`
: "calc(var(--mantine-radius-md) + 8px)",
position: "relative",
}}
>
<Box
style={{
opacity: showGlitch ? 0 : 1,
transition: "opacity 0.05s ease-in-out",
}}
>
<Paper
py={size / 12.5}
px={size / 20}
bg="var(--mantine-color-default-border)"
radius={radius}
withBorder={false}
style={{
cursor: "default",
}}
>
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
radius={radius}
w={size}
styles={{
image: {
objectFit: contain ? "contain" : "cover",
},
}}
src={src}
{...props}
>
{children}
</MantineAvatar>
</Paper>
</Box>
{glitchSrc && (
<Box
style={{
position: "absolute",
top: "8px",
left: "8px",
opacity: showGlitch ? 1 : 0,
visibility: showGlitch ? "visible" : "hidden",
transition: "opacity 0.05s ease-in-out",
pointerEvents: "none",
}}
>
<Paper
py={size / 12.5}
px={size / 20}
bg="var(--mantine-color-default-border)"
radius={radius}
withBorder={false}
style={{
overflow: "hidden",
}}
>
<video
ref={videoRef}
src={glitchSrc}
style={{
width: `${size}px`,
height: `${size}px`,
objectFit: contain ? "contain" : "cover",
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
display: "block",
}}
muted
playsInline
preload="auto"
/>
</Paper>
</Box>
)}
</Box>
);
};
export default GlitchAvatar;

View File

@@ -1,4 +1,6 @@
import GlitchAvatar from '@/components/glitch-avatar';
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
import { useCurrentTournament } from '@/features/tournaments/queries';
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react';
@@ -8,6 +10,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
const visualViewport = useVisualViewportSize();
const viewport = useViewportSize();
const { data: tournament } = useCurrentTournament();
return (
<AppShell>
@@ -31,8 +34,27 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
radius='md'
>
<Stack align='center' gap='xs' mb='md'>
<TrophyIcon size={75} />
<Title order={1} ta='center'>Welcome to Flexxon</Title>
<GlitchAvatar
name={tournament.name}
contain
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
glitchSrc={
tournament.glitch_logo
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
: undefined
}
radius="md"
size={250}
px="xs"
withBorder={false}
>
<TrophyIcon size={32} />
</GlitchAvatar>
<Title order={1} ta='center'>Welcome to FLXN</Title>
</Stack>
{children}
</Paper>

View File

@@ -11,7 +11,7 @@ const Header = ({ tournament }: HeaderProps) => {
return (
<>
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar contain name={tournament.name} radius={0} withBorder={false} size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
<Avatar contain name={tournament.name} radius="sm" size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={1}>{tournament.name}</Title>
</Flex>

View File

@@ -1,12 +1,8 @@
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Tournament } from "../../types";
import Avatar from "@/components/avatar";
import {
CalendarIcon,
MapPinIcon,
TrophyIcon,
} from "@phosphor-icons/react";
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import GlitchAvatar from "@/components/glitch-avatar";
const Header = ({ tournament }: { tournament: Tournament }) => {
const tournamentStart = useMemo(
@@ -16,7 +12,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
return (
<Stack px="sm" align="center" gap={0}>
<Avatar
<GlitchAvatar
name={tournament.name}
contain
src={
@@ -24,13 +20,18 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
glitchSrc={
tournament.glitch_logo
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
: undefined
}
radius="md"
size={200}
size={250}
px="xs"
withBorder={false}
>
<TrophyIcon size={24} />
</Avatar>
<TrophyIcon size={32} />
</GlitchAvatar>
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
{tournament.location && (
<Group gap="xs">
@@ -65,4 +66,4 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
);
};
export default Header;
export default Header;

View File

@@ -28,13 +28,13 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<Text size="md">
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
Enrolling as a free agent adds you to a pool of players looking for teammates.
</Text>
<Text size="sm" c='dimmed'>
You will be able to see a list of other enrolled free agents, as well as their contact information for organizing your team and walkout song. By enrolling, your phone number will be visible to other free agents.
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
</Text>
<Text size="xs" c="dimmed">
Note: this does not guarantee you a spot in the tournament. One person from your team must enroll in the app and choose a walkout song in order to secure a spot.
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
</Text>
<Button onClick={handleEnroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>

View File

@@ -1,6 +1,6 @@
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Tournament } from "../../types";
import Avatar from "@/components/avatar";
import GlitchAvatar from "@/components/glitch-avatar";
import {
CalendarIcon,
MapPinIcon,
@@ -16,8 +16,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
);
return (
<Stack align="center" gap={0}>
<Avatar
<Stack align="center" gap={16}>
<GlitchAvatar
name={tournament.name}
contain
src={
@@ -25,13 +25,18 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
glitchSrc={
tournament.glitch_logo
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
: undefined
}
radius="md"
size={300}
px="xs"
withBorder={false}
>
<TrophyIcon size={32} />
</Avatar>
</GlitchAvatar>
<Flex gap="xs" direction="column" justify="space-around">
{tournament.location && (
<Group gap="xs">

View File

@@ -25,6 +25,7 @@ export interface TournamentInfo {
start_time?: string;
end_time?: string;
logo?: string;
glitch_logo?: string;
first_place?: TeamInfo;
second_place?: TeamInfo;
third_place?: TeamInfo;
@@ -37,6 +38,7 @@ export interface Tournament {
desc?: string;
rules?: string;
logo?: string;
glitch_logo?: string;
enroll_time?: string;
start_time: string;
end_time?: string;

View File

@@ -59,9 +59,7 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
}
export const transformTournamentInfo = (record: any): TournamentInfo => {
// Check if tournament is complete by looking at matches
const matches = record.expand?.matches || [];
// Filter out bye matches (tbd status with bye=true) when checking completion
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
@@ -108,6 +106,7 @@ export const transformTournamentInfo = (record: any): TournamentInfo => {
start_time: record.start_time,
end_time: record.end_time,
logo: record.logo,
glitch_logo: record.glitch_logo,
first_place,
second_place,
third_place,
@@ -258,6 +257,7 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
desc: record.desc,
rules: record.rules,
logo: record.logo,
glitch_logo: record.glitch_logo,
enroll_time: record.enroll_time,
start_time: record.start_time,
end_time: record.end_time,