This commit is contained in:
yohlo
2025-08-20 22:35:40 -05:00
commit f51c278cd3
169 changed files with 8173 additions and 0 deletions

10
src/lib/events/emitter.ts Normal file
View File

@@ -0,0 +1,10 @@
import { EventEmitter } from "events";
export const serverEvents = new EventEmitter();
export type TestEvent = {
type: "test";
playerId: string;
};
export type ServerEvent = TestEvent;

199
src/lib/logger/index.ts Normal file
View File

@@ -0,0 +1,199 @@
type LogLevel = 'info' | 'success' | 'warn' | 'error';
interface LoggerOptions {
enabled?: boolean;
showTimestamp?: boolean;
collapsed?: boolean;
colors?: boolean;
}
// Cache for performance - update once per second max
let cachedTimestamp = '';
let lastTimestampUpdate = 0;
/**
* Get formatted timestamp with caching for performance
* Format: MM/DD HH:mm:ss
*/
function getTimestamp(): string {
const now = Date.now();
// Update cache only if more than 1 second has passed
if (now - lastTimestampUpdate > 1000) {
const date = new Date(now);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
cachedTimestamp = `${month}/${day} ${hours}:${minutes}:${seconds}`;
lastTimestampUpdate = now;
}
return cachedTimestamp;
}
/**
* Get color and emoji for each log level
*/
function getLevelStyle(level: LogLevel): { color: string; label: string } {
const styles = {
info: { color: '#f5f5f5', label: 'INFO' },
success: { color: '#10B981', label: 'SUCCESS' },
warn: { color: '#F59E0B', label: 'WARN' },
error: { color: '#EF4444', label: 'ERROR' },
};
return styles[level] || styles.info;
}
/**
* Main logger class
*/
class Logger {
private options: LoggerOptions;
private context?: string;
constructor(context?: string, options: LoggerOptions = {}) {
this.context = context;
this.options = {
enabled: process.env.NODE_ENV !== 'production',
showTimestamp: true,
collapsed: true,
colors: true,
...options
};
}
/**
* Create a child logger with a specific context
*/
child(context: string, options?: LoggerOptions): Logger {
const childContext = this.context ? `${this.context} > ${context}` : context;
return new Logger(childContext, { ...this.options, ...options });
}
/**
* Core logging method
*/
private log(
level: LogLevel,
label: string,
data?: any,
...rest: any[]
): void {
if (!this.options.enabled) return;
const style = getLevelStyle(level);
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
const groupLabel = `${timestamp}${style.label}${context}${label}`;
const group = this.options.collapsed ? console.groupCollapsed : console.group;
if (this.options.colors && typeof window !== 'undefined') {
group(
`%c${groupLabel}`,
`color: ${style.color}; font-weight: bold;`
);
} else {
group(groupLabel);
}
if (data !== undefined) {
console.log(data);
}
if (rest.length > 0) {
for (const item of rest) {
console.log(item);
}
}
console.groupEnd();
}
/**
* log level methods
*/
info(label: string, data?: any, ...rest: any[]): void {
this.log('info', label, data, ...rest);
}
success(label: string, data?: any, ...rest: any[]): void {
this.log('success', label, data, ...rest);
}
warn(label: string, data?: any, ...rest: any[]): void {
this.log('warn', label, data, ...rest);
}
error(label: string, data?: any, ...rest: any[]): void {
this.log('error', label, data, ...rest);
}
simple(message: string): void {
if (!this.options.enabled) return;
const style = getLevelStyle('info');
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
const logMessage = `${timestamp}${style.label}${context}${message}`;
if (this.options.colors && typeof window !== 'undefined') {
console.log(`%c${logMessage}`, `color: ${style.color};`);
} else {
console.log(logMessage);
}
}
/**
* Measure performance of an operation
*/
async measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
const result = await fn();
const duration = (performance.now() - start).toFixed(2);
this.success(`${label} completed`, {
duration: `${duration}ms`,
result
});
return result;
} catch (error) {
const duration = (performance.now() - start).toFixed(2);
this.error(`${label} failed`, {
duration: `${duration}ms`,
error
});
throw error;
}
}
/**
* Create a table log
*/
table(label: string, data: any[]): void {
if (!this.options.enabled) return;
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
console.group(`${timestamp}TABLE${context}${label}`);
console.table(data);
console.groupEnd();
}
}
export const logger = new Logger();
export { Logger };

