Files
redl-fivem-rp/resources/justrp/html/app.html
T
RedlHostingandClaude Opus 5 0144e83f05 Part 1: registration, login and character creation written from scratch
Всё, что здесь лежит, написал ИИ-агент на живом сервере под запись — без ESX,
без QBCore, без скачанных с форума скриптов.

- resources/justrp — свой ресурс на Lua: регистрация, вход, создание персонажа, спавн
- resources/justrp/html — экраны NUI: кинематографичная загрузка, вход, редактор персонажа
- api/api.py — внутренний HTTP-API к MariaDB, пароли через scrypt
- server.cfg.example — пример конфига

Секреты (пароль БД, внутренний ключ API) вынесены в переменные окружения и convar.
Ключ Cfx.re в репозиторий не попадает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:28:58 +00:00

1434 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JustRP</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<style>
/* ── Reset & tokens ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #07070c;
--bg1: #0d0d18;
--bg2: #131320;
--bg3: #1a1a28;
--border: rgba(255,255,255,0.06);
--border2: rgba(255,255,255,0.11);
--accent: #c8933f;
--accent-l: #e0aa55;
--glow: rgba(200,147,63,0.18);
--glow2: rgba(200,147,63,0.08);
--text: #ede9e4;
--text2: rgba(237,233,228,0.55);
--text3: rgba(237,233,228,0.28);
--text4: rgba(237,233,228,0.14);
--ok: #4ade80;
--err: #f87171;
--r: 10px;
--r2: 6px;
--ease: cubic-bezier(0.16,1,0.3,1);
}
html, body {
width: 100%; height: 100%; overflow: hidden;
background: var(--bg);
font-family: 'Inter', sans-serif;
color: var(--text);
-webkit-font-smoothing: antialiased;
}
/* ── Screen system ── */
.screen {
position: fixed; inset: 0;
display: flex;
opacity: 0;
pointer-events: none;
transition: opacity 0.5s var(--ease);
}
.screen.active {
opacity: 1;
pointer-events: all;
}
/* ── Auth ── */
#s-auth {
align-items: stretch;
}
.auth-left {
flex: 0 0 42%;
display: flex; flex-direction: column;
justify-content: flex-end;
padding: 64px;
background: linear-gradient(160deg, #0a0a18 0%, #07070c 100%);
border-right: 1px solid var(--border);
position: relative;
overflow: hidden;
}
.auth-left::before {
content: '';
position: absolute; inset: 0;
background:
radial-gradient(ellipse 60% 50% at 20% 80%, var(--glow2) 0%, transparent 70%);
pointer-events: none;
}
.auth-logo {
font-family: 'Cinzel', serif;
font-size: 48px; font-weight: 700;
letter-spacing: 0.14em;
line-height: 1;
margin-bottom: 16px;
}
.auth-logo span { color: var(--accent); }
.auth-tagline {
font-size: 13px;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--text2);
margin-bottom: 40px;
}
.auth-rule {
width: 56px; height: 2px;
background: var(--accent);
margin-bottom: 28px;
}
.auth-info {
font-size: 13px;
line-height: 1.8;
color: var(--text3);
max-width: 260px;
}
.auth-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 64px;
background: var(--bg1);
}
.auth-box {
width: 100%;
max-width: 380px;
}
/* Tabs */
.auth-tabs {
display: flex;
gap: 0;
margin-bottom: 36px;
border-bottom: 1px solid var(--border);
}
.auth-tab {
flex: 1;
padding: 0 0 14px;
text-align: center;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s ease, border-color 0.2s ease;
user-select: none;
}
.auth-tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.auth-tab:hover:not(.active) {
color: var(--text2);
}
/* Form panels */
.auth-form-panel {
display: none;
}
.auth-form-panel.active {
display: block;
animation: slideIn 0.35s var(--ease);
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Fields */
.field {
margin-bottom: 18px;
}
.field label {
display: block;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 8px;
transition: color 0.2s ease;
}
.field:focus-within label {
color: var(--accent);
}
.field input, .field textarea {
width: 100%;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r2);
padding: 12px 16px;
font-size: 14px;
font-family: inherit;
color: var(--text);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
resize: vertical;
}
.field input::placeholder, .field textarea::placeholder {
color: var(--text4);
}
.field input:focus, .field textarea:focus {
border-color: var(--accent);
background: var(--bg3);
box-shadow: 0 0 0 3px var(--glow2);
}
.field input.error {
border-color: var(--err);
box-shadow: 0 0 0 3px rgba(248,113,113,0.08);
}
.field input.ok {
border-color: rgba(74,222,128,0.35);
}
.field-error {
font-size: 11px;
color: var(--err);
margin-top: 6px;
min-height: 16px;
transition: opacity 0.2s ease;
}
/* Password toggle */
.pw-wrap { position: relative; }
.pw-toggle {
position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer;
color: var(--text3); font-size: 13px;
transition: color 0.2s ease;
padding: 2px;
}
.pw-toggle:hover { color: var(--text2); }
/* Password strength */
.pw-strength {
display: flex; gap: 4px;
margin-top: 8px; height: 3px;
}
.pw-strength-bar {
flex: 1; border-radius: 2px;
background: var(--border);
transition: background 0.3s ease;
}
/* Buttons */
.btn {
display: flex; align-items: center; justify-content: center;
width: 100%; padding: 13px 20px;
border: none; border-radius: var(--r2);
font-size: 13px; font-family: inherit;
font-weight: 500; letter-spacing: 0.08em;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.btn-primary {
background: var(--accent);
color: #07070c;
}
.btn-primary:hover {
background: var(--accent-l);
box-shadow: 0 4px 20px rgba(200,147,63,0.4);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
box-shadow: none;
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border2);
color: var(--text2);
}
.btn-ghost:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--glow2);
}
/* Global error banner */
.alert {
padding: 12px 16px;
border-radius: var(--r2);
font-size: 13px;
line-height: 1.5;
margin-bottom: 20px;
opacity: 0;
height: 0;
overflow: hidden;
transition: opacity 0.3s ease, height 0.3s ease, padding 0.3s ease;
}
.alert.show {
opacity: 1;
height: auto;
}
.alert-error {
background: rgba(248,113,113,0.08);
border: 1px solid rgba(248,113,113,0.2);
color: var(--err);
}
.auth-footer {
margin-top: 24px;
text-align: center;
font-size: 12px;
color: var(--text3);
}
/* ── Character select ── */
#s-chars {
align-items: center;
justify-content: center;
flex-direction: column;
background: var(--bg);
}
.chars-header {
text-align: center;
margin-bottom: 48px;
animation: fadeUp 0.6s var(--ease) forwards;
}
.chars-header h1 {
font-family: 'Cinzel', serif;
font-size: 28px; font-weight: 600;
letter-spacing: 0.1em;
margin-bottom: 8px;
}
.chars-header p {
font-size: 13px; color: var(--text3);
letter-spacing: 0.05em;
}
.chars-grid {
display: flex;
gap: 20px;
animation: fadeUp 0.6s var(--ease) 0.1s both;
}
.char-card {
width: 220px;
background: var(--bg1);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 28px 24px;
cursor: pointer;
transition: all 0.25s var(--ease);
text-align: center;
position: relative;
overflow: hidden;
}
.char-card::before {
content: '';
position: absolute; inset: 0;
background: radial-gradient(ellipse at top, var(--glow2), transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
}
.char-card:hover {
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px var(--glow);
}
.char-card:hover::before { opacity: 1; }
.char-card.empty {
border-style: dashed;
border-color: var(--border);
}
.char-card.empty:hover {
border-color: var(--accent);
border-style: dashed;
}
.char-avatar {
width: 64px; height: 64px;
border-radius: 50%;
background: var(--bg3);
border: 2px solid var(--border2);
margin: 0 auto 16px;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
color: var(--text3);
transition: border-color 0.3s ease;
}
.char-card:hover .char-avatar {
border-color: var(--accent);
}
.char-name {
font-size: 15px; font-weight: 500;
margin-bottom: 6px;
}
.char-sub {
font-size: 12px; color: var(--text3);
}
.char-plus {
font-size: 28px;
color: var(--text4);
line-height: 1;
margin-bottom: 12px;
transition: color 0.3s ease;
}
.char-card.empty:hover .char-plus {
color: var(--accent);
}
/* ── Character creation ── */
#s-char-create {
align-items: stretch;
}
.cc-preview {
flex: 1;
display: flex; flex-direction: column;
align-items: center; justify-content: flex-end;
padding: 40px;
background: rgba(0,0,0,0.3);
position: relative;
cursor: grab;
user-select: none;
}
.cc-preview:active { cursor: grabbing; }
.cc-hint {
padding: 10px 16px;
background: rgba(7,7,12,0.7);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 11px; color: var(--text3);
letter-spacing: 0.05em;
backdrop-filter: blur(8px);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.cc-panel {
width: 400px;
background: var(--bg1);
border-left: 1px solid var(--border);
display: flex; flex-direction: column;
overflow: hidden;
}
.cc-header {
padding: 28px 32px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.cc-header h2 {
font-family: 'Cinzel', serif;
font-size: 18px; font-weight: 600;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.cc-header p { font-size: 12px; color: var(--text3); }
.cc-steps {
display: flex; gap: 6px; margin-top: 14px;
}
.cc-step {
flex: 1; height: 2px;
border-radius: 2px;
background: var(--border);
transition: background 0.3s ease;
}
.cc-step.done { background: var(--accent); }
.cc-step.active { background: rgba(200,147,63,0.5); }
.cc-body {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
scrollbar-width: thin;
scrollbar-color: var(--border2) transparent;
}
.cc-body::-webkit-scrollbar { width: 4px; }
.cc-body::-webkit-scrollbar-track { background: transparent; }
.cc-body::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.cc-section {
display: none;
animation: slideIn 0.3s var(--ease);
}
.cc-section.active { display: block; }
.cc-section-title {
font-size: 11px; font-weight: 500;
letter-spacing: 0.15em; text-transform: uppercase;
color: var(--accent);
margin-bottom: 20px;
}
/* Sliders */
.slider-row {
margin-bottom: 20px;
}
.slider-row label {
display: flex; justify-content: space-between;
font-size: 12px;
color: var(--text2);
margin-bottom: 10px;
}
.slider-row label span {
color: var(--text3);
font-variant-numeric: tabular-nums;
}
input[type=range] {
-webkit-appearance: none;
width: 100%; height: 3px;
background: var(--bg3);
border-radius: 3px;
outline: none;
cursor: pointer;
position: relative;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
background: var(--accent);
border-radius: 50%;
box-shadow: 0 0 6px rgba(200,147,63,0.5);
transition: transform 0.15s ease;
}
input[type=range]:hover::-webkit-slider-thumb,
input[type=range]:focus::-webkit-slider-thumb {
transform: scale(1.25);
}
/* Color grid */
.color-grid {
display: flex; flex-wrap: wrap; gap: 6px;
margin-top: 4px;
}
.color-dot {
width: 24px; height: 24px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.color-dot:hover, .color-dot.selected {
border-color: var(--accent);
transform: scale(1.15);
}
/* Gender pills */
.gender-row {
display: flex; gap: 12px;
margin-bottom: 24px;
}
.gender-pill {
flex: 1; padding: 10px;
border: 1px solid var(--border);
border-radius: var(--r2);
cursor: pointer;
text-align: center;
font-size: 13px;
color: var(--text2);
transition: all 0.2s ease;
user-select: none;
}
.gender-pill:hover { border-color: var(--border2); }
.gender-pill.selected {
border-color: var(--accent);
background: var(--glow2);
color: var(--accent);
}
/* Style options */
.style-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 6px; margin-top: 8px;
}
.style-opt {
aspect-ratio: 1;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r2);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 11px; color: var(--text3);
transition: all 0.2s ease;
}
.style-opt:hover { border-color: var(--border2); }
.style-opt.selected {
border-color: var(--accent);
background: var(--glow2);
color: var(--accent);
}
.cc-footer {
padding: 20px 32px;
border-top: 1px solid var(--border);
display: flex; gap: 12px;
flex-shrink: 0;
}
/* ── Spawn ── */
#s-spawn {
align-items: center;
justify-content: center;
flex-direction: column;
background: var(--bg);
}
.spawn-header {
text-align: center;
margin-bottom: 40px;
animation: fadeUp 0.6s var(--ease) forwards;
}
.spawn-header h2 {
font-family: 'Cinzel', serif;
font-size: 24px; font-weight: 600;
letter-spacing: 0.12em;
margin-bottom: 6px;
}
.spawn-header p { font-size: 13px; color: var(--text3); }
.spawn-grid {
display: flex; gap: 16px;
flex-wrap: wrap;
justify-content: center;
max-width: 900px;
animation: fadeUp 0.6s var(--ease) 0.1s both;
}
.spawn-card {
width: 160px;
background: var(--bg1);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 24px 20px 20px;
cursor: pointer;
text-align: center;
transition: all 0.25s var(--ease);
position: relative;
overflow: hidden;
}
.spawn-card::before {
content: '';
position: absolute; inset: 0;
background: linear-gradient(180deg, var(--glow2) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.3s ease;
}
.spawn-card:hover {
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.spawn-card:hover::before { opacity: 1; }
.spawn-card.selected {
border-color: var(--accent);
background: linear-gradient(180deg, rgba(200,147,63,0.06) 0%, var(--bg1) 100%);
}
.spawn-icon {
font-size: 28px;
margin-bottom: 12px;
display: block;
}
.spawn-name {
font-size: 13px; font-weight: 500;
margin-bottom: 4px;
line-height: 1.3;
}
.spawn-desc {
font-size: 11px; color: var(--text3);
line-height: 1.5;
}
.spawn-footer {
margin-top: 36px;
animation: fadeUp 0.6s var(--ease) 0.2s both;
}
/* ── Misc ── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(7,7,12,0.4);
border-top-color: #07070c;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.divider {
height: 1px;
background: var(--border);
margin: 28px 0;
}
/* ── Username display ── */
.top-bar {
position: fixed; top: 0; left: 0; right: 0;
height: 48px;
display: flex; align-items: center; justify-content: flex-end;
padding: 0 24px;
background: linear-gradient(180deg, rgba(7,7,12,0.95) 0%, transparent 100%);
z-index: 100;
pointer-events: none;
}
.top-user {
font-size: 12px; color: var(--text3);
letter-spacing: 0.06em;
}
.top-user span { color: var(--accent); }
</style>
</head>
<body>
<!-- ═════════════ AUTH ═════════════ -->
<div id="s-auth" class="screen">
<div class="auth-left">
<div class="auth-logo">JUST<span>RP</span></div>
<div class="auth-tagline">Los Santos Roleplay</div>
<div class="auth-rule"></div>
<div class="auth-info">
A persistent roleplaying community set in Los Santos.<br><br>
Every character, every decision, every story — yours to write.
</div>
</div>
<div class="auth-right">
<div class="auth-box">
<div class="auth-tabs">
<div class="auth-tab active" data-tab="login">Login</div>
<div class="auth-tab" data-tab="register">Register</div>
</div>
<div id="auth-alert" class="alert alert-error"></div>
<!-- LOGIN -->
<div class="auth-form-panel active" id="panel-login">
<div class="field">
<label>Username</label>
<input id="l-username" type="text" placeholder="Enter your username" autocomplete="off" spellcheck="false">
<div class="field-error" id="l-username-err"></div>
</div>
<div class="field">
<label>Password</label>
<div class="pw-wrap">
<input id="l-password" type="password" placeholder="Enter your password" autocomplete="off">
<button class="pw-toggle" data-target="l-password">👁</button>
</div>
<div class="field-error" id="l-password-err"></div>
</div>
<button class="btn btn-primary" id="btn-login">Sign In</button>
<div class="auth-footer">
No account? <a href="#" class="link" data-tab="register">Create one</a>
</div>
</div>
<!-- REGISTER -->
<div class="auth-form-panel" id="panel-register">
<div class="field">
<label>Username</label>
<input id="r-username" type="text" placeholder="Choose a username (320 chars)" autocomplete="off" spellcheck="false" maxlength="20">
<div class="field-error" id="r-username-err"></div>
</div>
<div class="field">
<label>Password</label>
<div class="pw-wrap">
<input id="r-password" type="password" placeholder="Choose a strong password" autocomplete="off" minlength="6">
<button class="pw-toggle" data-target="r-password">👁</button>
</div>
<div class="pw-strength" id="pw-strength">
<div class="pw-strength-bar" id="ps1"></div>
<div class="pw-strength-bar" id="ps2"></div>
<div class="pw-strength-bar" id="ps3"></div>
<div class="pw-strength-bar" id="ps4"></div>
</div>
<div class="field-error" id="r-password-err"></div>
</div>
<div class="field">
<label>Confirm Password</label>
<div class="pw-wrap">
<input id="r-confirm" type="password" placeholder="Repeat your password" autocomplete="off">
<button class="pw-toggle" data-target="r-confirm">👁</button>
</div>
<div class="field-error" id="r-confirm-err"></div>
</div>
<button class="btn btn-primary" id="btn-register">Create Account</button>
<div class="auth-footer">
Already have one? <a href="#" class="link" data-tab="login">Sign in</a>
</div>
</div>
</div>
</div>
</div>
<!-- ═════════════ CHARACTERS ═════════════ -->
<div id="s-chars" class="screen">
<div class="top-bar"><div class="top-user">Signed in as <span id="top-username"></span></div></div>
<div class="chars-header">
<h1>Choose Your Character</h1>
<p>Select a character to continue or create a new one</p>
</div>
<div class="chars-grid" id="chars-grid">
<!-- populated by JS -->
</div>
</div>
<!-- ═════════════ CHARACTER CREATION ═════════════ -->
<div id="s-char-create" class="screen">
<div class="cc-preview" id="cc-preview">
<div class="cc-hint">Click · drag to rotate &nbsp;·&nbsp; Scroll to zoom</div>
</div>
<div class="cc-panel">
<div class="cc-header">
<h2>New Character</h2>
<p>Customise your appearance</p>
<div class="cc-steps">
<div class="cc-step active" id="ccs0"></div>
<div class="cc-step" id="ccs1"></div>
<div class="cc-step" id="ccs2"></div>
<div class="cc-step" id="ccs3"></div>
</div>
</div>
<div class="cc-body">
<!-- STEP 0: Name -->
<div class="cc-section active" id="ccs-name">
<div class="cc-section-title">Identity</div>
<div class="gender-row">
<div class="gender-pill selected" data-gender="0">♂ Male</div>
<div class="gender-pill" data-gender="1">♀ Female</div>
</div>
<div class="field">
<label>First Name</label>
<input id="cc-fname" type="text" placeholder="e.g. Marcus" maxlength="32" autocomplete="off">
<div class="field-error" id="cc-fname-err"></div>
</div>
<div class="field">
<label>Last Name</label>
<input id="cc-lname" type="text" placeholder="e.g. Rivera" maxlength="32" autocomplete="off">
<div class="field-error" id="cc-lname-err"></div>
</div>
<div class="field">
<label>Backstory <small style="color:var(--text4);font-weight:400">(optional)</small></label>
<textarea id="cc-backstory" placeholder="A few lines about your character's past…" rows="4"></textarea>
</div>
</div>
<!-- STEP 1: Face -->
<div class="cc-section" id="ccs-face">
<div class="cc-section-title">Face</div>
<div class="slider-row">
<label>Face Shape <span id="v-face-shape">50</span></label>
<input type="range" id="sl-face-shape" min="0" max="100" value="50" data-key="face_shape">
</div>
<div class="slider-row">
<label>Face Blend <span id="v-face-blend">50</span></label>
<input type="range" id="sl-face-blend" min="0" max="100" value="50" data-key="face_blend">
</div>
<div class="slider-row">
<label>Skin Tone <span id="v-skin-tone">50</span></label>
<input type="range" id="sl-skin-tone" min="0" max="100" value="50" data-key="skin_tone">
</div>
<div class="divider"></div>
<div class="cc-section-title">Eye Colour</div>
<div class="color-grid" id="eye-colors"></div>
</div>
<!-- STEP 2: Body & Hair -->
<div class="cc-section" id="ccs-body">
<div class="cc-section-title">Body</div>
<div class="slider-row">
<label>Body Weight <span id="v-body-weight">50</span></label>
<input type="range" id="sl-body-weight" min="0" max="100" value="50" data-key="body_weight">
</div>
<div class="divider"></div>
<div class="cc-section-title">Hair Style</div>
<div class="style-grid" id="hair-styles"></div>
<div class="divider"></div>
<div class="cc-section-title">Hair Colour</div>
<div class="color-grid" id="hair-colors"></div>
</div>
<!-- STEP 3: Outfit -->
<div class="cc-section" id="ccs-outfit">
<div class="cc-section-title">Top</div>
<div class="style-grid" id="outfit-tops"></div>
<div class="divider"></div>
<div class="cc-section-title">Pants</div>
<div class="style-grid" id="outfit-pants"></div>
<div class="divider"></div>
<div class="cc-section-title">Shoes</div>
<div class="style-grid" id="outfit-shoes"></div>
</div>
</div><!-- /cc-body -->
<div class="cc-footer">
<button class="btn btn-ghost" id="cc-back" style="flex:0 0 auto;width:auto;padding:13px 20px">← Back</button>
<button class="btn btn-primary" id="cc-next">Continue →</button>
</div>
<div style="padding:0 32px 16px;text-align:center">
<div id="cc-error" class="field-error" style="min-height:0"></div>
</div>
</div>
</div>
<!-- ═════════════ SPAWN ═════════════ -->
<div id="s-spawn" class="screen">
<div class="spawn-header">
<h2>Choose Spawn Location</h2>
<p>Where does your story begin today?</p>
</div>
<div class="spawn-grid" id="spawn-grid">
<div class="spawn-card" data-loc="legion">
<span class="spawn-icon">🏙</span>
<div class="spawn-name">Legion Square</div>
<div class="spawn-desc">Downtown Los Santos. Busy streets, city life.</div>
</div>
<div class="spawn-card" data-loc="vespucci">
<span class="spawn-icon">🌊</span>
<div class="spawn-name">Vespucci Beach</div>
<div class="spawn-desc">Laid-back beach district. Casual start.</div>
</div>
<div class="spawn-card" data-loc="sandy">
<span class="spawn-icon">🏜</span>
<div class="spawn-name">Sandy Shores</div>
<div class="spawn-desc">Rural desert town. Off the beaten path.</div>
</div>
<div class="spawn-card" data-loc="paleto">
<span class="spawn-icon">🌲</span>
<div class="spawn-name">Paleto Bay</div>
<div class="spawn-desc">Quiet northern town. Far from the chaos.</div>
</div>
<div class="spawn-card" data-loc="airport">
<span class="spawn-icon"></span>
<div class="spawn-name">LSIA Airport</div>
<div class="spawn-desc">Just arrived in the city. Fresh start.</div>
</div>
</div>
<div class="spawn-footer">
<button class="btn btn-primary" id="btn-spawn" disabled style="width:200px">Select a Location</button>
</div>
</div>
<script>
// ── Utilities ──────────────────────────────────────────────────────────────
function el(id) { return document.getElementById(id); }
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => {
s.classList.toggle('active', s.id === id);
});
}
function setAlert(msg) {
const a = el('auth-alert');
if (msg) {
a.textContent = msg;
a.classList.add('show');
} else {
a.textContent = '';
a.classList.remove('show');
}
}
async function callLua(cb, data) {
try {
const r = await fetch(`https://justrp/${cb}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data || {}),
});
return await r.json();
} catch (e) {
return null;
}
}
// ── NUI message router ─────────────────────────────────────────────────────
window.addEventListener('message', function(e) {
const { action, data } = e.data || {};
switch (action) {
case 'showAuth': showScreen('s-auth'); break;
case 'authOk': onAuthOk(data); break;
case 'authFail': onAuthFail(data); break;
case 'showChars': onShowChars(data); break;
case 'charCreateFail': onCharCreateFail(data); break;
case 'showSpawn': onShowSpawn(data); break;
case 'hide': document.body.style.display = 'none'; break;
}
});
// ── AUTH ────────────────────────────────────────────────────────────────────
// Tab switching
document.querySelectorAll('.auth-tab, .link[data-tab]').forEach(el_ => {
el_.addEventListener('click', e => {
e.preventDefault();
const tab = el_.dataset.tab;
document.querySelectorAll('.auth-tab').forEach(t =>
t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.auth-form-panel').forEach(p => {
p.classList.toggle('active', p.id === 'panel-' + tab);
});
setAlert('');
});
});
// Password toggles
document.querySelectorAll('.pw-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const inp = el(btn.dataset.target);
inp.type = inp.type === 'password' ? 'text' : 'password';
btn.textContent = inp.type === 'password' ? '👁' : '🙈';
});
});
// Password strength
el('r-password').addEventListener('input', function() {
const v = this.value;
let score = 0;
if (v.length >= 6) score++;
if (v.length >= 10) score++;
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) score++;
if (/[0-9!@#$%^&*]/.test(v)) score++;
const colors = ['', '#ef4444', '#f59e0b', '#4ade80', '#22d3ee'];
for (let i = 1; i <= 4; i++) {
el('ps' + i).style.background = i <= score ? colors[score] : 'var(--border)';
}
});
// Live validation
el('r-username').addEventListener('input', function() {
const v = this.value.trim();
const err = el('r-username-err');
if (v && (v.length < 3 || v.length > 20)) {
err.textContent = 'Must be 320 characters';
this.classList.add('error');
} else if (v && !/^[a-zA-Z0-9_]+$/.test(v)) {
err.textContent = 'Only letters, numbers and underscores';
this.classList.add('error');
} else {
err.textContent = '';
this.classList.remove('error');
if (v.length >= 3) this.classList.add('ok');
else this.classList.remove('ok');
}
});
el('r-confirm').addEventListener('input', function() {
const err = el('r-confirm-err');
if (this.value && this.value !== el('r-password').value) {
err.textContent = 'Passwords do not match';
this.classList.add('error');
} else {
err.textContent = '';
this.classList.remove('error');
}
});
function setLoading(btn, loading, text) {
if (loading) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>' + text;
} else {
btn.disabled = false;
btn.textContent = text;
}
}
el('btn-login').addEventListener('click', async () => {
const username = el('l-username').value.trim();
const password = el('l-password').value;
setAlert('');
if (!username || !password) {
setAlert('Please fill in all fields.');
return;
}
setLoading(el('btn-login'), true, 'Signing in…');
await callLua('authLogin', { username, password });
});
el('btn-register').addEventListener('click', async () => {
const username = el('r-username').value.trim();
const password = el('r-password').value;
const confirm = el('r-confirm').value;
setAlert('');
if (!username || !password || !confirm) {
setAlert('Please fill in all fields.');
return;
}
if (username.length < 3 || username.length > 20) {
setAlert('Username must be 320 characters.');
return;
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
setAlert('Username: letters, numbers and underscores only.');
return;
}
if (password.length < 6) {
setAlert('Password must be at least 6 characters.');
return;
}
if (password !== confirm) {
setAlert('Passwords do not match.');
return;
}
setLoading(el('btn-register'), true, 'Creating account…');
await callLua('authRegister', { username, password });
});
function onAuthOk(data) {
el('top-username').textContent = data.username;
setLoading(el('btn-login'), false, 'Sign In');
setLoading(el('btn-register'), false, 'Create Account');
}
function onAuthFail(data) {
setAlert(data.error || 'Authentication failed.');
setLoading(el('btn-login'), false, 'Sign In');
setLoading(el('btn-register'), false, 'Create Account');
}
// ── CHARACTER SELECT ────────────────────────────────────────────────────────
function onShowChars(data) {
const chars = data.characters || [];
const grid = el('chars-grid');
grid.innerHTML = '';
for (let slot = 0; slot < 3; slot++) {
const ch = chars.find(c => c.slot === slot);
const card = document.createElement('div');
card.className = 'char-card' + (ch ? '' : ' empty');
if (ch) {
card.innerHTML = `
<div class="char-avatar">👤</div>
<div class="char-name">${ch.firstname} ${ch.lastname}</div>
<div class="char-sub">Slot ${slot + 1}</div>
`;
card.addEventListener('click', () => callLua('charSelect', { id: ch.id }));
} else {
card.innerHTML = `
<div class="char-plus">+</div>
<div class="char-name" style="color:var(--text3)">Empty Slot</div>
<div class="char-sub">Create new character</div>
`;
card.addEventListener('click', () => openCharCreate(slot));
}
grid.appendChild(card);
}
showScreen('s-chars');
}
// ── CHARACTER CREATION ──────────────────────────────────────────────────────
let ccStep = 0;
let ccSlot = 0;
const CC_STEPS = ['ccs-name', 'ccs-face', 'ccs-body', 'ccs-outfit'];
const CC_LABELS = ['Continue →', 'Continue →', 'Continue →', 'Create Character'];
const charData = {
slot: 0, gender: 0,
firstname: '', lastname: '', backstory: '',
face_blend: 0.5, face_shape: 0.5, skin_tone: 0.5, eye_color: 0,
hair_style: 0, hair_color: 0, body_weight: 0.5,
outfit_top: 0, outfit_pants: 0, outfit_shoes: 0,
};
function openCharCreate(slot) {
ccSlot = slot;
ccStep = 0;
charData.slot = slot;
updateCCStep();
showScreen('s-char-create');
callLua('startCharCam', {});
}
function updateCCStep() {
CC_STEPS.forEach((id, i) => {
el(id).classList.toggle('active', i === ccStep);
el('ccs' + i).className = 'cc-step' +
(i < ccStep ? ' done' : i === ccStep ? ' active' : '');
});
el('cc-next').textContent = CC_LABELS[ccStep];
el('cc-back').style.visibility = ccStep === 0 ? 'hidden' : 'visible';
el('cc-error').textContent = '';
}
// Populate color/style grids
const EYE_COLORS = [
'#3a5f8a','#5a8a3a','#8a3a3a','#3a3a3a','#8a703a',
'#5a3a8a','#3a8a8a','#8a8a3a','#c0c0c0','#1a1a1a',
];
const HAIR_COLORS = [
'#1a0a00','#3d1f00','#5c3317','#7a4f2a','#b8860b',
'#f5deb3','#c0c0c0','#808080','#d4a017','#ff4500',
'#8b0000','#000000',
];
function buildColorGrid(containerId, colors, dataKey) {
const wrap = el(containerId);
wrap.innerHTML = '';
colors.forEach((color, i) => {
const dot = document.createElement('div');
dot.className = 'color-dot' + (i === 0 ? ' selected' : '');
dot.style.background = color;
dot.title = `Option ${i + 1}`;
dot.addEventListener('click', () => {
wrap.querySelectorAll('.color-dot').forEach(d => d.classList.remove('selected'));
dot.classList.add('selected');
charData[dataKey] = i;
callLua('applyAppearance', { [dataKey]: i });
});
wrap.appendChild(dot);
});
}
function buildStyleGrid(containerId, count, dataKey, label) {
const wrap = el(containerId);
wrap.innerHTML = '';
for (let i = 0; i < count; i++) {
const opt = document.createElement('div');
opt.className = 'style-opt' + (i === 0 ? ' selected' : '');
opt.textContent = i + 1;
opt.title = label + ' ' + (i + 1);
opt.addEventListener('click', () => {
wrap.querySelectorAll('.style-opt').forEach(o => o.classList.remove('selected'));
opt.classList.add('selected');
charData[dataKey] = i;
callLua('applyAppearance', { [dataKey]: i });
});
wrap.appendChild(opt);
}
}
buildColorGrid('eye-colors', EYE_COLORS, 'eye_color');
buildColorGrid('hair-colors', HAIR_COLORS, 'hair_color');
buildStyleGrid('hair-styles', 24, 'hair_style', 'Hair');
buildStyleGrid('outfit-tops', 20, 'outfit_top', 'Top');
buildStyleGrid('outfit-pants',18, 'outfit_pants', 'Pants');
buildStyleGrid('outfit-shoes',10, 'outfit_shoes', 'Shoes');
// Gender pills
document.querySelectorAll('.gender-pill').forEach(pill => {
pill.addEventListener('click', () => {
document.querySelectorAll('.gender-pill').forEach(p => p.classList.remove('selected'));
pill.classList.add('selected');
charData.gender = parseInt(pill.dataset.gender);
callLua('applyAppearance', { gender: charData.gender });
});
});
// Sliders with live preview
document.querySelectorAll('.cc-body input[type=range]').forEach(sl => {
const key = sl.dataset.key;
const disp = el('v-' + key.replace('_', '-'));
sl.addEventListener('input', () => {
const val = sl.value / 100;
charData[key] = val;
if (disp) disp.textContent = sl.value;
callLua('applyAppearance', { [key]: val });
});
});
// Drag to rotate
let dragging = false, lastDragX = 0;
el('cc-preview').addEventListener('mousedown', e => { dragging = true; lastDragX = e.clientX; });
document.addEventListener('mousemove', e => {
if (!dragging) return;
const delta = e.clientX - lastDragX;
lastDragX = e.clientX;
callLua('charCamDrag', { delta });
});
document.addEventListener('mouseup', () => { dragging = false; });
el('cc-next').addEventListener('click', async () => {
el('cc-error').textContent = '';
if (ccStep === 0) {
const fn = el('cc-fname').value.trim();
const ln = el('cc-lname').value.trim();
if (!fn || !ln) {
el('cc-error').textContent = 'Please enter a first and last name.';
return;
}
charData.firstname = fn;
charData.lastname = ln;
charData.backstory = el('cc-backstory').value.trim();
}
if (ccStep < CC_STEPS.length - 1) {
ccStep++;
updateCCStep();
return;
}
// Final step — submit
el('cc-next').disabled = true;
el('cc-next').innerHTML = '<span class="spinner"></span>Creating…';
await callLua('charCreate', charData);
});
el('cc-back').addEventListener('click', () => {
if (ccStep === 0) {
callLua('stopCharCam', {});
showScreen('s-chars');
return;
}
ccStep--;
updateCCStep();
});
function onCharCreateFail(data) {
el('cc-next').disabled = false;
el('cc-next').textContent = CC_LABELS[ccStep];
el('cc-error').textContent = data.error || 'Failed to create character.';
}
// ── SPAWN ──────────────────────────────────────────────────────────────────
let selectedSpawn = null;
function onShowSpawn(data) {
callLua('stopCharCam', {});
showScreen('s-spawn');
}
document.querySelectorAll('.spawn-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.spawn-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedSpawn = card.dataset.loc;
el('btn-spawn').disabled = false;
el('btn-spawn').textContent = 'Spawn Here →';
});
});
el('btn-spawn').addEventListener('click', async () => {
if (!selectedSpawn) return;
setLoading(el('btn-spawn'), true, 'Spawning…');
await callLua('spawnRequest', { location: selectedSpawn });
});
</script>
</body>
</html>