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 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 { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks'; import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react'; import { TrophyIcon } from '@phosphor-icons/react';
@@ -8,6 +10,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const isMobile = useMediaQuery(`(max-width: ${em(450)})`); const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
const visualViewport = useVisualViewportSize(); const visualViewport = useVisualViewportSize();
const viewport = useViewportSize(); const viewport = useViewportSize();
const { data: tournament } = useCurrentTournament();
return ( return (
<AppShell> <AppShell>
@@ -31,8 +34,27 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
radius='md' radius='md'
> >
<Stack align='center' gap='xs' mb='md'> <Stack align='center' gap='xs' mb='md'>
<TrophyIcon size={75} /> <GlitchAvatar
<Title order={1} ta='center'>Welcome to Flexxon</Title> 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> </Stack>
{children} {children}
</Paper> </Paper>

View File

@@ -11,7 +11,7 @@ const Header = ({ tournament }: HeaderProps) => {
return ( return (
<> <>
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'> <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%'> <Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={1}>{tournament.name}</Title> <Title ta='center' order={1}>{tournament.name}</Title>
</Flex> </Flex>

View File

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

View File

@@ -28,13 +28,13 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}> <Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Stack gap="xs"> <Stack gap="xs">
<Text size="md"> <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>
<Text size="sm" c='dimmed'> <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>
<Text size="xs" c="dimmed"> <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> </Text>
<Button onClick={handleEnroll}>Confirm</Button> <Button onClick={handleEnroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</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 { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Tournament } from "../../types"; import { Tournament } from "../../types";
import Avatar from "@/components/avatar"; import GlitchAvatar from "@/components/glitch-avatar";
import { import {
CalendarIcon, CalendarIcon,
MapPinIcon, MapPinIcon,
@@ -16,8 +16,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
); );
return ( return (
<Stack align="center" gap={0}> <Stack align="center" gap={16}>
<Avatar <GlitchAvatar
name={tournament.name} name={tournament.name}
contain contain
src={ src={
@@ -25,13 +25,18 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined : undefined
} }
glitchSrc={
tournament.glitch_logo
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
: undefined
}
radius="md" radius="md"
size={300} size={300}
px="xs" px="xs"
withBorder={false} withBorder={false}
> >
<TrophyIcon size={32} /> <TrophyIcon size={32} />
</Avatar> </GlitchAvatar>
<Flex gap="xs" direction="column" justify="space-around"> <Flex gap="xs" direction="column" justify="space-around">
{tournament.location && ( {tournament.location && (
<Group gap="xs"> <Group gap="xs">

View File

@@ -25,6 +25,7 @@ export interface TournamentInfo {
start_time?: string; start_time?: string;
end_time?: string; end_time?: string;
logo?: string; logo?: string;
glitch_logo?: string;
first_place?: TeamInfo; first_place?: TeamInfo;
second_place?: TeamInfo; second_place?: TeamInfo;
third_place?: TeamInfo; third_place?: TeamInfo;
@@ -37,6 +38,7 @@ export interface Tournament {
desc?: string; desc?: string;
rules?: string; rules?: string;
logo?: string; logo?: string;
glitch_logo?: string;
enroll_time?: string; enroll_time?: string;
start_time: string; start_time: string;
end_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 => { export const transformTournamentInfo = (record: any): TournamentInfo => {
// Check if tournament is complete by looking at matches
const matches = record.expand?.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 nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended'); 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, start_time: record.start_time,
end_time: record.end_time, end_time: record.end_time,
logo: record.logo, logo: record.logo,
glitch_logo: record.glitch_logo,
first_place, first_place,
second_place, second_place,
third_place, third_place,
@@ -258,6 +257,7 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
desc: record.desc, desc: record.desc,
rules: record.rules, rules: record.rules,
logo: record.logo, logo: record.logo,
glitch_logo: record.glitch_logo,
enroll_time: record.enroll_time, enroll_time: record.enroll_time,
start_time: record.start_time, start_time: record.start_time,
end_time: record.end_time, end_time: record.end_time,