View File

@@ -0,0 +1,23 @@
import { useAuth } from "@/contexts/auth-context";
import { useMantineTheme } from "@mantine/core";
import { useMantineColorScheme } from "@mantine/core";
import { useEffect } from "react";
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
const { metadata: { colorScheme } } = useAuth()
const { setColorScheme } = useMantineColorScheme();
const theme = useMantineTheme();
useEffect(() => {
if (!colorScheme) return;
setColorScheme(colorScheme);
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0]);
}
}, [colorScheme]);
return children
}
export default ColorSchemeProvider;

View File

@@ -0,0 +1,59 @@
import { useAuth } from "@/contexts/auth-context";
import { createTheme, MantineProvider as MantineProviderCore } from "@mantine/core";
import ColorSchemeProvider from "./color-scheme-provider";
const commonInputStyles = {
label: {
padding: 5
},
root: {
margin: '0'
}
}
const theme = createTheme({
defaultRadius: 'sm',
components: {
TextInput: {
styles: commonInputStyles
},
DateTimePicker: {
styles: commonInputStyles
},
Input: {
styles: commonInputStyles
},
Select: {
styles: commonInputStyles
},
Autocomplete: {
styles: commonInputStyles
},
DateTiemPicker: {
styles: {
root: {
zIndex: 1000
},
input: {
zIndex: 1000,
backgroundColor: 'red'
}
}
}
},
});
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
const { metadata } = useAuth()
return <MantineProviderCore
defaultColorScheme={metadata.colorScheme || 'auto'}
theme={{ ...theme, primaryColor: metadata.accentColor || 'blue' }}
>
<ColorSchemeProvider>
{children}
</ColorSchemeProvider>
</MantineProviderCore>
}
export default MantineProvider;

View File

@@ -0,0 +1,78 @@
import { Card, Container, createTheme, Paper, rem, Select } from "@mantine/core";
import type { MantineThemeOverride } from "@mantine/core";
const CONTAINER_SIZES: Record<string, string> = {
xxs: rem("200px"),
xs: rem("300px"),
sm: rem("400px"),
md: rem("500px"),
lg: rem("600px"),
xl: rem("1400px"),
xxl: rem("1600px"),
};
export const defaultTheme: MantineThemeOverride = createTheme({
scale: 1.1,
autoContrast: true,
fontSizes: {
xs: rem("12px"),
sm: rem("14px"),
md: rem("16px"),
lg: rem("18px"),
xl: rem("20px"),
"2xl": rem("24px"),
"3xl": rem("30px"),
"4xl": rem("36px"),
"5xl": rem("48px"),
},
spacing: {
"3xs": rem("4px"),
"2xs": rem("8px"),
xs: rem("10px"),
sm: rem("12px"),
md: rem("16px"),
lg: rem("20px"),
xl: rem("24px"),
"2xl": rem("28px"),
"3xl": rem("32px"),
},
primaryColor: "red",
components: {
Container: Container.extend({
vars: (_, { size, fluid }) => ({
root: {
"--container-size": fluid
? "100%"
: size !== undefined && size in CONTAINER_SIZES
? CONTAINER_SIZES[size]
: rem(size),
},
}),
}),
Paper: Paper.extend({
defaultProps: {
p: "md",
shadow: "xl",
radius: "md",
withBorder: true,
},
}),
Card: Card.extend({
defaultProps: {
p: "xl",
shadow: "xl",
radius: "var(--mantine-radius-default)",
withBorder: true,
},
}),
Select: Select.extend({
defaultProps: {
checkIconPosition: "right",
},
}),
},
other: {
style: "mantine",
},
});

