This commit is contained in:
2026-07-07 21:08:52 +02:00
commit 4c20cfc716
2613 changed files with 318021 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESX MULTICHARACTER</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
{
"name": "vite-react-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@iconify/react": "^6.0.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,62 @@
import CharacterSelection from "./components/CharacterSelection";
import { useState, useEffect } from "react";
import { useNuiEvent } from "./utils/useNuiEvent";
import { Character, Locale } from "./types/Character";
import { fetchNui } from "./utils/fetchNui";
function App() {
const [isVisible, setIsVisible] = useState<boolean>(false);
const [characters, setCharacters] = useState<Character[]>([]);
const [Candelete, setCandelete] = useState<boolean>(false);
const [MaxAllowedSlot, setMaxAllowedSlot] = useState<number>(0);
const [locale, setLocale] = useState<Locale>({
char_info_title: "",
play: "",
title: "",
});
useEffect(() => {
fetchNui("nuiReady");
}, []);
useNuiEvent("ToggleMulticharacter", (data: any) => {
if (data.show) {
const validCharacters = data.Characters.filter(
(char: any) => char !== null
);
const parsedCharacters: Character[] = validCharacters.map(
(char: any, index: number) => ({
id: char.id.toString(),
name: `${char.firstname} ${char.lastname}`,
birthDate: char.dateofbirth,
gender: char.sex,
occupation: char.job,
disabled: char.disabled,
isActive: index === 0,
})
);
setIsVisible(true);
setCharacters(parsedCharacters);
setCandelete(data.CanDelete);
setMaxAllowedSlot(data.AllowedSlot);
setLocale(data.Locale);
} else {
setIsVisible(false);
setCharacters([]);
}
});
return (
isVisible && (
<CharacterSelection
initialCharacters={characters}
Candelete={Candelete}
MaxAllowedSlot={MaxAllowedSlot}
locale={locale}
/>
)
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { Character } from '../types/Character';
import { Icon } from '@iconify/react';
interface CharacterCardProps {
character: Character;
onSelect: (id: string) => void;
onInfoClick: (id: string) => void;
showInfo: boolean;
PlayCharacter: () => void;
}
const CharacterCard: React.FC<CharacterCardProps> = ({
character,
onSelect,
onInfoClick,
showInfo,
PlayCharacter
}) => {
return (
<>
<div className='flex justify-center items-center mb-4'>
<div
className={`
h-[65px] w-[462px] cursor-pointer rounded-[5px] overflow-hidden group
${character.isActive
? ''
: 'cursor-pointer rounded-[5px] overflow-hidden bg-[#38383880] border-2 border-[#ffffff50] hover:border-[#FFFFFF] hover:bg-[#38383840'}
`}
onClick={() => onSelect(character.id)}
>
<div
className={`
flex items-center gap-2 transition-all duration-300
${character.isActive ? 'text-[#fb9b04]' : 'text-gray-400 group-hover:text-[#FFFFFF]'}
`}
>
<div
className={`
flex items-center rounded-[5px] p-4 overflow-hidden
${character.isActive
? 'h-[65px] bg-[#fb9b0440] border-2 border-[#fb9b04]'
: 'h-[60px]'}
${character.isActive && showInfo ? 'w-full' : character.isActive ? 'w-[411px]' : 'w-full'}
`}
>
<Icon
icon="material-symbols:person-rounded"
width="30"
height="30"
className={`mr-3 flex-shrink-0`}
/>
<div className="flex justify-center items-center w-full h-full overflow-hidden">
<span className={`font-bold text-[24px] tracking-wide whitespace-nowrap ${character.isActive ? 'truncate' : 'ml-2'}`}>
{character.name}
</span>
</div>
</div>
{character.isActive && (
<div className="flex gap-2 flex-shrink-0">
<button
className={`
h-[65px] rounded-[5px] ${character.disabled ? 'bg-gray-500' : 'bg-orange-500 hover:bg-orange-600'} flex text-black items-center justify-center transition-all duration-300
${showInfo ? 'w-0 scale-0 opacity-0 overflow-hidden' : 'w-[65px] h-[65px] scale-100 opacity-100'}
`}
onClick={(e) => {
e.stopPropagation();
PlayCharacter();
}}
disabled={character.disabled}
>
<Icon
icon="si:play-fill"
width="30"
height="30"
color="#383838"
/>
</button>
<button
className="w-[65px] h-[65px] rounded-[5px] bg-orange-500 text-black flex items-center justify-center transition-colors hover:bg-orange-600 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
onInfoClick(character.id);
}}
>
<Icon
icon="bi:info"
width="30"
height="30"
color="#383838"
/>
</button>
</div>
)}
</div>
</div>
</div>
</>
)
};
export default CharacterCard;

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { Info, Cake, User2, Briefcase, Trash2 } from 'lucide-react';
import { Character, Locale } from '../types/Character';
import { Icon } from '@iconify/react';
interface CharacterInfoProps {
character: Character;
onClose: () => void;
isAllowedtoDelete: boolean;
PlayCharacter : () => void;
handleDelete: () => void;
locale: Locale;
}
const CharacterInfo: React.FC<CharacterInfoProps> = ({ character, onClose, isAllowedtoDelete , PlayCharacter, handleDelete, locale}) => {
if (!character) return null;
return (
<div className='flex justify-center items-center'>
<div className="w-[462px] bg-neutral-900 rounded-b-lg p-4 animate-slideDown">
<div className="mb-4">
<h3 className="text-gray-400 uppercase text-sm mb-3 font-semibold flex items-center">
<Info className="mr-2" size={16} /> {locale.char_info_title}
</h3>
<div className="grid grid-cols-2 gap-2">
<div className="bg-neutral-800 p-3 rounded flex items-center">
<Cake className="mr-3 text-white" size={18} />
<div className="flex justify-center w-full">
<span className="text-white mr-1">{character.birthDate}</span>
</div>
</div>
<div className="bg-neutral-800 p-3 rounded flex items-center">
<User2 className="mr-3 text-white-400" size={18} />
<div className="flex justify-center w-full">
<span className="text-white mr-6">{character.gender}</span>
</div>
</div>
<div className="bg-neutral-800 p-3 rounded flex items-center col-span-2">
<Briefcase className="mr-3 text-white-400" size={18} />
<div className="flex justify-center w-full">
<span className="text-white mr-6">{character.occupation}</span>
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
className={`flex-1 ${character.disabled ? 'bg-gray-500' : 'bg-neutral-800 hover:bg-[#FFA31A] hover:text-[#383838]'} text-white py-2 px-4 font-semibold text-[16px] rounded-[5px] flex items-center justify-center transition-colors`}
onClick={PlayCharacter}
disabled={character.disabled}
>
<Icon icon="flowbite:play-solid" width={23} height={23} className="mr-2" />
{locale.play}
</button>
{isAllowedtoDelete && (
<button
className="bg-neutral-800 text-white p-2 rounded hover:bg-red-900 transition-colors"
onClick={handleDelete}
>
<Trash2 size={18} />
</button>
)}
</div>
</div>
</div>
);
};
export default CharacterInfo;

View File

@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { Plus } from 'lucide-react';
import { Character, Locale } from '../types/Character';
import CharacterCard from './CharacterCard';
import CharacterInfo from './CharacterInfo';
import Logo from '../assets/esxLogo.png';
import { fetchNui } from '../utils/fetchNui';
interface CharacterSelectionProps {
initialCharacters: Character[];
Candelete: boolean;
MaxAllowedSlot : number;
locale : Locale;
}
const CharacterSelection: React.FC<CharacterSelectionProps> = ({ initialCharacters, Candelete, MaxAllowedSlot, locale }) => {
const [characters, setCharacters] = useState<Character[]>(initialCharacters);
const [showInfo, setShowInfo] = useState<string | null>(null);
const [selectedCharacter, setSelectedCharacter] = useState<Character | null>(
characters.find(char => char.isActive) || null
);
const handleSelectCharacter = (id: string) => {
if (selectedCharacter?.id === id) return;
const updatedCharacters = characters.map(char => ({
...char,
isActive: char.id === id
}));
setCharacters(updatedCharacters);
setSelectedCharacter(updatedCharacters.find(char => char.id === id) || null);
setShowInfo(null);
fetchNui('SelectCharacter', {id : id})
};
const toggleInfo = (id: string) => {
setShowInfo(showInfo === id ? null : id);
};
const PlayCharacter = () => {
fetchNui('PlayCharacter')
}
const handleCreateCharacter = () => {
fetchNui('CreateCharacter')
}
const handleDeleteCharacter = () => {
if (!selectedCharacter) return;
const updatedCharactersRaw = characters.filter(char => char.id !== selectedCharacter.id);
fetchNui('DeleteCharacter');
if (updatedCharactersRaw.length > 0) {
const updatedCharacters = updatedCharactersRaw.map((char, index) => ({
...char,
isActive: index === 0
}));
setCharacters(updatedCharacters);
setSelectedCharacter(updatedCharacters[0]);
setShowInfo(null);
} else {
setCharacters([]);
setSelectedCharacter(null);
setShowInfo(null);
handleCreateCharacter();
}
};
return (
<div className="h-screen bg-[#161616F2] text-white w-[527px] border-r border-neutral-800 overflow-hidden animate-slideIn flex flex-col">
<div className="p-4 flex-1 overflow-auto">
<div className="flex items-center justify-between mb-6">
<img src={Logo} alt="Logo" className="h-16 ml-1" />
<h2 className="text-[24px] font-bold tracking-wide text-right flex-1">{locale.title}</h2>
</div>
<div className="space-y-2">
{characters.map(character => (
<div key={character.id}>
<CharacterCard
character={character}
onSelect={handleSelectCharacter}
onInfoClick={toggleInfo}
showInfo={showInfo === character.id}
PlayCharacter={PlayCharacter}
/>
{character.isActive && showInfo === character.id && (
<CharacterInfo
character={character}
onClose={() => setShowInfo(null)}
isAllowedtoDelete={Candelete}
PlayCharacter={PlayCharacter}
handleDelete={handleDeleteCharacter}
locale={locale}
/>
)}
</div>
))}
</div>
</div>
<div className="p-4 border-t border-neutral-800">
<button
className={`w-full h-[70px] ${characters.length >= MaxAllowedSlot ? 'bg-gray-500' : 'bg-[#FB9B04] cursor-pointer hover:bg-orange-600'} text-[#383838] p-3 rounded flex items-center justify-center transition-colors`}
onClick={handleCreateCharacter}
disabled={characters.length >= MaxAllowedSlot}
>
<Plus size={24} />
</button>
</div>
</div>
);
};
export default CharacterSelection;

View File

@@ -0,0 +1,58 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--primary: #ff9800;
--primary-dark: #f57c00;
--dark-bg: #1c1c1c;
--card-bg: #262626;
}
body {
margin: 0;
font-family: "Poppins", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: white;
}
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideDown {
from {
max-height: 0;
opacity: 0;
transform: translateY(-10px);
}
to {
max-height: 500px;
opacity: 1;
transform: translateY(0);
}
}
.animate-slideIn {
animation: slideIn 0.5s ease-out forwards;
}
.animate-slideDown {
animation: slideDown 0.3s ease-out forwards;
overflow: hidden;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}

