stepstead-web/assets/chars.html
jts dea01dd6a8 İlk commit: Stepstead web (statik panel + db)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:30:59 +03:00

432 lines
21 KiB
HTML
Raw Permalink 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="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<title>Stepstead — Karakterler</title>
<style>
:root{--bg:#0f1419;--panel:#1a2230;--line:#2c3a4f;--txt:#e6edf3;--muted:#8b99a8;--gold:#e8b923;--ok:#2ea043;--err:#e5534b}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--txt);line-height:1.5}
.wrap{max-width:1100px;margin:0 auto;padding:20px 16px}
h1{font-size:20px;color:var(--gold);margin:0 0 4px}
h2{font-size:15px;color:var(--gold);margin:0 0 10px}
.sub{color:var(--muted);font-size:13px;margin-bottom:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:16px}
input,button,select,textarea{font-size:13px;border-radius:6px;font-family:inherit}
input,select,textarea{background:#222d3d;color:var(--txt);border:1px solid var(--line);padding:7px 9px}
input[type=email],input[type=password]{width:100%;margin-bottom:10px}
textarea{width:100%;min-height:70px;resize:vertical;line-height:1.5}
.btn{background:var(--gold);color:#1a1300;border:none;padding:8px 16px;font-weight:600;cursor:pointer}
.btn.sec{background:#2a3648;color:var(--txt)}
.btn.sm{padding:5px 10px;font-size:12px}
.btn.link{background:none;border:none;color:var(--muted);cursor:pointer;padding:4px 6px;font-size:12px}
.btn.link:hover{color:var(--gold)}
.btn.link.del:hover{color:var(--err)}
.msg{font-size:13px;margin-top:8px;min-height:16px}
.msg.ok{color:var(--ok)} .msg.err{color:var(--err)}
.hide{display:none}
.tabs{display:flex;gap:6px;margin-bottom:16px;border-bottom:1px solid var(--line)}
.tab{background:none;border:none;color:var(--muted);cursor:pointer;padding:9px 14px;font-size:14px;font-weight:600;border-bottom:2px solid transparent;margin-bottom:-1px}
.tab.on{color:var(--gold);border-bottom-color:var(--gold)}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.toolbar input[type=search]{flex:1 1 200px}
.count{color:var(--muted);font-size:12px}
.row{border-top:1px solid var(--line);padding:10px 2px}
.row:first-child{border-top:none}
.row .head{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.row .head b{font-size:14px}
.pill{font-size:11px;padding:1px 8px;border-radius:10px;background:#243247;color:var(--gold);font-weight:600}
.pill.max{background:#2a2438;color:#c9a0ff}
.pill.cls{background:#233a2c;color:#8fd6a0}
.row .acts{margin-left:auto;display:flex;gap:2px}
.row .desc{white-space:pre-wrap;word-wrap:break-word;font-size:13px;margin-top:4px}
.row .eff{white-space:pre-wrap;word-wrap:break-word;font-size:13px;margin-top:4px;border-left:2px solid var(--gold);padding-left:8px}
.row .notes{white-space:pre-wrap;word-wrap:break-word;font-size:12px;color:var(--muted);margin-top:4px;border-left:2px solid var(--line);padding-left:8px}
.frm{display:grid;grid-template-columns:2fr 1fr 1fr;gap:8px;margin-bottom:8px}
@media(max-width:700px){.frm{grid-template-columns:1fr}}
label{font-size:11px;color:var(--muted);display:block;margin-bottom:3px}
.frm input,.frm select{width:100%}
#toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:#1a2230;border:1px solid var(--gold);color:var(--txt);padding:9px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:.3s;z-index:9}
#toast.on{opacity:1}
</style>
<script src="/nav.js?v=8" data-theme="dark" defer></script>
</head>
<body>
<div class="wrap">
<h1>⚔ Karakterler</h1>
<div class="sub">Sınıf ve skill tanımları. Sınıf = karakterin rolü / ne yaptığı. Skill = max level + etkisi. Skiller istersen bir sınıfa bağlanabilir.</div>
<div class="card" id="loginCard">
<input type="email" id="email" placeholder="E-posta" autocomplete="username">
<input type="password" id="pass" placeholder="Şifre" autocomplete="current-password">
<button class="btn" id="loginBtn">Giriş</button>
<div class="msg" id="loginMsg"></div>
</div>
<div id="app" class="hide">
<div class="tabs">
<button class="tab on" data-tab="classes">🛡 Sınıflar</button>
<button class="tab" data-tab="skills">✨ Skiller</button>
<button class="tab" data-tab="races">🧬 Irklar</button>
</div>
<!-- ============ SINIFLAR ============ -->
<div id="tab-classes">
<div class="card">
<div class="toolbar">
<input type="search" id="qC" placeholder="Sınıf ara…">
<span class="count" id="cntC"></span>
</div>
<div id="listC"></div>
<div class="count" id="emptyC">Yükleniyor…</div>
</div>
<div class="card" id="formCardC">
<h2 id="formTitleC">+ Yeni Sınıf</h2>
<div class="frm">
<div><label>İsim *</label><input type="text" id="cName" placeholder="örn. Madenci"></div>
<div><label>Özet (kısa)</label><input type="text" id="cSummary" placeholder="tek satır tanım"></div>
<div><label>Sıra</label><input type="number" id="cSort" value="0"></div>
</div>
<label>ıklama (ne yapar, rolü, oynanış)</label>
<textarea id="cDesc"></textarea>
<label style="margin-top:8px">Not (iç not — denge fikirleri, TODO)</label>
<textarea id="cNotes" style="min-height:50px"></textarea>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center">
<button class="btn" id="saveBtnC">Kaydet</button>
<button class="btn sec hide" id="cancelBtnC">Vazgeç (düzenleme)</button>
<div class="msg" id="fMsgC" style="margin:0"></div>
</div>
</div>
</div>
<!-- ============ SKILLER ============ -->
<div id="tab-skills" class="hide">
<div class="card">
<div class="toolbar">
<input type="search" id="qS" placeholder="Skill, etki veya sınıf ara…">
<select id="fCls"><option value="">Tüm sınıflar</option></select>
<span class="count" id="cntS"></span>
</div>
<div id="listS"></div>
<div class="count" id="emptyS">Yükleniyor…</div>
</div>
<div class="card" id="formCardS">
<h2 id="formTitleS">+ Yeni Skill</h2>
<div class="frm">
<div><label>İsim *</label><input type="text" id="sName" placeholder="örn. Mining"></div>
<div><label>Max Level</label><input type="number" id="sMax" placeholder="örn. 50" min="0"></div>
<div><label>Bağlı Sınıf</label><select id="sCls"><option value="">— bağımsız —</option></select></div>
</div>
<label>Ne yapar (etki)</label>
<textarea id="sEff"></textarea>
<label style="margin-top:8px">Detay / seviye eğrisi / açıklama</label>
<textarea id="sDesc" style="min-height:50px"></textarea>
<label style="margin-top:8px">Not (iç not)</label>
<textarea id="sNotes" style="min-height:44px"></textarea>
<label style="margin-top:8px">Sıra</label>
<input type="number" id="sSort" value="0" style="width:100px">
<div style="margin-top:10px;display:flex;gap:8px;align-items:center">
<button class="btn" id="saveBtnS">Kaydet</button>
<button class="btn sec hide" id="cancelBtnS">Vazgeç (düzenleme)</button>
<div class="msg" id="fMsgS" style="margin:0"></div>
</div>
</div>
</div>
<!-- ============ IRKLAR ============ -->
<div id="tab-races" class="hide">
<div class="card">
<div class="toolbar">
<input type="search" id="qR" placeholder="Irk ara…">
<span class="count" id="cntR"></span>
</div>
<div id="listR"></div>
<div class="count" id="emptyR">Yükleniyor…</div>
</div>
<div class="card" id="formCardR">
<h2 id="formTitleR">+ Yeni Irk</h2>
<div class="frm">
<div><label>İsim *</label><input type="text" id="rName" placeholder="örn. Zirler"></div>
<div><label>Özet (kısa)</label><input type="text" id="rSummary" placeholder="tek satır"></div>
<div><label>Sıra</label><input type="number" id="rSort" value="0"></div>
</div>
<label>Genel özellikler (madde madde, · ile ayır)</label>
<textarea id="rTraits" style="min-height:50px" placeholder="Karanlıkta görüş · Yeraltı · Doğa büyüsü"></textarea>
<label style="margin-top:8px">ıklama (köken, kültür, oyundaki yeri)</label>
<textarea id="rDesc"></textarea>
<label style="margin-top:8px">Not (iç not / çapraz referans)</label>
<textarea id="rNotes" style="min-height:44px"></textarea>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center">
<button class="btn" id="saveBtnR">Kaydet</button>
<button class="btn sec hide" id="cancelBtnR">Vazgeç (düzenleme)</button>
<div class="msg" id="fMsgR" style="margin:0"></div>
</div>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const API="https://api.stepstead.com";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
let CLASSES=[],SKILLS=[],RACES=[],editC=null,editS=null,editR=null;
const $=id=>document.getElementById(id);
const norm=s=>(s||"").toLocaleLowerCase("tr");
function esc(s){return (s??"").toString().replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));}
function toast(m,ms=2500){const t=$("toast");t.textContent=m;t.classList.add("on");clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove("on"),ms);}
async function api(path,opts={}){
opts.headers=Object.assign({"Content-Type":"application/json"},opts.headers,{Authorization:"Bearer "+TOKEN});
const r=await fetch(API+path,opts);
let body={};try{body=await r.json();}catch(e){}
return {ok:r.ok,status:r.status,body};
}
async function login(){
$("loginMsg").textContent="…";
const r=await fetch(API+"/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({email:$("email").value.trim(),password:$("pass").value})});
const b=await r.json().catch(()=>({}));
if(r.ok&&b.access_token){TOKEN=b.access_token;localStorage.setItem("ss_upload_token",TOKEN);
if(window.ssNavRefresh)ssNavRefresh();showApp();}
else{$("loginMsg").className="msg err";$("loginMsg").textContent=b.detail||"Giriş başarısız";}
}
async function showApp(){
const c=await api("/chars/classes");
if(!c.ok){
if(c.status===401||c.status===403){
$("loginMsg").className="msg err";
$("loginMsg").textContent=c.status===403?"Bu hesabın karakter yetkisi yok":"Giriş gerekli / oturum bitti";
}
return;
}
CLASSES=c.body;
const [s,r]=await Promise.all([api("/chars/skills"),api("/chars/races")]);
SKILLS=s.ok?s.body:[];RACES=r.ok?r.body:[];
$("loginCard").classList.add("hide");$("app").classList.remove("hide");
refreshClassOptions();
renderC();renderS();renderR();
}
async function reloadR(){const r=await api("/chars/races");RACES=r.ok?r.body:[];renderR();}
async function reloadC(){const r=await api("/chars/classes");CLASSES=r.ok?r.body:[];refreshClassOptions();renderC();renderS();}
async function reloadS(){const r=await api("/chars/skills");SKILLS=r.ok?r.body:[];renderS();}
function refreshClassOptions(){
const opts=CLASSES.map(c=>`<option value="${c.id}">${esc(c.name)}</option>`).join("");
$("sCls").innerHTML='<option value="">— bağımsız —</option>'+opts;
$("fCls").innerHTML='<option value="">Tüm sınıflar</option>'+opts+'<option value="none">— sınıfsız —</option>';
}
/* ---- Sınıflar ---- */
function renderC(){
const q=norm($("qC").value.trim());
const rows=CLASSES.filter(c=>!q||norm(c.name).includes(q)||norm(c.summary).includes(q)||norm(c.description).includes(q));
$("cntC").textContent=`${rows.length} / ${CLASSES.length} sınıf`;
$("emptyC").style.display=rows.length?"none":"block";
$("emptyC").textContent=CLASSES.length?"Eşleşen sınıf yok.":"Henüz sınıf yok — aşağıdan ekle.";
let html="";
for(const c of rows){
const nSk=SKILLS.filter(s=>s.class_id===c.id).length;
html+=`<div class="row">
<div class="head"><b>${esc(c.name)}</b>
${c.summary?`<span class="pill">${esc(c.summary)}</span>`:""}
${nSk?`<span class="pill cls">${nSk} skill</span>`:""}
<span class="acts">
<button class="btn link" data-editc="${c.id}">düzenle</button>
<button class="btn link del" data-delc="${c.id}">sil</button>
</span></div>
${c.description?`<div class="desc">${esc(c.description)}</div>`:""}
${c.notes?`<div class="notes">${esc(c.notes)}</div>`:""}
</div>`;
}
$("listC").innerHTML=html;
}
function fillC(c){
editC=c?c.id:null;
$("formTitleC").textContent=c?("✏️ Düzenle: "+c.name):"+ Yeni Sınıf";
$("cName").value=c?c.name:"";
$("cSummary").value=c?(c.summary||""):"";
$("cSort").value=c?c.sort_order:0;
$("cDesc").value=c?(c.description||""):"";
$("cNotes").value=c?(c.notes||""):"";
$("cancelBtnC").classList.toggle("hide",!c);
if(c)$("formCardC").scrollIntoView({behavior:"smooth",block:"nearest"});
}
/* ---- Skiller ---- */
function renderS(){
const q=norm($("qS").value.trim()),fc=$("fCls").value;
const rows=SKILLS.filter(s=>{
if(fc==="none"&&s.class_id)return false;
if(fc&&fc!=="none"&&s.class_id!==+fc)return false;
if(!q)return true;
return norm(s.name).includes(q)||norm(s.effect).includes(q)||norm(s.description).includes(q)||norm(s.class_name).includes(q);
});
$("cntS").textContent=`${rows.length} / ${SKILLS.length} skill`;
$("emptyS").style.display=rows.length?"none":"block";
$("emptyS").textContent=SKILLS.length?"Eşleşen skill yok.":"Henüz skill yok — aşağıdan ekle.";
let html="";
for(const s of rows){
html+=`<div class="row">
<div class="head"><b>${esc(s.name)}</b>
${s.max_level!=null?`<span class="pill max">max ${s.max_level}</span>`:""}
${s.class_name?`<span class="pill cls">${esc(s.class_name)}</span>`:""}
<span class="acts">
<button class="btn link" data-edits="${s.id}">düzenle</button>
<button class="btn link del" data-dels="${s.id}">sil</button>
</span></div>
${s.effect?`<div class="eff">${esc(s.effect)}</div>`:""}
${s.description?`<div class="desc">${esc(s.description)}</div>`:""}
${s.notes?`<div class="notes">${esc(s.notes)}</div>`:""}
</div>`;
}
$("listS").innerHTML=html;
}
function fillS(s){
editS=s?s.id:null;
$("formTitleS").textContent=s?("✏️ Düzenle: "+s.name):"+ Yeni Skill";
$("sName").value=s?s.name:"";
$("sMax").value=s&&s.max_level!=null?s.max_level:"";
$("sCls").value=s&&s.class_id?s.class_id:"";
$("sEff").value=s?(s.effect||""):"";
$("sDesc").value=s?(s.description||""):"";
$("sNotes").value=s?(s.notes||""):"";
$("sSort").value=s?s.sort_order:0;
$("cancelBtnS").classList.toggle("hide",!s);
if(s)$("formCardS").scrollIntoView({behavior:"smooth",block:"nearest"});
}
/* ---- Irklar ---- */
function renderR(){
const q=norm($("qR").value.trim());
const rows=RACES.filter(r=>!q||norm(r.name).includes(q)||norm(r.summary).includes(q)||norm(r.traits).includes(q)||norm(r.description).includes(q));
$("cntR").textContent=`${rows.length} / ${RACES.length} ırk`;
$("emptyR").style.display=rows.length?"none":"block";
$("emptyR").textContent=RACES.length?"Eşleşen ırk yok.":"Henüz ırk yok — aşağıdan ekle.";
let html="";
for(const r of rows){
html+=`<div class="row">
<div class="head"><b>${esc(r.name)}</b>
${r.summary?`<span class="pill">${esc(r.summary)}</span>`:""}
<span class="acts">
<button class="btn link" data-editr="${r.id}">düzenle</button>
<button class="btn link del" data-delr="${r.id}">sil</button>
</span></div>
${r.traits?`<div class="eff">${esc(r.traits)}</div>`:""}
${r.description?`<div class="desc">${esc(r.description)}</div>`:""}
${r.notes?`<div class="notes">${esc(r.notes)}</div>`:""}
</div>`;
}
$("listR").innerHTML=html;
}
function fillR(r){
editR=r?r.id:null;
$("formTitleR").textContent=r?("✏️ Düzenle: "+r.name):"+ Yeni Irk";
$("rName").value=r?r.name:"";
$("rSummary").value=r?(r.summary||""):"";
$("rSort").value=r?r.sort_order:0;
$("rTraits").value=r?(r.traits||""):"";
$("rDesc").value=r?(r.description||""):"";
$("rNotes").value=r?(r.notes||""):"";
$("cancelBtnR").classList.toggle("hide",!r);
if(r)$("formCardR").scrollIntoView({behavior:"smooth",block:"nearest"});
}
/* ---- Olaylar ---- */
document.addEventListener("click",async e=>{
const t=e.target;
if(t.dataset.editc){const c=CLASSES.find(x=>x.id===+t.dataset.editc);if(c)fillC(c);}
if(t.dataset.dels)return dels(t);
if(t.dataset.edits){const s=SKILLS.find(x=>x.id===+t.dataset.edits);if(s)fillS(s);}
if(t.dataset.editr){const r=RACES.find(x=>x.id===+t.dataset.editr);if(r)fillR(r);}
if(t.dataset.delc){
const c=CLASSES.find(x=>x.id===+t.dataset.delc);
if(!c||!confirm(`"${c.name}" sınıfı silinsin mi? (Bağlı skiller sınıfsız kalır)`))return;
const r=await api("/chars/classes/"+c.id+"/delete",{method:"POST"});
if(r.ok){toast("Silindi");if(editC===c.id)fillC(null);await reloadC();}
else toast("⚠ "+(r.body.detail||"Hata"));
}
if(t.dataset.delr){
const r=RACES.find(x=>x.id===+t.dataset.delr);
if(!r||!confirm(`"${r.name}" ırkı silinsin mi?`))return;
const res=await api("/chars/races/"+r.id+"/delete",{method:"POST"});
if(res.ok){toast("Silindi");if(editR===r.id)fillR(null);await reloadR();}
else toast("⚠ "+(res.body.detail||"Hata"));
}
if(t.classList.contains("tab")){
document.querySelectorAll(".tab").forEach(x=>x.classList.toggle("on",x===t));
for(const k of ["classes","skills","races"])$("tab-"+k).classList.toggle("hide",t.dataset.tab!==k);
}
});
async function dels(t){
const s=SKILLS.find(x=>x.id===+t.dataset.dels);
if(!s||!confirm(`"${s.name}" skill silinsin mi?`))return;
const r=await api("/chars/skills/"+s.id+"/delete",{method:"POST"});
if(r.ok){toast("Silindi");if(editS===s.id)fillS(null);await reloadS();}
else toast("⚠ "+(r.body.detail||"Hata"));
}
$("saveBtnC").onclick=async()=>{
const name=$("cName").value.trim();
if(!name){$("fMsgC").className="msg err";$("fMsgC").textContent="İsim gerekli";return;}
$("fMsgC").className="msg";$("fMsgC").textContent="…";
const payload={name,summary:$("cSummary").value.trim()||null,
description:$("cDesc").value.trim()||null,notes:$("cNotes").value.trim()||null,
sort_order:parseInt($("cSort").value)||0};
const r=editC?await api("/chars/classes/"+editC,{method:"POST",body:JSON.stringify(payload)})
:await api("/chars/classes",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("fMsgC").className="msg err";$("fMsgC").textContent=r.body.detail||"Hata";return;}
$("fMsgC").className="msg ok";$("fMsgC").textContent=editC?"Güncellendi":"Eklendi";
setTimeout(()=>$("fMsgC").textContent="",1500);
fillC(null);await reloadC();
};
$("cancelBtnC").onclick=()=>fillC(null);
$("saveBtnS").onclick=async()=>{
const name=$("sName").value.trim();
if(!name){$("fMsgS").className="msg err";$("fMsgS").textContent="İsim gerekli";return;}
$("fMsgS").className="msg";$("fMsgS").textContent="…";
const mx=$("sMax").value.trim();
const payload={name,max_level:mx===""?null:parseInt(mx),
effect:$("sEff").value.trim()||null,description:$("sDesc").value.trim()||null,
class_id:$("sCls").value?+$("sCls").value:null,
notes:$("sNotes").value.trim()||null,sort_order:parseInt($("sSort").value)||0};
const r=editS?await api("/chars/skills/"+editS,{method:"POST",body:JSON.stringify(payload)})
:await api("/chars/skills",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("fMsgS").className="msg err";$("fMsgS").textContent=r.body.detail||"Hata";return;}
$("fMsgS").className="msg ok";$("fMsgS").textContent=editS?"Güncellendi":"Eklendi";
setTimeout(()=>$("fMsgS").textContent="",1500);
fillS(null);await reloadS();
};
$("cancelBtnS").onclick=()=>fillS(null);
$("saveBtnR").onclick=async()=>{
const name=$("rName").value.trim();
if(!name){$("fMsgR").className="msg err";$("fMsgR").textContent="İsim gerekli";return;}
$("fMsgR").className="msg";$("fMsgR").textContent="…";
const payload={name,summary:$("rSummary").value.trim()||null,
traits:$("rTraits").value.trim()||null,description:$("rDesc").value.trim()||null,
notes:$("rNotes").value.trim()||null,sort_order:parseInt($("rSort").value)||0};
const r=editR?await api("/chars/races/"+editR,{method:"POST",body:JSON.stringify(payload)})
:await api("/chars/races",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("fMsgR").className="msg err";$("fMsgR").textContent=r.body.detail||"Hata";return;}
$("fMsgR").className="msg ok";$("fMsgR").textContent=editR?"Güncellendi":"Eklendi";
setTimeout(()=>$("fMsgR").textContent="",1500);
fillR(null);await reloadR();
};
$("cancelBtnR").onclick=()=>fillR(null);
$("qC").addEventListener("input",renderC);
$("qS").addEventListener("input",renderS);
$("qR").addEventListener("input",renderR);
$("fCls").addEventListener("change",renderS);
$("loginBtn").onclick=login;
$("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login();});
if(TOKEN)showApp();
</script>
</body>
</html>