View File

@@ -0,0 +1,49 @@
import PocketBase from 'pocketbase';
import { createPlayersService } from './services/players';
import { createTournamentsService } from './services/tournaments';
import { createTeamsService } from './services/teams';
class PocketBaseAdminClient {
private pb: PocketBase;
private authPromise: Promise<void>;
constructor() {
this.pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
this.pb.beforeSend = (url, options) => {
options.cache = 'no-store';
options.headers = {
...options.headers,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
};
return { url, options };
};
this.pb.autoCancellation(false);
this.authPromise = this.authenticate();
this.authPromise.then(() => {
Object.assign(this, createPlayersService(this.pb));
Object.assign(this, createTeamsService(this.pb));
Object.assign(this, createTournamentsService(this.pb));
});
}
private async authenticate() {
await this.pb.collection("_superusers").authWithPassword(
import.meta.env.VITE_POCKETBASE_ADMIN_EMAIL!,
import.meta.env.VITE_POCKETBASE_ADMIN_PASSWORD!
);
}
}
interface AdminClient extends
PocketBaseAdminClient,
ReturnType<typeof createPlayersService>,
ReturnType<typeof createTeamsService>,
ReturnType<typeof createTournamentsService> {}
export const pbAdmin = new PocketBaseAdminClient() as AdminClient;

View File

@@ -0,0 +1,46 @@
import type { Player, PlayerInput, PlayerUpdateInput } from '@/features/players/types';
import { transformPlayer } from '@/lib/pocketbase/util/transform-types';
import PocketBase from 'pocketbase';
export function createPlayersService(pb: PocketBase) {
return {
async getPlayerByAuthId(authId: string): Promise<Player | null> {
const result = await pb.collection('players').getList<Player>(1, 1, {
filter: `auth_id = "${authId}"`
});
return result.items[0] ? transformPlayer(result.items[0]) : null;
},
async getPlayer(id: string): Promise<Player | null> {
const result = await pb.collection('players').getOne(id, {
expand: 'teams'
});
return transformPlayer(result);
},
async listPlayers(): Promise<Player[]> {
const result = await pb.collection('players').getFullList<Player>({
fields: 'id,first_name,last_name'
});
return result.map(transformPlayer);
},
async createPlayer(data: PlayerInput): Promise<Player> {
const result = await pb.collection('players').create<Player>(data);
return transformPlayer(result);
},
async updatePlayer(id: string, data: PlayerUpdateInput): Promise<Player> {
const result = await pb.collection('players').update<Player>(id, data);
return transformPlayer(result);
},
async getUnassociatedPlayers(): Promise<Player[]> {
const result = await pb.collection('players').getFullList<Player>({
filter: 'auth_id = ""',
fields: 'id,first_name,last_name'
});
return result.map(transformPlayer);
}
};
}

View File

@@ -0,0 +1,20 @@
import { logger } from "@/lib/logger";
import PocketBase from "pocketbase";
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
import { Team } from "@/features/teams/types";
export function createTeamsService(pb: PocketBase) {
return {
async getTeam(id: string): Promise<Team | null> {
try {
logger.info('PocketBase | Getting team', id);
const result = await pb.collection('teams').getOne(id, {
expand: 'players, tournaments'
});
return transformTeam(result);
} catch {
return null;
}
},
};
}

View File