View File

@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<App />
);

View File

@@ -0,0 +1,15 @@
export interface Character {
id: string;
name: string;
birthDate: string;
gender: 'MALE' | 'FEMALE';
occupation: string;
isActive?: boolean;
disabled?: boolean;
}
export interface Locale {
char_info_title: string;
play : string;
title : string;
}

View File

@@ -0,0 +1,39 @@
import { isEnvBrowser } from "./misc";
/**
* Simple wrapper around fetch API tailored for CEF/NUI use. This abstraction
* can be extended to include AbortController if needed or if the response isn't
* JSON. Tailor it to your needs.
*
* @param eventName - The endpoint eventname to target
* @param data - Data you wish to send in the NUI Callback
* @param mockData - Mock data to be returned if in the browser
*
* @return returnData - A promise for the data sent back by the NuiCallbacks CB argument
*/
export async function fetchNui<T = unknown>(
eventName: string,
data?: unknown,
mockData?: T,
): Promise<T> {
const options = {
method: "post",
headers: {
"Content-Type": "application/json; charset=UTF-8",
},
body: JSON.stringify(data),
};
if (isEnvBrowser() && mockData) return mockData;
const resourceName = (window as any).GetParentResourceName
? (window as any).GetParentResourceName()
: "nui-frame-app";
const resp = await fetch(`https://${resourceName}/${eventName}`, options);
const respFormatted = await resp.json();
return respFormatted;
}

