Chat moved up to top level now in all services frontend

This commit is contained in:
NigeParis 2026-01-10 18:09:20 +01:00 committed by Nigel
parent 814c389e38
commit b4af6e08ca
32 changed files with 687 additions and 42 deletions

View file

@ -1,290 +0,0 @@
@import "tailwindcss";
@font-face {
font-family: "DejaVu Sans Mono";
src: url("/fonts/DejaVuSansMono.woff2") format("woff2");
}
@tailwind utilities;
.recessed {
@apply
inline-block
bg-gray-100
text-gray-800
p-2
rounded-md
shadow-inner
border
border-gray-300;
}
.btn-style {
@apply
w-25
h-8
border
border-gray-500
rounded-3xl
bg-gray-500
text-white
cursor-pointer
shadow-[0_2px_0_0_black]
transition-all
hover:bg-blue-200
active:bg-gray-400
active:translate-y-px
active:shadow-[0_2px_0_0_black];
}
.send-btn-style {
@apply
w-12.5
h-12.5
border
border-gray-500
rounded-3xl
hover:bg-blue-200
bg-red-100
text-red-700
cursor-pointer
shadow-[0_2px_0_0_black]
transition-all
active:bg-gray-400
active:translate-y-px
active:shadow-[0_2px_0_0_black];;
}
.chatbox-style {
@apply
w-162.5
h-75 /* increase height if needed */
p-2
border
border-black
shadow-2xl
text-left
text-gray-700
bg-white
rounded-3xl
overflow-y-auto
whitespace-pre-line
flex
flex-col
mx-auto;
}
.system-info {
@apply
h-10
bg-gray-200
text-gray-700
p-3
rounded-3xl
mb-2 border
border-gray-200
text-center
shadow
overflow-y-auto
justify-end /* 👈 forces text to bottom */
relative; /* needed for overlay */
}
.modal-messages {
@apply
h-20
bg-white
text-gray-700
p-3
rounded-3xl
mb-2 border
border-gray-200
text-center
shadow
overflow-y-auto
justify-end /* 👈 forces text to bottom */
relative; /* needed for overlay */
}
.text-info {
@apply
text-blue-800
}
.chat-window-style {
@apply
w-100
h-12.5
p-6
border
border-black
shadow-sm
flex-1
rounded-3xl
focus:bg-blue-300
hover:bg-blue-200
bg-white
text-gray-800;
}
.displaybox {
@apply
fixed
inset-0
flex
items-center
justify-center
bg-[#43536b];
}
.mainboxDisplay {
@apply
fixed
top-1/2
left-1/2
-translate-x-1/2
-translate-y-1/2
bg-gray-200 w-212.5
p-6 rounded-xl
shadow-2xl
text-center z-50;
}
.mainboxDisplay button {
@apply
cursor-pointer
}
.title {
@apply
text-6xl
font-bold
text-gray-800
}
.ping-box {
@apply
w-37.5
ml-2 border
border-gray-500
bg-white
rounded-2xl
p-2
shadow-md
flex flex-col
gap-1
h-87.5;
}
.ping-title {
@apply
text-sm
font-semibold
text-blue-800;
}
div-buddies-list {
@apply
text-black
whitespace-pre-wrap
cursor-pointer
hover:text-blue-500
transition-colors
duration-150;
}
p {
@apply
text-black
}
div-test {
@apply
text-red-800
text-right;
}
div-notlog {
@apply
text-red-800
text-3xl
text-center;
}
div-private {
@apply
text-blue-800;
}
.popUpBox {
@apply
bg-white
rounded-xl
shadow-xl
w-200
h-87.5
p-6
border
border-black
}
.profilPopup {
@apply
fixed
inset-0
bg-black/50
flex
justify-center
items-center;
}
.popup-b-invite {
@apply
absolute
bottom-42
right-12
}
.popup-b-block {
@apply
absolute
bottom-52
right-12
}
.popUpMessage {
@apply
bg-white
rounded-xl
shadow-xl
w-200
h-33
p-6
border
border-black
}
.gamePopup {
@apply
fixed
inset-0
bg-black/50
flex
justify-center
items-center;
}
.hidden{
display: none;
}

View file

@ -1,13 +0,0 @@
import { Socket } from 'socket.io-client';
import type { ClientProfil } from '../types_front';
import { blockUser } from './blockUser';
export function actionBtnPopUpBlock(block: ClientProfil, senderSocket: Socket) {
setTimeout(() => {
const blockUserBtn = document.querySelector("#popup-b-block");
blockUserBtn?.addEventListener("click", () => {
block.text = '';
blockUser(block, senderSocket);
});
}, 0)
};

View file

@ -1,19 +0,0 @@
import type { ClientProfil } from "../types_front";
import { Socket } from "socket.io-client";
import { inviteToPlayPong } from "./inviteToPlayPong";
/**
* function listens for a click on the U-Game button and activates a popup function
* inviteToPlayPong
* @param invite - Clients target profil
* @param senderSocket - socket from the sender
**/
export function actionBtnPopUpInvite(invite: ClientProfil, senderSocket: Socket) {
setTimeout(() => {
const InvitePongBtn = document.querySelector("#popup-b-invite");
InvitePongBtn?.addEventListener("click", () => {
inviteToPlayPong(invite, senderSocket);
});
}, 0)
};

View file

@ -1,15 +0,0 @@
/**
* function adds a message to the frontend chatWindow
* @param text
* @returns
*/
export function addMessage(text: string) {
const chatWindow = document.getElementById("t-chatbox") as HTMLDivElement;
if (!chatWindow) return;
const messageElement = document.createElement("div-test");
messageElement.textContent = text;
chatWindow.appendChild(messageElement);
chatWindow.scrollTop = chatWindow.scrollHeight;
return ;
};

View file

@ -1,9 +0,0 @@
import { Socket } from 'socket.io-client';
import type { ClientProfil } from '../types_front';
import { getUser } from "@app/auth";
export function blockUser(profil: ClientProfil, senderSocket: Socket) {
profil.SenderName = getUser()?.name ?? '';
if (profil.SenderName === profil.user) return;
senderSocket.emit('blockUser', JSON.stringify(profil));
};

View file

@ -1,36 +0,0 @@
import { addMessage } from "./addMessage";
import { Socket } from 'socket.io-client';
import { getUser } from "@app/auth";
import type { ClientMessage } from "../types_front";
import { noGuestFlag } from "../chat";
/**
* function sends socket.emit to the backend to active and broadcast a message to all sockets
* echos the message with addMessage to the sender
* @param socket
* @param msgCommand
*/
export function broadcastMsg (socket: Socket, msgCommand: string[]): void {
let msgText = msgCommand[1] ?? "";
let dest = '';
addMessage(msgText);
const user = getUser();
if (user && socket?.connected) {
const message: ClientMessage = {
command: msgCommand[0],
destination: '',
type: "chat",
user: user.name,
token: document.cookie,
text: msgText,
timestamp: Date.now(),
SenderWindowID: socket.id ?? "",
SenderUserName: user.name,
SenderUserID: user.id,
userID: '',
frontendUserName: '',
frontendUser: '',
Sendertext: '',
};
socket.emit('message', JSON.stringify(message));
}
};

View file

@ -1,12 +0,0 @@
import { Socket } from 'socket.io-client';
/**
* function clears all messages in the chat window
* @param senderSocket
* @returns
*/
export function clearChatWindow(senderSocket: Socket) {
const chatWindow = document.getElementById("t-chatbox") as HTMLDivElement;
if (!chatWindow) return;
chatWindow.innerHTML = "";
}

View file

@ -1,19 +0,0 @@
import { addMessage } from "./addMessage";
import { getUser } from "@app/auth";
export function cmdList() {
addMessage('*');
addMessage('** ********** List of @cmds ********** **');
addMessage('\'@cls\' - clear chat screen conversations');
addMessage('\'@profile <name>\' - pulls ups user profile');
addMessage('\'@block <name>\' - blocks / unblock user');
const guestflag = getUser()?.guest;
if(!guestflag) {
addMessage('\'@guest\' - guest broadcast msgs on / off');
}
addMessage('\'@notify\' - toggles notifications on / off');
addMessage('\'@quit\' - disconnect user from the chat');
addMessage('** *********************************** **');
addMessage('*');
}

View file

@ -1,32 +0,0 @@
import { Socket } from "socket.io-client";
import { isLoggedIn } from "./isLoggedIn";
import { showError } from "@app/toast";
import { updateUser } from "@app/auth";
/**
* function displays who is logged in the chat in the ping-Bubbies window
* @param socket
*/
export async function connected(socket: Socket): Promise<void> {
setTimeout(async () => {
try {
const buddies = document.getElementById('div-buddies') as HTMLDivElement;
const loggedIn = isLoggedIn();
if (!loggedIn) throw('Not Logged in');
let oldUser = localStorage.getItem("oldName") ?? "";
if (loggedIn?.name === undefined) {return ;};
oldUser = loggedIn.name ?? "";
let user = await updateUser();
localStorage.setItem("oldName", oldUser);
buddies.textContent = "";
socket.emit('list', {
oldUser: oldUser,
user: user?.name,
});
} catch (e) {
showError('Failed to login: Unknown error');
}
}, 16);
};

View file

@ -1,25 +0,0 @@
import { Socket } from 'socket.io-client';
import type { ClientProfil } from '../types_front';
/**
* getProfil of a user
* @param socket
* @param user
* @returns
*/
export function getProfil(socket: Socket, user: string) {
if (!socket.connected) return;
const profil: ClientProfil = {
command: '@profile',
destination: 'profilMessage',
type: "chat",
user: user,
token: document.cookie ?? "",
text: user,
userID: '',
timestamp: Date.now(),
SenderWindowID: socket.id,
};
socket.emit('profilMessage', JSON.stringify(profil));
}

View file

@ -1,7 +0,0 @@
let count = 0;
export function incrementCounter(): number {
count += 1;
return count;
}

View file

@ -1,18 +0,0 @@
import { Socket } from 'socket.io-client';
import type { ClientProfil } from '../types_front';
import { getUser } from '@app/auth';
import { addMessage } from './addMessage';
/**
* function displays an invite message to sender
* it also sends a message to backend for a link and displays it in the target window
* @param profil of the target
* @param senderSocket
*/
export function inviteToPlayPong(profil: ClientProfil, senderSocket: Socket) {
profil.SenderName = getUser()?.name ?? '';
if (profil.SenderName === profil.user) return;
addMessage(`You invited to play: ${profil.user}🏓`)
senderSocket.emit('inviteGame', JSON.stringify(profil));
};

View file

@ -1,9 +0,0 @@
import { getUser } from "@app/auth";
import type { User } from '@app/auth'
/**
* function checks if logged in
* @returns either user | null
*/
export function isLoggedIn(): User | null {
return getUser() || null;
};

View file

@ -1,44 +0,0 @@
import { getUser } from "@app/auth";
import { Socket } from 'socket.io-client';
import { getProfil } from './getProfil';
/**
* function adds a user to the ping Buddies window\
* it also acts as click or double click\
* activates two possible actions:\
* click => private Mag\
* dbl click => get Profil of the name\
* collected in the clipBoard
* @param socket
* @param buddies
* @param bud
* @returns
*/
export async function listBuddies(socket: Socket, buddies: HTMLDivElement, listBuddies: string[]) {
buddies.innerHTML = "";
for (const bud of listBuddies)
{
if (!buddies) return;
const sendtextbox = document.getElementById('t-chat-window') as HTMLButtonElement;
const buddiesElement = document.createElement("div-buddies-list");
buddiesElement.textContent = bud + '\n';
const user = getUser()?.name ?? "";
buddies.appendChild(buddiesElement);
buddies.scrollTop = buddies.scrollHeight;
buddiesElement.style.cursor = "pointer";
buddiesElement.addEventListener("click", () => {
navigator.clipboard.writeText(bud);
if (bud !== user && user !== "") {
sendtextbox.value = `@${bud}: `;
sendtextbox.focus();
}
});
buddiesElement.addEventListener("dblclick", () => {
getProfil(socket, bud);
sendtextbox.value = "";
});
}
}

View file

@ -1,11 +0,0 @@
import { Socket } from "socket.io-client";
import { __socket } from "../chat";
export function logout(socket: Socket) {
socket.emit("logout"); // notify server
socket.disconnect(); // actually close the socket
localStorage.clear();
if (__socket !== undefined)
__socket.close();
};

View file

@ -1,26 +0,0 @@
import { incrementCounter } from "./incrementCounter";
// let count = 0;
// function incrementCounter(): number {
// count += 1;
// return count;
// }
export async function openMessagePopup(message: any) {
const modalmessage = document.getElementById("modal-message") ?? null;
if(!message) return
const obj = message;
if (modalmessage) {
const messageElement = document.createElement("div");
messageElement.innerHTML = `
<div id="profile-about">Next Game Message ${incrementCounter()}: ${obj.nextGame}</div>
`;
modalmessage.appendChild(messageElement);
modalmessage.scrollTop = modalmessage.scrollHeight;
}
const gameMessage = document.getElementById("game-modal") ?? null;
if (gameMessage) {
gameMessage.classList.remove("hidden");
}
}

View file

@ -1,22 +0,0 @@
import type { ClientProfil } from '../types_front';
export async function openProfilePopup(profil: ClientProfil) {
const modalname = document.getElementById("modal-name") ?? null;
if (modalname)
modalname.innerHTML =
`
<div class="profile-info">
<div-profil-name id="profilName" class="text-xl font-bold text-blue-500"> Profile of ${profil.user} </div>
<div-login-name id="loginName"> Login status: <span class="recessed">${profil.loginName ?? 'Guest'}</span> </div>
</br>
<div-login-name id="loginName"> Login ID: <span class="recessed">${profil.userID ?? ''}</span> </div>
</br>
<button id="popup-b-invite" class="btn-style popup-b-invite">U Game ?</button>
<button id="popup-b-block" class="btn-style popup-b-block">Block User</button>
<div id="profile-about" class="text-2xl">About: <span class="recessed text-amber-500">${profil.text}</span> </div>
</div>
`;
const profilList = document.getElementById("profile-modal") ?? null;
if (profilList)
profilList.classList.remove("hidden");
}

View file

@ -1,47 +0,0 @@
/**
* function takes the input line of the chat and checks the it's not a cmd
* ex: @command "arg" or @command noarg
* ex: command @help - displays commands availble
* @param msgText : string from input line
*
*/
export function parseCmdMsg(msgText: string): string[] | undefined {
if (!msgText?.trim()) return;
msgText = msgText.trim();
const command: string[] = ['', ''];
if (!msgText.startsWith('@')) {
command[0] = '@msg';
command[1] = msgText;
return command;
}
const noArgCommands = ['@quit', '@help', '@cls'];
if (noArgCommands.includes(msgText)) {
command[0] = msgText;
command[1] = '';
return command;
}
const ArgCommands = ['@profile', '@block'];
const userName = msgText.indexOf(" ");
const cmd2 = msgText.slice(0, userName).trim() ?? "";
const user = msgText.slice(userName + 1).trim();
if (ArgCommands.includes(cmd2)) {
command[0] = cmd2;
command[1] = user;
return command;
}
const colonIndex = msgText.indexOf(":");
if (colonIndex === -1) {
command[0] = msgText;
command[1] = '';
return command;
}
const cmd = msgText.slice(0, colonIndex).trim();
const rest = msgText.slice(colonIndex + 1).trim();
command[0] = cmd;
command[1] = rest;
return command;
}

View file

@ -1,29 +0,0 @@
import { Socket } from "socket.io-client";
import { getSocket } from "../chat";
import { logout } from "./logout";
import { connected } from "./connected";
import { showError } from "@app/toast";
import { setTitle } from "@app/routing";
/**
* function to quit the chat - leaves the ping-Buddies list
* @param socket
*/
export function quitChat (socket: Socket) {
try {
const systemWindow = document.getElementById('system-box') as HTMLDivElement;
const chatWindow = document.getElementById("t-chatbox") as HTMLDivElement;
if (socket) {
logout(socket);
setTitle('Chat Page');
connected(socket);
} else {
getSocket();
}
} catch (e) {
showError('Failed to Quit Chat: Unknown error');
}
};

View file

@ -1,24 +0,0 @@
import { Socket } from 'socket.io-client';
/**
* getProfil of a user
* @param socket
* @param user
* @returns
*/
export function setGuestInfo(socket: Socket, user: string, guest: boolean) {
if (!socket.connected) return;
const profilInfo = {
command: '@guestInfo',
destination: 'guestInfo',
type: "chat",
user: user,
token: document.cookie ?? "",
text: user,
timestamp: Date.now(),
SenderWindowID: socket.id,
guest: guest,
};
socket.emit('guestInfo', JSON.stringify(profilInfo));
}

View file

@ -1,14 +0,0 @@
import { Socket } from "socket.io-client";
/**
* function waits for the socket to be connected and when connected calls socket.on "connect"
* @param socket
* @returns
*/
export function waitSocketConnected(socket: Socket): Promise<void> {
return new Promise(resolve => {
if (socket.connected) return resolve();
socket.on("connect", () => resolve());
});
};

View file

@ -1,18 +0,0 @@
import { __socket } from '../chat';
import { updateUser } from "@app/auth";
export async function windowStateHidden() {
const socketId = __socket || undefined;
// let oldName = localStorage.getItem("oldName") ?? undefined;
let oldName: string;
if (socketId === undefined) return;
let userName = await updateUser();
oldName = userName?.name ?? "";
if (oldName === "") return;
localStorage.setItem('oldName', oldName);
socketId.emit('client_left', {
user: userName?.name,
why: 'tab window hidden - socket not dead',
});
return;
};

View file

@ -1,28 +0,0 @@
import { __socket } from "../chat";
import { setTitle } from "@app/routing";
import { updateUser } from "@app/auth";
/**
* function stores old name clears ping buddies list
* and emit a client entered to backend
* @returns
*/
export async function windowStateVisable() {
const buddies = document.getElementById('div-buddies') as HTMLDivElement;
const socketId = __socket || undefined;
let oldName = localStorage.getItem("oldName") || undefined;
if (socketId === undefined || oldName === undefined) {return;};
let user = await updateUser();
if(user === null) return;
socketId.emit('client_entered', {
userName: oldName,
user: user?.name,
});
buddies.innerHTML = '';
buddies.textContent = '';
setTitle('Chat Page');
return;
};

View file

@ -1,64 +0,0 @@
export type ClientMessage = {
command: string
destination: string;
type: string,
user: string;
userID: string,
token: string
frontendUserName: string,
frontendUser: string,
text: string;
SenderWindowID: string,
SenderUserName: string,
SenderUserID: string,
timestamp: number,
Sendertext: string,
innerHtml?: string,
};
export type ClientProfil = ClientProfilPartial & {
loginName?: string | '',
SenderName?: string | '',
Sendertext?: string | '',
innerHtml?: string | '',
};
export type ClientProfilPartial = {
command: string,
type: string,
destination: string,
user?: string | '',
userID?: string | '',
timestamp: number,
SenderWindowID?:string | '',
SenderID?: string | '',
text?: string | '',
token?: string | '',
guestmsg?: boolean,
}
export type blockedUnBlocked =
{
userState: string,
userTarget: string,
by: string,
};
export type obj =
{
command: string,
destination: string,
type: string,
user: string,
frontendUserName: string,
frontendUser: string,
token: string,
text: string,
timestamp: number,
SenderWindowID: string,
Sendertext: string,
};

View file

@ -1,6 +1,6 @@
import { setTitle, handleRoute } from '@app/routing';
import './root/root.ts'
import './chat/chat.ts'
import '../chat/chat.ts'
import './pong/pong.ts'
import './login/login.ts'
import './signin/signin.ts'

View file

@ -6,38 +6,147 @@
@tailwind utilities;
@layer utilities {
.gray-color {
@apply border-gray-500 bg-gray-500
}
.white-color {
@apply border-white bg-white
}
.fit-all {
@apply
w-fit h-fit
}
.blue-hover {
@apply
hover:bg-blue-200
hover:border-blue-200
}
.rounded-elem {
@apply
border-6 rounded-3xl
}
.circle-8 {
@apply w-8 h-8 rounded-full
}
.base-box {
@apply
flex items-center justify-center
}
.focus-elem {
@apply
z-50
shadow-2xl
text-center
.btn-style {
@apply
w-25
h-8
border
border-gray-500
rounded-3xl
bg-gray-500
text-white
cursor-pointer
shadow-[0_2px_0_0_black]
transition-all
hover:bg-blue-200
active:bg-gray-400
active:translate-y-px
active:shadow-[0_2px_0_0_black];
}
.chatbox-style {
@apply
w-162.5
h-75 /* increase height if needed */
p-2
border
border-black
shadow-2xl
text-left
text-gray-700
bg-white
rounded-3xl
overflow-y-auto
whitespace-pre-line
flex
flex-col
mx-auto;
}
.system-info {
@apply
h-10
bg-gray-200
text-gray-700
p-3
rounded-3xl
mb-2 border
border-gray-200
text-center
shadow
overflow-y-auto
justify-end /* 👈 forces text to bottom */
relative; /* needed for overlay */
}
.displaybox {
@apply
fixed
inset-0
flex
items-center
justify-center
bg-[#43536b];
}
.mainboxDisplay {
@apply
fixed
top-1/2
left-1/2
-translate-x-1/2
-translate-y-1/2
bg-gray-200 w-212.5
p-6 rounded-xl
shadow-2xl
text-center
z-50;
}
.mainboxDisplay button {
@apply
cursor-pointer
}
.pongbox-style {
@apply
h-112.5
w-200
bg-gray-400
text-6xl
flex
items-center
justify-center;
}
.text-style {
@apply
text-black
items-center
justify-center
min-w-[2rem] h-8 px-2
rounded-md border border-gray-300
bg-gray-100 text-gray-800
font-mono text-sm font-medium
shadow-sm
select-none
}
.pong-field {
@apply relative w-200 h-112.5 bg-black;
}
.pong-bat {
@apply absolute w-3 h-20 bg-white;
}
.pong-batleft {
@apply absolute left-4 w-3 h-20 top-0;
}
.pong-batright {
@apply absolute right-4 w-3 h-20 top-0;
}
.pong-center-line {
@apply
absolute
left-1/2
top-0
h-full
w-1
-translate-x-1/2
bg-[linear-gradient(to_bottom,white_50%,transparent_50%)]
bg-size-[4px_20px];
}
.pong-end-screen {
@apply
rounded-2xl
absolute
justify-center
text-black
absolute
text-xl
@ -115,3 +224,36 @@
absolute right-4 top-0;
}
}
.pong-protips-key {
@apply
inline-flex
items-center
justify-center
min-w-[2rem] h-8 px-2
rounded-md border border-gray-300
bg-gray-100 text-gray-800
font-mono text-sm font-medium
shadow-sm
select-none
}
.pong-how-to-play {
@apply
inline-flex items-center justify-center
rounded-full w-8 h-8 bg-blue-500
border-10 border-blue-500
}
.chatPopUp {
@apply
fixed
inset-0
flex
justify-center
items-center;
}
.hidden{
display: none;
}

View file

@ -125,6 +125,7 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn
navigateTo("/app");
return;
}
if (
!batLeft ||
!batRight ||
@ -142,7 +143,8 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn
!tour_infos
)
// sanity check
return showError("fatal error");
return showError("fatal error"); <a href="/chat" class="hover:bg-gray-700 rounded-md px-3 py-2">👤 Chat</a>
if (!how_to_play_btn || !protips) showError("missing protips"); // not a fatal error
tournamentBtn.addEventListener("click", () => {
@ -179,6 +181,16 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn
how_to_play_btn.innerText =
how_to_play_btn.innerText === "?" ? "x" : "?";
});
}
document.addEventListener("keydown", (e) => {keys[e.key.toLowerCase()] = true;});
document.addEventListener("keyup", (e) => {keys[e.key.toLowerCase()] = false;});
setInterval(() => { // key sender
if (keys['escape'] === true && protips && how_to_play_btn) {
protips.classList.add("hidden");
how_to_play_btn.innerText = '?';
}
document.addEventListener("keydown", (e) => {
keys[e.key.toLowerCase()] = true;
@ -324,6 +336,7 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn
queueBtn.innerText = QueueState.InQueu;
socket.emit("enqueue");
});
LocalGameBtn.addEventListener("click", () => {
if (
queueBtn.innerText !== QueueState.Iddle ||

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -101,6 +101,7 @@
<!-- CTA -->
<section class="bg-yellow-500">
<div class="max-w-7xl mx-auto px-6 py-16 text-center">
<h3 class="text-4xl font-extrabold mb-4">
Embrace the Ball Lifestyle
</h3>