@@ -0,0 +1,57 @@
import { logger } from "@/lib/logger";
import type { Tournament, TournamentInput, TournamentUpdateInput } from "@/features/tournaments/types";
import PocketBase from "pocketbase";
import { transformTournament } from "@/lib/pocketbase/util/transform-types";
import { BracketGenerator } from "@/features/bracket/bracket";
export function createTournamentsService(pb: PocketBase) {
return {
async getTournament(id: string): Promise<Tournament | null> {
try {
const generator = new BracketGenerator(12, true);
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=--=-=-=-=-=-=-=-=-=-")
console.log('Winners Bracket:');
generator.bracket.forEach((round, i) => {
console.log(`Round ${i}:`);
round.forEach(match => {
console.log('-', match);
});
});
console.log('\nLosers Bracket:');
generator.losersBracket.forEach((round, i) => {
console.log(`Round ${i}:`);
round.forEach(match => {
console.log('-', match);
});
});
logger.info('PocketBase | Getting tournament', id);
const result = await pb.collection('tournaments').getOne(id, {
expand: 'teams, teams.players'
});
return transformTournament(result);
} catch {
return null;
}
},
async listTournaments(): Promise<Tournament[]> {
const result = await pb.collection('tournaments').getFullList<Tournament>({
fields: 'id,name,start_time,end_time,logo_url,created',
sort: '-created'
});
console.log(result)
return result.map(transformTournament);
},
async createTournament(data: TournamentInput): Promise<Tournament> {
const result = await pb.collection('tournaments').create<Tournament>(data);
return transformTournament(result);
},
async updateTournament(id: string, data: TournamentUpdateInput): Promise<Tournament> {
const result = await pb.collection('tournaments').update<Tournament>(id, data);
return transformTournament(result);
},
};
}

View File

@@ -0,0 +1,69 @@
import { Player } from "@/features/players/types";
import { Team } from "@/features/teams/types";
import { Tournament } from "@/features/tournaments/types";
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
// this file transforms raw pocketbase results to our types
export function transformPlayer(record: any): Player {
const sadf: string[] = [];
const teams = record.expand?.teams
?.sort((a: Team, b: Team) => new Date(a.created) < new Date(b.created) ? -1 : 0)
?.map(transformTeam) ?? [];
return {
id: record.id!,
first_name: record.first_name,
last_name: record.last_name,
auth_id: record.auth_id,
created: record.created,
updated: record.updated,
teams,
};
}
export function transformTeam(record: any): Team {
const players = record.expand?.players
?.sort((a: Player, b: Player) => new Date(a.created!) < new Date(b.created!) ? -1 : 0)
?.map(transformPlayer) ?? [];
return {
id: record.id,
name: record.name,
logo_url: record.logo_url,
primary_color: record.primary_color,
accent_color: record.accent_color,
song_id: record.song_id,
song_name: record.song_name,
song_artist: record.song_artist,
song_album: record.song_album,
song_year: record.song_year,
song_start: record.song_start,
song_end: 0,
song_image_url: record.song_image_url,
created: record.created,
updated: record.updated,
players,
};
}
export function transformTournament(record: any): Tournament {
const teams = record.expand?.teams
?.sort((a: Team, b: Team) => new Date(a.created) < new Date(b.created) ? -1 : 0)
?.map(transformTeam) ?? [];
return {
id: record.id,
name: record.name,
location: record.location,
desc: record.desc,
rules: record.rules,
logo_url: record.logo_url,
enroll_time: record.enroll_time,
start_time: record.start_time,
end_time: record.end_time,
created: record.created,
updated: record.updated,
teams,
};
}

