İlk commit: Stepstead web (statik panel + db)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jts 2026-07-11 11:30:59 +03:00
commit dea01dd6a8
18 changed files with 3764 additions and 0 deletions

432
assets/chars.html Normal file
View file

@ -0,0 +1,432 @@
<!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>