View File

@@ -0,0 +1,6 @@
// Will return whether the current environment is in a regular browser
// and not CEF
export const isEnvBrowser = (): boolean => !(window as any).invokeNative;
// Basic no operation function
export const noop = () => {};

View File

@@ -0,0 +1,49 @@
import { MutableRefObject, useEffect, useRef } from "react";
import { noop } from "./misc";
interface NuiMessageData<T = unknown> {
action: string;
data: T;
}
type NuiHandlerSignature<T> = (data: T) => void;
/**
* A hook that manage events listeners for receiving data from the client scripts
* @param action The specific `action` that should be listened for.
* @param handler The callback function that will handle data relayed by this hook
*
* @example
* useNuiEvent<{visibility: true, wasVisible: 'something'}>('setVisible', (data) => {
* // whatever logic you want
* })
*
**/
export const useNuiEvent = <T = unknown>(
action: string,
handler: (data: T) => void,
) => {
const savedHandler: MutableRefObject<NuiHandlerSignature<T>> = useRef(noop);
// Make sure we handle for a reactive handler
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = (event: MessageEvent<NuiMessageData<T>>) => {
const { action: eventAction, data } = event.data;
if (savedHandler.current) {
if (eventAction === action) {
savedHandler.current(data);
}
}
};
window.addEventListener("message", eventListener);
// Remove Event Listener on component cleanup
return () => window.removeEventListener("message", eventListener);
}, [action]);
};

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
orange: {
500: '#FF9800',
600: '#F57C00',
},
neutral: {
800: '#262626',
900: '#1c1c1c',
}
},
animation: {
'slide-in': 'slideIn 0.5s ease-out forwards',
'slide-down': 'slideDown 0.3s ease-out forwards',
},
keyframes: {
slideIn: {
'0%': { transform: 'translateX(-100%)', opacity: 0 },
'100%': { transform: 'translateX(0)', opacity: 1 },
},
slideDown: {
'0%': { maxHeight: '0', opacity: 0, transform: 'translateY(-10px)' },
'100%': { maxHeight: '500px', opacity: 1, transform: 'translateY(0)' },
},
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
build: {
outDir: 'build',
},
});