48
src/lib/sonner/index.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { toast as sonnerToast } from 'sonner';
import { ToastProps } from './types';
import { Notification } from '@mantine/core';
import { ShieldCheckIcon, WarningCircleIcon } from '@phosphor-icons/react';
const makeToast = (toast: Omit<ToastProps, 'id'>) => {
return sonnerToast.custom((id) => (
<Toast
id={id}
title={toast.title}
description={toast.description}
withCloseButton={toast.withCloseButton}
icon={toast.icon}
color={toast.color}
loading={!!toast.loading}
/>
))
}
function success(toast: Omit<ToastProps, 'id'> | string) {
const config = typeof toast === 'string' ? { description: toast } : toast;
return makeToast({ ...config, icon: <ShieldCheckIcon color='lightgreen' size={48} weight='fill'/> });
}
function error(toast: Omit<ToastProps, 'id'> | string) {
const config = typeof toast === 'string' ? { description: toast } : toast;
return makeToast({ ...config, icon: <WarningCircleIcon color='lightcoral' size={48} weight='fill' /> });
}
function Toast(props: ToastProps) {
const { title, description, withCloseButton, icon, loading } = props;
return (
<Notification miw='md' color={'rgba(0,0,0,0)'} withBorder
withCloseButton={!!withCloseButton}
loading={loading}
title={title}
icon={icon}
>
{description}
</Notification>
);
}
export default {
success,
error,
}

11
src/lib/sonner/types.ts Normal file
View File

@@ -0,0 +1,11 @@
import { MantineColor } from "@mantine/core";
export interface ToastProps {
id: string | number;
title?: string;
description?: string;
withCloseButton?: boolean;
icon?: React.ReactNode;
color?: MantineColor;
loading?: boolean;
}

View File

@@ -0,0 +1,38 @@
import SuperTokens from 'supertokens-web-js';
import Session from 'supertokens-web-js/recipe/session';
import Passwordless from 'supertokens-web-js/recipe/passwordless';
import { appInfo } from './config';
import { logger } from './';
export const frontendConfig = () => {
return {
appInfo,
recipeList: [
Passwordless.init(),
Session.init({
tokenTransferMethod: "cookie",
sessionTokenBackendDomain: undefined,
preAPIHook: async (context) => {
context.requestInit.credentials = "include";
return context;
},
})
]
};
}
let initialized = false;
export function ensureSuperTokensFrontend() {
if (typeof window === 'undefined') return;
if (!initialized) {
SuperTokens.init(frontendConfig());
initialized = true;
logger.info("Initialized");
Session.doesSessionExist().then(exists => {
logger.info(`Session does${exists ? '' : 'NOT'} exist on load!`);
});
}
}

View File

@@ -0,0 +1,7 @@
export const appInfo = {
appName: 'Tanstack Start SuperTokens',
apiDomain: import.meta.env.VITE_API_DOMAIN || 'http://localhost:3000',
websiteDomain: import.meta.env.VITE_WEBSITE_DOMAIN || 'http://localhost:3000',
apiBasePath: '/api/auth',
websiteBasePath: '/auth',
}

View File

@@ -0,0 +1,4 @@
import { Logger } from "@/lib/logger";
const logger = new Logger('SuperTokens');
export { logger };

View File

@@ -0,0 +1,30 @@
import Passwordless from "supertokens-node/recipe/passwordless";
import { logger } from "../";
const init = () => (
Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
smsDelivery: {
override: (originalImplementation) => {
return {
...originalImplementation,
sendSms: async ({ userInputCode }) => {
if (!userInputCode) {
throw new Error("No user input code provided to sendSms");
}
logger.info('Sending Code',
'######################',
'## SuperTokens Code ##',
`## ${userInputCode} ##`,
'######################'
);
}
}
}
}
})
)
export default { init };

View File

@@ -0,0 +1,65 @@
import { useSession } from "@tanstack/react-start/server";
import Passwordless from "supertokens-node/recipe/passwordless";
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
const init = () => (
Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
smsDelivery: {
override: (originalImplementation) => {
return {
...originalImplementation,
sendSms: async ({ userInputCode, phoneNumber, preAuthSessionId }) => {
if (!userInputCode) {
throw new Error("No user input code provided to sendSms");
}
const sid = await sendVerifyCode(phoneNumber, userInputCode);
const session = await useSession({
password: preAuthSessionId
});
await session.update({
twilioSid: sid
});
}
}
}
},
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
consumeCode: async (input) => {
const session = await useSession({ password: input.preAuthSessionId });
const twilioSid = session?.data.twilioSid;
if (!twilioSid) {
throw new Error("Twilio SID not found in session");
}
let response = await originalImplementation.consumeCode(input);
if (response.status === "OK") {
await updateVerify(twilioSid);
await session.update({
twilioSid: undefined,
userId: response?.user.id
})
} else if (response.status === "INCORRECT_USER_INPUT_CODE_ERROR") {
if (response.failedCodeInputAttemptCount !== response.maximumCodeInputAttempts) {
await updateVerify(twilioSid);
}
}
return response;
}
}
}
}
})
)
export default { init };

View File

@@ -0,0 +1,48 @@
import { getSessionForSSR } from "supertokens-node/custom";
import { ensureSuperTokensBackend } from "../server";
import { logger } from "../";
export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
ensureSuperTokensBackend();
try {
const session = await getSessionForSSR(request);
if (session.hasToken) {
return {
hasToken: true,
accessTokenPayload: session.accessTokenPayload,
userId: session.accessTokenPayload?.sub,
sessionHandle: session.accessTokenPayload?.sessionHandle,
};
}
return null;
} catch (error: any) {
logger.error("Session error", error);
if (error.type === "TRY_REFRESH_TOKEN") {
return {
hasToken: false,
needsRefresh: true,
error: 'TRY_REFRESH_TOKEN'
};
}
if (options?.sessionRequired === false) {
return null;
}
throw error;
}
}
export async function verifySession(request: Request, options?: { sessionRequired?: boolean }) {
const session = await getSessionForStart(request, options);
if (!session && options?.sessionRequired !== false) {
throw new Response("Unauthorized", { status: 401 });
}
return session;
}

View File

@@ -0,0 +1,42 @@
import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import { TypeInput } from "supertokens-node/types";
import Dashboard from "supertokens-node/recipe/dashboard";
import UserRoles from "supertokens-node/recipe/userroles";
import { appInfo } from "./config";
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
import { logger } from "./";
export const backendConfig = (): TypeInput => {
return {
framework: 'custom',
supertokens: {
connectionURI: import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
},
appInfo,
recipeList: [
PasswordlessDevelopmentMode.init(),
Session.init({
cookieSameSite: "lax",
cookieSecure: process.env.NODE_ENV === 'production',
cookieDomain: process.env.NODE_ENV === 'production' ? ".example.com" : undefined,
antiCsrf: process.env.NODE_ENV === 'production' ? "VIA_TOKEN" : "NONE",
// Debug only
exposeAccessTokenToFrontendInCookieBasedAuth: true,
}),
Dashboard.init(),
UserRoles.init()
],
telemetry: process.env.NODE_ENV !== 'production',
};
}
let initialized = false;
export function ensureSuperTokensBackend() {
if (!initialized) {
SuperTokens.init(backendConfig());
initialized = true;
logger.simple("Backend initialized");
}
}

34
src/lib/twilio/index.ts Normal file
View File

@@ -0,0 +1,34 @@
import twilio from "twilio";
const accountSid = import.meta.env.VITE_TWILIO_ACCOUNT_SID
const authToken = import.meta.env.VITE_TWILIO_AUTH_TOKEN
const serviceSid = import.meta.env.VITE_TWILIO_SERVICE_SID
const client = twilio(accountSid, authToken);
export async function sendVerifyCode(phoneNumber: string, code: string) {
const verification = await client.verify.v2
.services(serviceSid)
.verifications.create({
channel: "sms",
customCode: code,
to: phoneNumber,
});
if (verification.status !== "pending") {
throw new Error("Unknown error sending verification code");
}
return verification.sid;
}
export async function updateVerify(sid: string) {
const verification = await client.verify.v2
.services(serviceSid)
.verifications(sid)
.update({ status: "approved" });
if (verification.status !== "approved") {
throw new Error("Unknown error updating verification");
}
}