İ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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# Kullanıcı yüklemeleri (büyük binary, canlıda kalır ama versiyonlanmaz)
assets/uploads/

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>

469
assets/index.html Normal file
View file

@ -0,0 +1,469 @@
<!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 — Asset Takip</title>
<style>
:root{
--bg:#0f1419; --panel:#1a2230; --panel2:#222d3d; --line:#2c3a4f;
--txt:#e6edf3; --muted:#8b99a8; --gold:#e8b923;
--eksik:#e5534b; --yapiliyor:#d9a521; --tamam:#2ea043;
--d2:#4a9eff; --d3:#c77dff;
}
*{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}
header{padding:20px 16px;border-bottom:1px solid var(--line);
position:sticky;top:0;background:var(--bg);z-index:10}
h1{margin:0;font-size:20px;color:var(--gold)}
.sub{color:var(--muted);font-size:13px;margin-top:4px}
.wrap{max-width:1040px;margin:0 auto;padding:16px}
.bar{height:10px;background:var(--panel);border-radius:6px;overflow:hidden;margin:10px 0 4px}
.bar > i{display:block;height:100%;background:var(--tamam);width:0;transition:width .3s}
.barlbl{font-size:12px;color:var(--muted)}
.cat{background:var(--panel);border:1px solid var(--line);border-radius:10px;
margin:12px 0;overflow:hidden}
.cat > summary{cursor:pointer;list-style:none;padding:14px 16px;
display:flex;align-items:center;gap:10px;font-weight:600;user-select:none}
.cat > summary::-webkit-details-marker{display:none}
.cat > summary .chev{transition:transform .2s;color:var(--muted)}
.cat[open] > summary .chev{transform:rotate(90deg)}
.cat .count{margin-left:auto;font-size:12px;color:var(--muted);font-weight:400;display:flex;gap:5px;flex-wrap:wrap}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:10px 16px;border-top:1px solid var(--line);font-size:14px;vertical-align:top}
th{color:var(--muted);font-weight:500;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
td.name{font-weight:600}
td.desc{color:var(--muted)}
select.st,select.dm{background:var(--panel2);color:var(--txt);border:1px solid var(--line);
border-radius:6px;padding:5px 8px;font-size:13px;cursor:pointer}
select.st.eksik{color:var(--eksik)} select.st.yapiliyor{color:var(--yapiliyor)} select.st.tamam{color:var(--tamam)}
select.dm.d2{color:var(--d2)} select.dm.d3{color:var(--d3)} select.dm.dx{color:var(--muted)}
tr.done td.name{text-decoration:line-through;opacity:.6}
.del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px;opacity:.5}
.del:hover{color:var(--eksik);opacity:1}
.addrow td{padding:8px 16px 14px}
.addrow input{background:var(--panel2);color:var(--txt);border:1px solid var(--line);
border-radius:6px;padding:7px 9px;font-size:13px;margin-right:6px}
.addrow input.n{width:180px} .addrow input.d{width:240px}
.addrow select{background:var(--panel2);color:var(--txt);border:1px solid var(--line);border-radius:6px;padding:7px;font-size:13px;margin-right:6px}
.btn{background:var(--gold);color:#1a1300;border:none;border-radius:6px;
padding:7px 14px;font-size:13px;font-weight:600;cursor:pointer}
.btn.ghost{background:transparent;color:var(--muted);border:1px solid var(--line)}
.btn.ghost.on{border-color:var(--gold);color:var(--gold)}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;align-items:center}
.filt{display:flex;gap:6px;margin-right:auto}
.pill{font-size:11px;padding:1px 7px;border-radius:10px}
.pill.eksik{background:rgba(229,83,75,.15);color:var(--eksik)}
.pill.yapiliyor{background:rgba(217,165,33,.15);color:var(--yapiliyor)}
.pill.tamam{background:rgba(46,160,67,.15);color:var(--tamam)}
.pill.d2{background:rgba(74,158,255,.15);color:var(--d2)}
.pill.d3{background:rgba(199,125,255,.15);color:var(--d3)}
.hide{display:none !important}
.logincard{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:8px}
.logincard input{background:#222d3d;color:var(--txt,#e6edf3);border:1px solid #2c3a4f;border-radius:6px;padding:6px 9px;font-size:13px}
.logincard .msg{font-size:12px;color:#e5534b}
button.up{background:none;border:1px solid #2c3a4f;border-radius:6px;cursor:pointer;font-size:13px;padding:2px 7px}
button.up:hover{border-color:#e8b923}
#toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:#1a2230;border:1px solid #e8b923;color:#e6edf3;padding:9px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:.3s;max-width:90vw;z-index:9}
#toast.on{opacity:1}
</style>
<script src="/nav.js?v=8" data-theme="dark" defer></script>
</head>
<body>
<header>
<div class="wrap" style="padding-bottom:0">
<h1>🎨 Stepstead — Asset Takip</h1>
<div class="sub">Eksik görselleri gör, 2D/3D ayır, tamamlandıkça işaretle. Veriler bu tarayıcıda saklanır.</div>
<div class="bar"><i id="pbar"></i></div>
<div class="barlbl" id="plbl"></div>
<div class="toolbar">
<div class="filt">
<button class="btn ghost on" data-filt="all">Tümü</button>
<button class="btn ghost" data-filt="2d">🟦 2D</button>
<button class="btn ghost" data-filt="3d">🟪 3D</button>
</div>
<a class="btn ghost" href="/assets/upload.html" style="text-decoration:none">📤 Dosya Yükle</a>
<button class="btn ghost" id="expBtn">⬇ Dışa aktar</button>
<button class="btn ghost" id="impBtn">⬆ İçe aktar</button>
<input type="file" id="impFile" accept="application/json" hidden>
<button class="btn ghost" id="authBtn">🔑 Giriş</button>
</div>
<div class="logincard hide" id="loginCard">
<input type="email" id="lgEmail" placeholder="E-posta" autocomplete="username">
<input type="password" id="lgPass" placeholder="Şifre" autocomplete="current-password">
<button class="btn ghost" id="lgBtn">Giriş yap</button>
<span class="msg" id="lgMsg"></span>
</div>
</div>
</header>
<div id="toast"></div>
<div class="wrap" id="root"></div>
<script>
const STATES=["eksik","yapiliyor","tamam"];
const STLBL={eksik:"Eksik",yapiliyor:"Yapılıyor",tamam:"Tamam"};
const DIMS=["2d","3d","yok"];
const DMLBL={"2d":"2D","3d":"3D","yok":"—"};
const DMCLS={"2d":"d2","3d":"d3","yok":"dx"};
const KEY="stepstead_assets_v3";
// [ad, açıklama, boyut] — 2026-07-04: lokasyon-graf pivotuna göre yenilendi (tile/3D söküldü)
const SEED=[
{cat:"🗺 Dünya Haritası (EN KRİTİK)", items:[
["Ana harita illüstrasyonu","Statik dünya haritası, portre 720×1280; 15 lokasyon pin koordinatları 0-1000 grid'e oturacak (world.gd TextureRect)","2d"],
["Pin: köy","Başlangıç köyü işareti","2d"],
["Pin: pazar","Ticaret/market işareti","2d"],
["Pin: liman","Liman işareti","2d"],
["Pin: orman (5 tier)","Fısıltı→Karanlık Orman; tier tonlaması","2d"],
["Pin: maden (3 tier)","Taş Ocağı / Derin Maden / Ejder Dağı","2d"],
["Pin: su (göl/kıyı)","Aynalı Göl, Fırtına Kıyısı","2d"],
["Pin: çayır & av","Çayırlar, Av Toprakları","2d"],
["Yol dokusu","Lokasyonlar arası patika çizgi stili (şimdilik Line2D)","2d"],
["Oyuncu konumu işareti","'Buradasın' halkası/bayrak + diğer oyuncu 👤N rozeti","2d"],
]},
{cat:"📍 Lokasyon Kartları (15)", items:[
["Köy","Panel üst görseli / vignette","2d"],
["Pazar","Tezgâhlar, vendor","2d"],
["Liman","Balıı iskeleleri","2d"],
["Fısıltı Ormanı","Tier 1 orman","2d"],
["Gümüş Çamlık","Tier 2 orman","2d"],
["Akça Koru","Tier 3 orman","2d"],
["Kadim Orman","Tier 4 orman","2d"],
["Karanlık Orman","Tier 5 orman","2d"],
["Taş Ocağı","Başlangıç madeni","2d"],
["Derin Maden","Orta seviye maden","2d"],
["Ejder Dağı","Üst seviye maden","2d"],
["Aynalı Göl","Tatlı su balıılığı","2d"],
["Fırtına Kıyısı","Deniz balıılığı","2d"],
["Çayırlar","Ot toplama","2d"],
["Av Toprakları","Avcılık","2d"],
]},
{cat:"🎴 Olay Kartı İllüstrasyonları (20)", items:[
["Gezgin Tacir","Genel yol olayı","2d"],
["Haydut Pususu","Genel yol olayı","2d"],
["Yaralı Gezgin","Genel yol olayı","2d"],
["Kayıp Sandık","Genel yol olayı","2d"],
["Dev Ayı","Genel yol olayı","2d"],
["Mantar Halkası","Genel yol olayı","2d"],
["Eski Harabe","Genel yol olayı","2d"],
["Nehir Geçidi","Genel yol olayı","2d"],
["Gizemli Yaşlı","Genel yol olayı","2d"],
["Kırık Araba","Genel yol olayı","2d"],
["Sağanak","Genel yol olayı","2d"],
["Şanslı Buluş","Genel yol olayı","2d"],
["Kurt Sürüsü","Av Toprakları / Karanlık Orman","2d"],
["Vahşi Domuz","Ormanlar / av","2d"],
["Taze Geyik İzi","Çayır / av","2d"],
["Mağara Örümceği","Madenler","2d"],
["Korsan Artığı","Liman / kıyı (gold ödüllü)","2d"],
["Yaşlı Avcı","Av Toprakları / Çayırlar","2d"],
["Terk Edilmiş Ağ","Göl / kıyı / liman","2d"],
["Ayı İni","Kadim / Karanlık Orman","2d"],
["Olay kartı çerçevesi","Altın kenarlı kart şablonu + karartma","2d"],
["d20 zar görseli","Skill check sonucu (başarılı/başarısız durumları)","2d"],
]},
{cat:"🎒 Item İkonları (~318, aile bazlı)", items:[
["Odunlar (13)","Meşe→Abanoz tier serisi","2d"],
["Cevher + külçe (6+6)","Bakır→Altın","2d"],
["Otlar (12)","Nane, adaçayı, lavanta...","2d"],
["Balıklar (15)","Sazan→Mersin","2d"],
["Et + deri (5+5)","Tavşan/geyik/domuz/kurt/ayı","2d"],
["Bootstrap malzemeleri","Küçük taş, sopa, sarmaşık","2d"],
["Tool'lar","Taş/bakır/demir balta+kazma, 3 olta","2d"],
["Consumable'lar","Çaylar, iksirler, yemekler (adım veren)","2d"],
["Marangozluk ürünleri","Mobilya/yapı ~80 ikon","2d"],
["Okçuluk ürünleri","Yay/ok ~26 ikon","2d"],
["Kuyumculuk ürünleri","Takılar ~6 ikon","2d"],
["Dericilik ürünleri","Giyim ~20 ikon","2d"],
["Nadirlik çerçeveleri","Common→Legendary renk/çerçeve","2d"],
]},
{cat:"⛺ Sistem Görselleri", items:[
["Kamp görseli","Lokasyonda kamp kurulu göstergesi (⛺ yerine)","2d"],
["Adım ikonu","👣 yerine özel ikon","2d"],
["Gold ikonu","🪙 yerine özel ikon","2d"],
["Günlük ödül / streak","🎁 + 🔥 streak görselleri","2d"],
["Liderlik rozetleri","🥇🥈🥉 + sıra çerçeveleri","2d"],
["Skill ikonları (10)","Madencilik, balıılık, toplama, ağaç kesme, avcılık, savaş, demircilik, simya, aşçılık, inşaat","2d"],
]},
{cat:"🧑 Karakter / NPC Portreleri", items:[
["Oyuncu avatarı","2D portre (profil/HUD)","2d"],
["Vendor NPC","Pazar tüccarı portresi","2d"],
["Olay NPC'leri","Tacir, yaşlı avcı, gizemli yaşlı, haydutlar (kartlarda kullanılır)","2d"],
]},
{cat:"🖼 UI / Arayüz", items:[
["Buton seti","Normal / basılı / pasif durumlar","2d"],
["Panel & çerçeve","Envanter, craft, market, lokasyon, chat panelleri","2d"],
["Menü ikonları","💬🏪🎁📊🏆🎒🔨 buton ikonları","2d"],
["Popup / modal","Onay, ödül, uyarı kutusu çerçevesi","2d"],
["Progress bar","Adım / XP / günlük hedef çubukları","2d"],
["Sekme & navigasyon","Panel içi sekmeler (Genel/Burada, İlanlar/Sat/Vendor)","2d"],
]},
{cat:"🏷 Marka", items:[
["Logo","Oyun logosu (yatay + kare)","2d"],
["Splash ekranı","Açılış görseli","2d"],
["App ikonu","Android/iOS launcher ikonu","2d"],
["Store görselleri","Mağaza banner / ekran görüntüleri","2d"],
]},
{cat:"✨ Efekt / Geri bildirim", items:[
["Toplama efekti","Kaynak toplandı parçacık/animasyon","2d"],
["Craft tamam efekti","Üretim başarılı ışıma","2d"],
["Level-up efekti","Seviye atlama kutlaması","2d"],
["Günlük ödül konfetisi","Claim anı","2d"],
]},
{cat:"🔊 Ses", items:[
["Tık sesi","UI buton sesi","yok"],
["Toplama sesi","Kaynak toplama","yok"],
["Craft sesi","Üretim sesi","yok"],
["Zar sesi","Olay kartı d20 atışı","yok"],
["Olay kartı sesi","Kart açılma","yok"],
["Seyahat sesi","Yola çıkma / varış","yok"],
["Level-up sesi","Seviye atlama","yok"],
["Ödül sesi","Günlük ödül / satış","yok"],
["Ambient","Arka plan müzik / ortam (lokasyon türüne göre?)","yok"],
]},
];
function freshSeed(){
return SEED.map(c=>({cat:c.cat,items:c.items.map(i=>({name:i[0],desc:i[1],dim:i[2],state:"eksik"}))}));
}
function load(){
let data;
try{ data=JSON.parse(localStorage.getItem(KEY)); }catch(e){}
if(!data || !Array.isArray(data)){
// v3: lokasyon-graf pivotu sonrası taze liste; v2'deki durumlar isim eşleşmesiyle korunur
data=freshSeed();
let old;
try{ old=JSON.parse(localStorage.getItem("stepstead_assets_v2")); }catch(e){}
if(Array.isArray(old)){
const seedNames=new Set();
data.forEach(c=>c.items.forEach(i=>seedNames.add(i.name)));
const states={};
const extras=[];
old.forEach(c=>c.items.forEach(i=>{
states[i.name]=i.state;
if(!seedNames.has(i.name) && i.state!=="tamam")
extras.push({name:i.name,desc:i.desc||"",dim:i.dim||"2d",state:i.state||"eksik"});
}));
data.forEach(c=>c.items.forEach(i=>{ if(states[i.name]) i.state=states[i.name]; }));
if(extras.length) data.push({cat:"📦 Eski listeden taşınanlar",items:extras});
}
save(data);
}
data.forEach(c=>c.items.forEach(i=>{ if(!i.dim) i.dim="2d"; }));
// Sunucu gerçeği: oyuna bağlanmış asset'ler her tarayıcıda "tamam" görünür (2026-07-06)
const SERVER_DONE=new Set([
"Ana harita illüstrasyonu","Pin: köy","Pin: pazar","Pin: liman",
"Pin: orman (5 tier)","Pin: maden (3 tier)","Pin: su (göl/kıyı)","Pin: çayır & av",
"Oyuncu konumu işareti",
"Köy","Pazar","Liman","Fısıltı Ormanı","Gümüş Çamlık","Akça Koru",
"Kadim Orman","Karanlık Orman","Taş Ocağı","Derin Maden","Ejder Dağı",
"Aynalı Göl","Fırtına Kıyısı","Çayırlar","Av Toprakları",
"Haydut Pususu","Yaralı Gezgin","Kayıp Sandık",
]);
// Kısmen bağlananlar: her tarayıcıda "yapılıyor" görünür (tamam'ı ezmez)
const SERVER_WIP=new Set([]);
data.forEach(c=>c.items.forEach(i=>{
if(SERVER_DONE.has(i.name)) i.state="tamam";
else if(SERVER_WIP.has(i.name) && i.state!=="tamam") i.state="yapiliyor";
}));
save(data);
return data;
}
function save(d){ localStorage.setItem(KEY,JSON.stringify(d)); }
let DATA=load();
let FILT="all";
// ---- Giriş + satırdan upload (up_<asset adı>.<ext> olarak kaydedilir) ----
const API="https://api.stepstead.com";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
let ADMIN=false;
let PENDING=null; // {c,i} — hangi asset için dosya seçiliyor
function toast(msg,ms=3500){
const t=document.getElementById("toast");
t.textContent=msg; t.classList.add("on");
clearTimeout(t._tm); t._tm=setTimeout(()=>t.classList.remove("on"),ms);
}
function updateAuthUI(){
document.getElementById("authBtn").textContent=ADMIN?"🚪 Çıkış":"🔑 Giriş";
if(window.ssNavRefresh)window.ssNavRefresh();
}
async function checkAdmin(){
if(!TOKEN){ADMIN=false;updateAuthUI();return;}
try{
const r=await fetch(API+"/admin/uploads",{headers:{Authorization:"Bearer "+TOKEN}});
ADMIN=r.ok;
if(!r.ok&&(r.status===401||r.status===403)){TOKEN="";localStorage.removeItem("ss_upload_token");}
}catch(e){ADMIN=false;}
updateAuthUI(); render();
}
async function doLogin(){
const m=document.getElementById("lgMsg"); m.textContent="…";
try{
const r=await fetch(API+"/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({email:document.getElementById("lgEmail").value.trim(),
password:document.getElementById("lgPass").value})});
const b=await r.json().catch(()=>({}));
if(r.ok&&b.access_token){
TOKEN=b.access_token; localStorage.setItem("ss_upload_token",TOKEN);
m.textContent=""; document.getElementById("loginCard").classList.add("hide");
await checkAdmin();
if(!ADMIN) toast("⚠ Bu hesabın yükleme yetkisi yok");
}else m.textContent=b.detail||"Giriş başarısız";
}catch(e){ m.textContent="Bağlantı hatası"; }
}
async function uploadFor(c,i,file){
if(!file) return;
const it=DATA[c].items[i];
toast("⏳ Yükleniyor: "+it.name);
const fd=new FormData();
fd.append("file",file);
fd.append("target_name",it.name);
try{
const r=await fetch(API+"/admin/upload",{method:"POST",
headers:{Authorization:"Bearer "+TOKEN},body:fd});
const b=await r.json().catch(()=>({}));
if(r.ok){
if(it.state!=="tamam") it.state="yapiliyor"; // kontrol bekliyor
save(DATA); render();
toast("✅ Yüklendi: "+b.filename+" — kontrol edilecek");
}else toast("⚠ "+(b.detail||("Hata "+r.status)));
}catch(e){ toast("⚠ Bağlantı hatası"); }
}
document.getElementById("authBtn").onclick=()=>{
if(ADMIN){
TOKEN=""; localStorage.removeItem("ss_upload_token");
ADMIN=false; updateAuthUI(); render(); toast("Çıkış yapıldı");
}else document.getElementById("loginCard").classList.toggle("hide");
};
document.getElementById("lgBtn").onclick=doLogin;
document.getElementById("lgPass").addEventListener("keydown",e=>{if(e.key==="Enter")doLogin();});
const upInput=document.createElement("input");
upInput.type="file"; upInput.accept=".png,.jpg,.jpeg,.webp,.svg,.json,.zip"; upInput.hidden=true;
document.body.appendChild(upInput);
upInput.onchange=e=>{
if(PENDING) uploadFor(PENDING.c,PENDING.i,e.target.files[0]);
PENDING=null; upInput.value="";
};
function visible(it){ return FILT==="all" || it.dim===FILT; }
function render(){
const root=document.getElementById("root");
root.innerHTML="";
let total=0,done=0;
DATA.forEach((cat,ci)=>{
const shown=cat.items.filter(visible);
const cTotal=shown.length;
const cDone=shown.filter(i=>i.state==="tamam").length;
const cWip=shown.filter(i=>i.state==="yapiliyor").length;
const cEksik=cTotal-cDone-cWip;
const c2=cat.items.filter(i=>i.dim==="2d").length;
const c3=cat.items.filter(i=>i.dim==="3d").length;
total+=cTotal; done+=cDone;
const det=document.createElement("details");
det.className="cat";
if(FILT!=="all" && cTotal===0){ det.classList.add("hide"); }
const sum=document.createElement("summary");
sum.innerHTML=`<span class="chev"></span>${cat.cat}
<span class="count">
${c2?`<span class="pill d2">${c2} 2D</span>`:""}
${c3?`<span class="pill d3">${c3} 3D</span>`:""}
<span class="pill tamam">${cDone} ✓</span>
<span class="pill eksik">${cEksik} eksik</span>
</span>`;
det.appendChild(sum);
const tbl=document.createElement("table");
tbl.innerHTML=`<thead><tr><th>Asset</th><th>ıklama</th><th>Boyut</th><th>Durum</th><th></th></tr></thead>`;
const tb=document.createElement("tbody");
cat.items.forEach((it,ii)=>{
if(!visible(it)) return;
const tr=document.createElement("tr");
if(it.state==="tamam") tr.className="done";
tr.innerHTML=`<td class="name">${esc(it.name)}</td>
<td class="desc">${esc(it.desc||"")}</td>
<td><select class="dm ${DMCLS[it.dim]}" data-c="${ci}" data-i="${ii}">
${DIMS.map(d=>`<option value="${d}" ${d===it.dim?"selected":""}>${DMLBL[d]}</option>`).join("")}
</select></td>
<td><select class="st ${it.state}" data-c="${ci}" data-i="${ii}">
${STATES.map(s=>`<option value="${s}" ${s===it.state?"selected":""}>${STLBL[s]}</option>`).join("")}
</select></td>
<td style="white-space:nowrap">${ADMIN?`<button class="up" data-c="${ci}" data-i="${ii}" title="Bu asset için görsel yükle">📤</button> `:""}<button class="del" data-c="${ci}" data-i="${ii}" title="Sil"></button></td>`;
tb.appendChild(tr);
});
const addtr=document.createElement("tr");
addtr.className="addrow";
addtr.innerHTML=`<td colspan="5">
<input class="n" placeholder="Yeni asset adı" data-add-n="${ci}">
<input class="d" placeholder="Açıklama (ops.)" data-add-d="${ci}">
<select data-add-dim="${ci}">${DIMS.map(d=>`<option value="${d}">${DMLBL[d]}</option>`).join("")}</select>
<button class="btn" data-add="${ci}">+ Ekle</button></td>`;
tb.appendChild(addtr);
tbl.appendChild(tb);
det.appendChild(tbl);
root.appendChild(det);
});
const pct=total?Math.round(done/total*100):0;
document.getElementById("pbar").style.width=pct+"%";
const fl=FILT==="all"?"":` (${DMLBL[FILT]})`;
document.getElementById("plbl").textContent=`${done}/${total} tamamlandı${fl} — %${pct}`;
}
function esc(s){return (s+"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));}
document.addEventListener("change",e=>{
const c=+e.target.dataset.c, i=+e.target.dataset.i;
if(e.target.classList.contains("st")){
DATA[c].items[i].state=e.target.value; save(DATA); render();
}
if(e.target.classList.contains("dm")){
DATA[c].items[i].dim=e.target.value; save(DATA); render();
}
});
document.addEventListener("click",e=>{
if(e.target.dataset.filt){
FILT=e.target.dataset.filt;
document.querySelectorAll("[data-filt]").forEach(b=>b.classList.toggle("on",b.dataset.filt===FILT));
render(); return;
}
if(e.target.classList.contains("up")){
PENDING={c:+e.target.dataset.c,i:+e.target.dataset.i};
upInput.click(); return;
}
if(e.target.classList.contains("del")){
const c=+e.target.dataset.c, i=+e.target.dataset.i;
if(confirm("Bu asset satırı silinsin mi?")){ DATA[c].items.splice(i,1); save(DATA); render(); }
}
if(e.target.dataset.add!==undefined){
const c=+e.target.dataset.add;
const ni=document.querySelector(`[data-add-n="${c}"]`);
const di=document.querySelector(`[data-add-d="${c}"]`);
const dm=document.querySelector(`[data-add-dim="${c}"]`);
const name=ni.value.trim(); if(!name) return;
DATA[c].items.push({name,desc:di.value.trim(),dim:dm.value,state:"eksik"});
save(DATA); render();
document.querySelectorAll(".cat")[c].open=true;
}
});
document.getElementById("expBtn").onclick=()=>{
const blob=new Blob([JSON.stringify(DATA,null,2)],{type:"application/json"});
const a=document.createElement("a");
a.href=URL.createObjectURL(blob);
a.download="stepstead-assets.json"; a.click();
};
document.getElementById("impBtn").onclick=()=>document.getElementById("impFile").click();
document.getElementById("impFile").onchange=ev=>{
const f=ev.target.files[0]; if(!f) return;
const r=new FileReader();
r.onload=()=>{ try{ const d=JSON.parse(r.result);
if(Array.isArray(d)){ DATA=d; load.call&&0; DATA.forEach(c=>c.items.forEach(i=>{if(!i.dim)i.dim="2d";})); save(DATA); render(); } else alert("Geçersiz dosya"); }
catch(e){ alert("Okunamadı"); } };
r.readAsText(f);
};
render();
checkAdmin();
</script>
</body>
</html>

451
assets/lore.html Normal file
View file

@ -0,0 +1,451 @@
<!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 — Lore Notları</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.6}
.wrap{max-width:1240px;margin:0 auto;padding:20px 16px}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
@media(max-width:900px){.cols{grid-template-columns:1fr}}
.summary{white-space:pre-wrap;word-wrap:break-word;background:#141c28;border:1px solid var(--line);border-radius:8px;padding:14px;min-height:200px;font-size:14px}
.summary h3{color:var(--gold);font-size:15px;margin:16px 0 6px;border-bottom:1px solid var(--line);padding-bottom:4px}
.summary h3:first-child{margin-top:0}
.summary p{margin:0 0 8px}
.colhead{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.colhead h2{font-size:15px;color:var(--gold);margin:0}
.colhead .count{margin-left:auto}
h1{font-size:20px;color:var(--gold);margin:0 0 2px}
.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:14px}
input,button,textarea{font-size:14px;border-radius:6px;font-family:inherit}
input,textarea,select{background:#222d3d;color:var(--txt);border:1px solid var(--line);padding:9px 10px}
select{border-radius:6px;font-size:13px;font-family:inherit;max-width:220px}
.note .subj{background:#243247;color:#7fb3e8;border-radius:4px;padding:1px 7px;font-weight:600;cursor:pointer}
.note .subj:hover{color:var(--gold)}
input[type=email],input[type=password]{width:100%;margin-bottom:10px}
textarea{width:100%;min-height:90px;resize:vertical;line-height:1.5}
.btn{background:var(--gold);color:#1a1300;border:none;padding:9px 18px;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}
.msg.ok{color:var(--ok)} .msg.err{color:var(--err)}
.hide{display:none}
.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:10px}
.toolbar input{flex:0 0 180px}
.note{border-top:1px solid var(--line);padding:12px 2px}
.note:first-child{border-top:none}
.note .meta{display:flex;gap:10px;align-items:center;font-size:11px;color:var(--muted);margin-bottom:6px}
.note .tag{background:#243247;color:var(--gold);border-radius:4px;padding:1px 7px;font-weight:600}
.author{color:#7fd0a0;font-weight:600}
.note .body{white-space:pre-wrap;word-wrap:break-word}
.bilingual{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media(max-width:640px){.bilingual{grid-template-columns:1fr}}
.bilingual .lang{border:1px solid var(--line);border-radius:6px;padding:8px 10px;background:#141c28}
.bilingual .lang .lbl{font-size:10px;color:var(--muted);font-weight:600;letter-spacing:.5px;margin-bottom:4px}
.bilingual .lang .txt{white-space:pre-wrap;word-wrap:break-word;font-size:13px}
.bilingual .lang.en{opacity:.9}
.note .acts{margin-left:auto;display:flex;gap:2px}
.count{color:var(--muted);font-size:12px}
a{color:var(--gold)}
.story{border-top:1px solid var(--line);padding:14px 2px}
.story:first-of-type{border-top:none}
.story h3{color:var(--gold);font-size:16px;margin:0 0 4px}
.story .meta{font-size:11px;color:var(--muted);margin-bottom:8px;display:flex;gap:10px;align-items:center}
.story .meta .acts{margin-left:auto;display:flex;gap:2px}
.story .body{white-space:pre-wrap;word-wrap:break-word;line-height:1.7}
.story textarea{min-height:260px}
#stBody{min-height:260px}
#stTitle{width:100%;margin-bottom:8px}
</style>
<script src="/nav.js?v=8" data-theme="dark" defer></script>
</head>
<body>
<div class="wrap">
<h1>📜 Lore Notları</h1>
<div class="sub">Özel — sadece admin. Küçük notlar al, sonra hikaye olarak derlenir.</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 style="display:flex;justify-content:flex-end;margin-bottom:10px">
<button class="btn sec sm" id="langBtn">🌐 English</button>
</div>
<div class="card">
<div class="toolbar">
<select id="newSubj" title="Bir kişi veya yerle ilişkilendir"></select>
<input type="text" id="newTag" placeholder="etiket (ops.) örn. Eldoria">
<span class="count" id="cnt"></span>
</div>
<textarea id="newBody" placeholder="Not yaz… (kısa fikir, olay, karakter, cümle — ne olursa)"></textarea>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center">
<button class="btn" id="addBtn">+ Not Ekle</button>
<span style="color:var(--muted);font-size:12px">Ctrl/Cmd + Enter · yazınca otomatik çevrilir</span>
<div class="msg" id="addMsg" style="margin:0"></div>
</div>
</div>
<div class="card" id="listCard">
<div class="colhead"><h2>📝 Notlar</h2>
<select id="filterSubj" title="Konuya göre filtrele" style="margin-left:auto"></select>
</div>
<div id="notes"></div>
<div class="count" id="empty">Yükleniyor…</div>
</div>
<div class="card">
<div class="colhead"><h2>📖 Temel Hikaye</h2><span class="count" id="stCnt"></span></div>
<div id="stories"></div>
<div class="count" id="stEmpty">Yükleniyor…</div>
</div>
<div class="card">
<h2 style="font-size:15px;color:var(--gold);margin:0 0 10px">+ Yeni Bölüm</h2>
<input type="text" id="stTitle" placeholder="Bölüm başlığı (ops.) örn. Eldoria'nın Doğuşu">
<textarea id="stBody" placeholder="Uzun hikaye metnini buraya yaz…"></textarea>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center">
<button class="btn" id="stAddBtn">+ Bölüm Ekle</button>
<span style="color:var(--muted);font-size:12px">yazınca otomatik çevrilir</span>
<div class="msg" id="stMsg" style="margin:0"></div>
</div>
</div>
</div>
</div>
<script>
const API="https://api.stepstead.com";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
let notes=[];
let editId=null;
let ME={id:null,admin:false};
function canEdit(item){return ME.admin || (item.author_id!=null && item.author_id===ME.id);}
const $=id=>document.getElementById(id);
/* ---- Konu (kişi/yer) bağlama ---- */
let SUBJECTS={npc:[],place:[]};
let filterKey=""; // "", "npc:12", "place:3"
const KIND_TR={npc:"Kişiler",place:"Yerler"};
function subjKey(kind,id){return (kind&&id)?kind+":"+id:"";}
function subjLabel(kind,id){
const arr=SUBJECTS[kind]||[];const m=arr.find(x=>x.id===id);return m?m.label:null;
}
async function loadSubjects(){
const r=await api("/lore/subjects");
if(r.ok)SUBJECTS={npc:r.body.npc||[],place:r.body.place||[]};
buildSubjSelect($("newSubj"),"","",true,"— konu yok —");
buildFilterSelect();
}
// select'i optgroup'larla doldur. cur = "kind:id" seçili.
function buildSubjSelect(sel,curKind,curId,withNone,noneLabel){
if(!sel)return;
const cur=subjKey(curKind,curId);
sel.innerHTML="";
if(withNone){const o=document.createElement("option");o.value="";o.textContent=noneLabel||"— konu yok —";sel.appendChild(o);}
for(const kind of ["npc","place"]){
if(!SUBJECTS[kind].length)continue;
const og=document.createElement("optgroup");og.label=KIND_TR[kind];
for(const s of SUBJECTS[kind]){
const o=document.createElement("option");o.value=kind+":"+s.id;
o.textContent=s.label+(s.sub?` · ${s.sub}`:"");
if(o.value===cur)o.selected=true;
og.appendChild(o);
}
sel.appendChild(og);
}
}
function buildFilterSelect(){
const sel=$("filterSubj");if(!sel)return;
const prev=filterKey;sel.innerHTML="";
const o0=document.createElement("option");o0.value="";o0.textContent="Tüm konular";sel.appendChild(o0);
for(const kind of ["npc","place"]){
if(!SUBJECTS[kind].length)continue;
const og=document.createElement("optgroup");og.label=KIND_TR[kind];
for(const s of SUBJECTS[kind]){
const o=document.createElement("option");o.value=kind+":"+s.id;o.textContent=s.label;
if(o.value===prev)o.selected=true;og.appendChild(o);
}
sel.appendChild(og);
}
sel.onchange=()=>{filterKey=sel.value;render();};
}
function readSubj(sel){
const v=sel&&sel.value?sel.value:"";
if(!v)return {subject_kind:null,subject_id:null,subject_label:null};
const [kind,idStr]=v.split(":");const id=parseInt(idStr,10);
return {subject_kind:kind,subject_id:id,subject_label:subjLabel(kind,id)};
}
let LANG=localStorage.getItem("ss_lore_lang")||"tr";
function pickLang(item){return LANG==="en"?(item.body_en||item.body):item.body;}
function setLang(l){LANG=l;localStorage.setItem("ss_lore_lang",l);
$("langBtn").textContent=LANG==="tr"?"🌐 English":"🌐 Türkçe";
render();renderStories();}
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};
}
function esc(s){return (s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));}
function fmtDate(iso){const d=new Date(iso);return d.toLocaleString("tr-TR",{day:"2-digit",month:"short",hour:"2-digit",minute:"2-digit"});}
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);showApp();}
else{$("loginMsg").className="msg err";$("loginMsg").textContent=b.detail||"Giriş başarısız";}
}
async function showApp(){
const r=await api("/lore");
if(r.status===401||r.status===403){$("loginMsg").className="msg err";$("loginMsg").textContent="Yetki yok / oturum bitti";return;}
$("loginCard").classList.add("hide");$("app").classList.remove("hide");
$("langBtn").textContent=LANG==="tr"?"🌐 English":"🌐 Türkçe";
const me=await api("/auth/me");
if(me.ok){ME.id=me.body.id;ME.admin=me.body.is_admin||(me.body.permissions||[]).includes("*");}
await loadSubjects();
notes=r.ok?r.body:[];render();
await loadStories();
}
/* ---- Temel Hikaye ---- */
let stories=[],stEditId=null;
async function loadStories(){const r=await api("/lore/story");stories=r.ok?r.body:[];renderStories();}
function renderStories(){
const box=$("stories");box.innerHTML="";
$("stCnt").textContent=stories.length+" bölüm";
$("stEmpty").style.display=stories.length?"none":"block";
$("stEmpty").textContent="Henüz bölüm yok — aşağıdan ilk bölümü ekle.";
for(const s of stories){
const div=document.createElement("div");div.className="story";
if(stEditId===s.id){
div.innerHTML=`<input type="text" class="etitle" value="${esc(s.title||"")}" placeholder="Bölüm başlığı" style="width:100%;margin-bottom:8px">
<div class="lbl" style="font-size:10px;color:var(--muted);margin-bottom:3px">TÜRKÇE</div>
<textarea class="ebody">${esc(s.body)}</textarea>
<div class="lbl" style="font-size:10px;color:var(--muted);margin:8px 0 3px">ENGLISH</div>
<textarea class="ebodyen">${esc(s.body_en||"")}</textarea>
<div style="margin-top:8px;display:flex;gap:6px;align-items:center">
<button class="btn sm save">Kaydet</button>
<button class="btn sec sm retr">🌐 Yeniden Çevir</button>
<button class="btn sec sm cancel">Vazgeç</button>
<span class="msg emsg" style="margin:0"></span>
</div>`;
div.querySelector(".save").onclick=async()=>{
const body=div.querySelector(".ebody").value.trim();
if(!body)return;
const body_en=div.querySelector(".ebodyen").value.trim()||null;
const r=await api("/lore/story/"+s.id,{method:"POST",
body:JSON.stringify({title:div.querySelector(".etitle").value.trim()||null,body,body_en})});
if(!r.ok){alert("Kaydedilemedi");return;}
stEditId=null;await loadStories();
};
div.querySelector(".retr").onclick=async()=>{
const em=div.querySelector(".emsg");em.className="msg";em.textContent="Çeviriliyor…";
try{const p=await translatePair(div.querySelector(".ebody").value.trim());
div.querySelector(".ebody").value=p.tr;div.querySelector(".ebodyen").value=p.en;
em.className="msg ok";em.textContent="Çevrildi";}
catch(err){em.className="msg err";em.textContent="Olmadı: "+err.message;}
};
div.querySelector(".cancel").onclick=()=>{stEditId=null;renderStories();};
}else{
div.innerHTML=`${s.title?`<h3>${esc(s.title)}</h3>`:""}
<div class="meta"><span>${fmtDate(s.updated_at)}</span>
${s.author?`<span class="author">✍ ${esc(s.author)}</span>`:""}
${canEdit(s)?`<span class="acts">
<button class="btn link edit">düzenle</button>
<button class="btn link del delx">sil</button>
</span>`:""}</div>
<div class="body">${esc(pickLang(s))}</div>`;
if(canEdit(s)){
div.querySelector(".edit").onclick=()=>{stEditId=s.id;renderStories();};
div.querySelector(".delx").onclick=async()=>{
if(!confirm("Bölüm silinsin mi?"))return;
const r=await api("/lore/story/"+s.id+"/delete",{method:"POST"});
if(!r.ok){alert("Silinemedi");return;}
await loadStories();
};
}
}
box.appendChild(div);
}
}
async function loadNotes(){const r=await api("/lore");notes=r.ok?r.body:[];render();}
function render(){
const box=$("notes");box.innerHTML="";
const shown=filterKey?notes.filter(n=>subjKey(n.subject_kind,n.subject_id)===filterKey):notes;
$("cnt").textContent=filterKey?`${shown.length}/${notes.length} not`:notes.length+" not";
$("empty").style.display=shown.length?"none":"block";
$("empty").textContent=filterKey?"Bu konuda not yok.":"Henüz not yok.";
for(const n of shown){
const div=document.createElement("div");div.className="note";
if(editId===n.id){
div.innerHTML=`<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<select class="esubj"></select>
<input type="text" class="etag" value="${esc(n.tag||"")}" placeholder="etiket" style="width:160px">
</div>
<div class="bilingual">
<div><div class="lbl" style="font-size:10px;color:var(--muted);margin-bottom:3px">TÜRKÇE</div>
<textarea class="ebody">${esc(n.body)}</textarea></div>
<div><div class="lbl" style="font-size:10px;color:var(--muted);margin-bottom:3px">ENGLISH</div>
<textarea class="ebodyen">${esc(n.body_en||"")}</textarea></div>
</div>
<div style="margin-top:8px;display:flex;gap:6px;align-items:center">
<button class="btn sm save">Kaydet</button>
<button class="btn sec sm retr">🌐 Yeniden Çevir</button>
<button class="btn sec sm cancel">Vazgeç</button>
<span class="msg emsg" style="margin:0"></span>
</div>`;
buildSubjSelect(div.querySelector(".esubj"),n.subject_kind,n.subject_id,true,"— konu yok —");
div.querySelector(".save").onclick=()=>saveEdit(n.id,div);
div.querySelector(".cancel").onclick=()=>{editId=null;render();};
div.querySelector(".retr").onclick=async(e)=>{
const em=div.querySelector(".emsg");em.className="msg";em.textContent="Çeviriliyor…";
try{const p=await translatePair(div.querySelector(".ebody").value.trim());
div.querySelector(".ebody").value=p.tr;div.querySelector(".ebodyen").value=p.en;
em.className="msg ok";em.textContent="Çevrildi";}
catch(err){em.className="msg err";em.textContent="Olmadı: "+err.message;}
};
}else{
const subjChip=n.subject_kind&&n.subject_id
? `<span class="subj" data-k="${subjKey(n.subject_kind,n.subject_id)}">${n.subject_kind==="npc"?"👤":"📍"} ${esc(n.subject_label||subjLabel(n.subject_kind,n.subject_id)||"?")}</span>`:"";
div.innerHTML=`<div class="meta">
${subjChip}
${n.tag?`<span class="tag">${esc(n.tag)}</span>`:""}
<span>${fmtDate(n.created_at)}</span>
${n.author?`<span class="author">✍ ${esc(n.author)}</span>`:""}
${canEdit(n)?`<span class="acts">
<button class="btn link edit">düzenle</button>
<button class="btn link del delx">sil</button>
</span>`:""}</div>
<div class="body">${esc(pickLang(n))}</div>`;
const sc=div.querySelector(".subj");
if(sc)sc.onclick=()=>{filterKey=sc.dataset.k;$("filterSubj").value=filterKey;render();};
if(canEdit(n)){
div.querySelector(".edit").onclick=()=>{editId=n.id;render();};
div.querySelector(".delx").onclick=()=>delNote(n.id);
}
}
box.appendChild(div);
}
}
async function add(){
const input=$("newBody").value.trim();
if(!input){return;}
$("addMsg").className="msg";$("addMsg").textContent="Çeviriliyor…";
let tr,en;
try{const p=await translatePair(input);tr=p.tr;en=p.en;}
catch(e){$("addMsg").className="msg err";$("addMsg").textContent="Çeviri olmadı: "+e.message;return;}
$("addMsg").textContent="Kaydediliyor…";
const subj=readSubj($("newSubj"));
const r=await api("/lore",{method:"POST",body:JSON.stringify(Object.assign(
{body:tr,body_en:en,tag:$("newTag").value.trim()||null},subj))});
if(!r.ok){$("addMsg").className="msg err";$("addMsg").textContent=r.body.detail||"Hata";return;}
$("newBody").value="";$("newTag").value="";$("newSubj").value="";
$("addMsg").className="msg ok";$("addMsg").textContent="Eklendi (TR + EN)";
setTimeout(()=>$("addMsg").textContent="",1500);
await loadNotes();
}
async function saveEdit(id,div){
const body=div.querySelector(".ebody").value.trim();
const body_en=div.querySelector(".ebodyen").value.trim()||null;
if(!body)return;
const subj=readSubj(div.querySelector(".esubj"));
const r=await api("/lore/"+id,{method:"POST",body:JSON.stringify(Object.assign(
{body,body_en,tag:div.querySelector(".etag").value.trim()||null},subj))});
if(!r.ok){alert("Kaydedilemedi");return;}
editId=null;await loadNotes();
}
async function delNote(id){
if(!confirm("Not silinsin mi?"))return;
const r=await api("/lore/"+id+"/delete",{method:"POST"});
if(!r.ok){alert("Silinemedi");return;}
await loadNotes();
}
/* ---- Çeviri (TR↔EN, otomatik yön) ---- */
function detectLang(t){
if(/[çğıöşüÇĞİÖŞÜ]/.test(t))return "tr";
const w=(t.toLowerCase().match(/\b(ve|bir|bu|için|ile|çok|ama|değil|ben|sen|olan|gibi|daha|kadar|olarak|sonra)\b/g)||[]).length;
return w>=2?"tr":"en";
}
function chunk(t,max=450){
const parts=t.split(/(?<=[.!?\n])\s+/);const out=[];let cur="";
for(const p of parts){
if((cur+" "+p).length>max){if(cur)out.push(cur);cur=p.length>max?p:p;
while(cur.length>max){out.push(cur.slice(0,max));cur=cur.slice(max);}}
else cur=cur?cur+" "+p:p;
}
if(cur)out.push(cur);return out;
}
async function mmTranslate(text,src,tgt){
const r=await fetch(`https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${src}|${tgt}&de=joethesleeper84@gmail.com`);
const b=await r.json();
if(!b.responseData||!b.responseData.translatedText)throw new Error(b.responseDetails||"çeviri hatası");
return b.responseData.translatedText;
}
async function translateAll(text,src,tgt){
const outs=[];for(const c of chunk(text))outs.push(await mmTranslate(c,src,tgt));
return outs.join(" ");
}
// Girdiyi TR+EN çifte çevir; sol her zaman TR, sağ her zaman EN.
async function translatePair(input){
if(!input)throw new Error("boş metin");
const src=detectLang(input);
if(src==="tr")return {tr:input,en:await translateAll(input,"tr","en")};
return {en:input,tr:await translateAll(input,"en","tr")};
}
async function translateBox(taId,msgId){
const ta=$(taId),msg=$(msgId);
const text=ta.value.trim();
if(!text){msg.className="msg err";msg.textContent="Metin boş";return;}
const src=detectLang(text),tgt=src==="tr"?"en":"tr";
msg.className="msg";msg.textContent=`Çeviriliyor… (${src.toUpperCase()}→${tgt.toUpperCase()})`;
try{
const outs=[];
for(const c of chunk(text))outs.push(await mmTranslate(c,src,tgt));
ta.value=text+"\n\n--- "+tgt.toUpperCase()+" ---\n"+outs.join(" ");
msg.className="msg ok";msg.textContent=`Çevrildi (${src.toUpperCase()}→${tgt.toUpperCase()})`;
setTimeout(()=>msg.textContent="",2000);
}catch(e){msg.className="msg err";msg.textContent="Çeviri olmadı: "+e.message;}
}
$("langBtn").onclick=()=>setLang(LANG==="tr"?"en":"tr");
$("loginBtn").onclick=login;
$("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login();});
$("addBtn").onclick=add;
$("newBody").addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&e.key==="Enter")add();});
$("stAddBtn").onclick=async()=>{
const input=$("stBody").value.trim();
if(!input){$("stMsg").className="msg err";$("stMsg").textContent="Metin boş";return;}
$("stMsg").className="msg";$("stMsg").textContent="Çeviriliyor…";
let tr,en;
try{const p=await translatePair(input);tr=p.tr;en=p.en;}
catch(e){$("stMsg").className="msg err";$("stMsg").textContent="Çeviri olmadı: "+e.message;return;}
$("stMsg").textContent="Kaydediliyor…";
const r=await api("/lore/story",{method:"POST",
body:JSON.stringify({title:$("stTitle").value.trim()||null,body:tr,body_en:en})});
if(!r.ok){$("stMsg").className="msg err";$("stMsg").textContent=r.body.detail||"Hata";return;}
$("stTitle").value="";$("stBody").value="";
$("stMsg").className="msg ok";$("stMsg").textContent="Bölüm eklendi (TR + EN)";
setTimeout(()=>$("stMsg").textContent="",1500);
await loadStories();
};
if(TOKEN)showApp();
</script>
</body>
</html>

758
assets/map-editor.html Normal file
View file

@ -0,0 +1,758 @@
<!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 — Harita Editörü</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:1500px;margin:0 auto;padding:16px}
h1{font-size:20px;color:var(--gold);margin:0 0 4px}
.sub{color:var(--muted);font-size:13px;margin-bottom:14px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:14px}
input,button,select,textarea{font-size:14px;border-radius:6px}
input,select,textarea{background:#222d3d;color:var(--txt);border:1px solid var(--line);padding:8px 10px}
input[type=email],input[type=password]{width:100%;margin-bottom:10px}
textarea{width:100%;min-height:54px;resize:vertical}
.btn{background:var(--gold);color:#1a1300;border:none;padding:8px 16px;font-weight:600;cursor:pointer}
.btn.sec{background:#2a3648;color:var(--txt)}
.btn.danger{background:var(--err);color:#fff}
.btn:disabled{opacity:.45;cursor:default}
.msg{font-size:13px;margin-top:8px}
.msg.ok{color:var(--ok)} .msg.err{color:var(--err)}
.hide{display:none}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.toolbar .grp{display:flex;gap:6px;align-items:center;background:#141c28;border:1px solid var(--line);border-radius:8px;padding:6px 8px}
.modebtn{background:#2a3648;color:var(--txt);border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:6px}
.modebtn.on{background:var(--gold);color:#1a1300;border-color:var(--gold);font-weight:600}
.maparea{position:relative;width:100%;user-select:none;line-height:0;margin-bottom:14px}
#mapview{position:relative;width:100%}
#mapzoom{position:relative;width:100%;transform-origin:0 0}
#mapimg{width:100%;display:block;border-radius:8px;border:1px solid var(--line)}
#svg{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair}
/* tam ekran düzenleme modu */
.maparea.fs{position:fixed;inset:0;z-index:2000;width:100vw;height:100vh;height:100dvh;margin:0;overflow:hidden;background:#0b0f14;display:flex;flex-direction:column}
.maparea.fs #mapview{flex:1;min-height:0;overflow:hidden;touch-action:none}
.maparea.fs #mapimg{border-radius:0;border:none}
.toolbar.fsfloat{flex:0 0 auto;margin:0;background:#141c28;border:none;border-bottom:1px solid var(--line);border-radius:0;padding:6px 8px;max-height:42vh;overflow:auto}
.maparea.fs #fsOpenBtn{display:none}
.zoomctl{display:none}
.maparea.fs .zoomctl{display:flex;position:absolute;right:10px;bottom:10px;z-index:5;flex-direction:column;gap:6px}
.zoomctl button{width:44px;height:44px;font-size:20px;font-weight:700;background:rgba(20,28,40,.92);color:var(--txt);border:1px solid var(--line);border-radius:8px;cursor:pointer;line-height:1;display:flex;align-items:center;justify-content:center}
.zoomctl button:hover{border-color:var(--gold)}
#fsClose{display:none}
.maparea.fs #fsClose{display:inline-flex}
#formCard{max-width:560px;margin-bottom:14px}
.taggroups{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;align-items:start}
@media(max-width:900px){.taggroups{grid-template-columns:1fr}}
.taggroups .card{margin-bottom:0}
.grouphead{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin-bottom:6px}
.grouphead .cnt{color:var(--muted);font-size:12px;font-weight:400}
.taglist{max-height:340px;overflow:auto}
.gempty{color:var(--muted);font-size:12px;padding:6px 2px}
.tagrow{display:flex;align-items:center;gap:8px;padding:6px 4px;border-top:1px solid var(--line);font-size:13px;cursor:pointer}
.tagrow:hover{background:#141c28}
.tagrow.sel{background:#243247}
.swatch{width:12px;height:12px;border-radius:3px;flex:0 0 12px}
.knd{color:var(--muted);font-size:11px;margin-left:auto}
.delx{flex:0 0 auto;background:none;border:none;color:var(--muted);cursor:pointer;font-size:15px;padding:0 4px;line-height:1}
.delx:hover{color:var(--err)}
.hint{color:var(--muted);font-size:12px;margin-top:8px}
label{font-size:12px;color:var(--muted);display:block;margin:8px 0 3px}
.row{display:flex;gap:8px}
.row>*{flex:1}
.regchecks{display:flex;flex-wrap:wrap;gap:6px;max-height:140px;overflow:auto;background:#141c28;border:1px solid var(--line);border-radius:6px;padding:8px}
.regchecks label{display:inline-flex;align-items:center;gap:5px;margin:0;font-size:12px;color:var(--txt);background:#222d3d;border:1px solid var(--line);border-radius:14px;padding:3px 9px;cursor:pointer}
.regchecks label:hover{border-color:var(--gold)}
.regchecks input{width:auto;margin:0}
.regchecks label.auto{border-color:var(--ok)}
.regchecks .gempty{color:var(--muted);font-size:12px}
polygon,polyline,circle,text{vector-effect:non-scaling-stroke}
#svg.draw *{pointer-events:none}
.veclabel{fill:#fff;paint-order:stroke;stroke:#000;stroke-width:3px;font-weight:600}
</style>
<script src="/nav.js?v=8" data-theme="dark" defer></script>
</head>
<body>
<div class="wrap">
<h1>🗺 Harita Editörü</h1>
<div class="sub">Haritada kıta/bölge/nokta işaretle, lokasyonlar arası yol çiz → DB'ye kaydet. Kıtalar: Eldoria · Kaldün · Meridia</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="editor" class="hide">
<div class="toolbar">
<div class="grp">
<span style="color:var(--muted);font-size:12px">Mod:</span>
<button class="modebtn on" data-mode="select">👆 Seç</button>
<button class="modebtn" data-mode="region">▱ Bölge çiz</button>
<button class="modebtn" data-mode="poi">📍 Nokta koy</button>
<button class="modebtn" data-mode="road">🛣 Yol çiz</button>
</div>
<div class="grp" id="drawCtl" style="display:none">
<span id="drawInfo" style="font-size:12px;color:var(--muted)">0 köşe</span>
<button class="modebtn" id="undoPt">↩︎ Geri al</button>
<button class="btn" id="finishBtn">✓ Bitir</button>
<button class="modebtn" id="cancelDraw">✕ İptal</button>
</div>
<div class="grp" id="roadCtl" style="display:none">
<span id="roadInfo" style="font-size:12px;color:var(--muted)">Başlangıç lokasyonuna tıkla</span>
<button class="modebtn" id="undoWp">↩︎ Geri al</button>
<button class="modebtn" id="cancelRoad">✕ İptal</button>
</div>
<div class="grp" style="gap:10px">
<span style="color:var(--muted);font-size:12px">Göster:</span>
<label style="margin:0"><input type="checkbox" id="showLabels" checked style="width:auto"> İsimler</label>
<label style="margin:0"><input type="checkbox" id="showRegions" checked style="width:auto"> Bölgeler</label>
<label style="margin:0"><input type="checkbox" id="showRoads" checked style="width:auto"> Yollar</label>
<label style="margin:0"><input type="checkbox" id="showPois" checked style="width:auto"> Noktalar</label>
</div>
<div class="grp">
<button class="btn" id="fsOpenBtn" title="Haritayı tam ekranda düzenle">🖊 Tam ekran düzenle</button>
</div>
</div>
<div class="maparea" id="maparea">
<div id="mapview">
<div id="mapzoom">
<img id="mapimg" src="/assets/world_map@4x.webp?v=2" alt="Dünya haritası">
<svg id="svg" viewBox="0 0 1 1" preserveAspectRatio="none"></svg>
</div>
</div>
<div class="zoomctl">
<button type="button" id="fsClose" title="Tam ekranı kapat" style="background:var(--gold);color:#1a1300"></button>
<button type="button" id="zoomIn" title="Yakınlaştır">+</button>
<button type="button" id="zoomOut" title="Uzaklaştır"></button>
<button type="button" id="zoomReset" title="Sığdır" style="font-size:15px"></button>
</div>
</div>
<div class="card hide" id="formCard">
<b id="formTitle" style="font-size:14px">Yeni etiket</b>
<label>İsim</label>
<input type="text" id="fName" placeholder="örn. Fısıltı Ormanı">
<div class="row">
<div>
<label>Tür</label>
<select id="fKind">
<option value="region">Bölge</option>
<option value="continent">Kıta</option>
<option value="poi">Nokta (POI)</option>
</select>
</div>
<div>
<label>Kıta</label>
<select id="fCont">
<option value=""></option>
<option>Eldoria</option>
<option>Kaldün</option>
<option>Meridia</option>
</select>
</div>
</div>
<div id="catWrap">
<label>Kategori <span style="color:var(--muted)">(tip — ikon/renk otomatik gelir)</span></label>
<select id="fCategory"><option value="">— (belirsiz) —</option></select>
</div>
<div id="regionsWrap" class="hide">
<label>Bölge(ler) — nokta hangi bölgede? <span style="color:var(--muted)">(içinde olduğu bölgeler otomatik işaretlendi)</span></label>
<div id="fRegions" class="regchecks"></div>
</div>
<label>Not (opsiyonel)</label>
<textarea id="fNote" placeholder="lore / açıklama"></textarea>
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn" id="saveBtn">Kaydet</button>
<button class="btn sec" id="closeForm">Vazgeç</button>
<button class="btn danger hide" id="delBtn" style="margin-left:auto">Sil</button>
</div>
<div class="msg" id="formMsg"></div>
</div>
<div class="card hide" id="roadForm" style="max-width:560px">
<b id="roadTitle" style="font-size:14px">Yeni yol</b>
<div style="font-size:13px;margin:8px 0" id="roadEnds"></div>
<div class="row">
<div>
<label>Adım maliyeti</label>
<input type="number" id="rCost" min="1" max="100000" value="300">
</div>
<div>
<label>Olay şansı (0-1)</label>
<input type="number" id="rEvent" min="0" max="1" step="0.05" value="0.3">
</div>
</div>
<div class="hint" id="rWpInfo">0 ara nokta</div>
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn" id="saveRoad">Kaydet</button>
<button class="btn sec" id="closeRoad">Vazgeç</button>
<button class="btn danger hide" id="delRoadBtn" style="margin-left:auto">Sil</button>
</div>
<div class="msg" id="roadMsg"></div>
</div>
<div class="taggroups">
<div class="card">
<div class="grouphead">🌍 Kıtalar <span class="cnt" id="cntContinent">0</span></div>
<div class="taglist" id="listContinent"></div>
</div>
<div class="card">
<div class="grouphead">▱ Bölgeler <span class="cnt" id="cntRegion">0</span></div>
<div class="taglist" id="listRegion"></div>
</div>
<div class="card">
<div class="grouphead">📍 Noktalar <span class="cnt" id="cntPoi">0</span></div>
<div class="taglist" id="listPoi"></div>
</div>
<div class="card">
<div class="grouphead">🛣 Yollar <span class="cnt" id="cntRoad">0</span></div>
<div class="taglist" id="listRoad"></div>
</div>
</div>
<div class="hint" id="listHint">Yükleniyor…</div>
</div>
</div>
<script>
const API="https://api.stepstead.com";
const SVGNS="http://www.w3.org/2000/svg";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
let CAN_EDIT=false; // map_edit izni — yoksa salt okunur (map_view)
let mode="select";
let tags=[]; // sunucudan gelen etiketler
let draft=[]; // çizilmekte olan köşeler [[x,y],...]
let editingId=null; // seçili/düzenlenen etiket id (null = yeni)
let draftPoint=null; // poi için tek nokta
let locs=[]; // lokasyonlar (yol uçları) — {id,code,name,type,x,y}
let roads=[]; // yollar — {id,a,b,step_cost,event_chance,waypoints}
let roadA=null,roadB=null;// çizilen yolun uç lokasyon code'ları
let roadWps=[]; // çizilen yolun ara noktaları [[x,y],...]
let roadEditId=null; // düzenlenen yol id (null = yeni)
const $=id=>document.getElementById(id);
const CONT_COLOR={Eldoria:"#3fa34d",["Kaldün"]:"#e0632a",Meridia:"#3a7bd5"};
let CATS={}; // kategori kataloğu {key:{kind,label,icon,color}}
let VENDOR_CATS=[],QUEST_CATS=[];
function cat(k){return k&&CATS[k]?CATS[k]:null;}
function catIcon(k){return cat(k)?cat(k).icon:"";}
function catLabel(k){return cat(k)?cat(k).label:"";}
function colorFor(t){return t.color||(cat(t.category)?cat(t.category).color:null)||CONT_COLOR[t.continent]||"#9aa7b5";}
function catOptions(kind,selected){
let html='<option value="">— (belirsiz) —</option>';
for(const k in CATS)if(CATS[k].kind===kind)
html+=`<option value="${k}"${k===selected?" selected":""}>${CATS[k].icon} ${esc(CATS[k].label)}</option>`;
return html;
}
async function loadCats(){
const r=await api("/map-tags/categories");
if(r.ok){CATS=r.body.categories||{};VENDOR_CATS=r.body.vendor||[];QUEST_CATS=r.body.questgiver||[];}
}
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};
}
/* ---- auth ---- */
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);showEditor();}
else{$("loginMsg").className="msg err";$("loginMsg").textContent=b.detail||"Giriş başarısız";}
}
async function showEditor(){
// izin kontrolü: map_edit → tam yetki, map_view → salt okunur, yoksa red
try{
const r=await fetch(API+"/auth/me",{headers:{Authorization:"Bearer "+TOKEN}});
if(!r.ok){
if(r.status===401){TOKEN="";localStorage.removeItem("ss_upload_token");}
$("loginMsg").className="msg err";$("loginMsg").textContent="Oturum geçersiz — tekrar giriş yap";
return;
}
const u=await r.json();
const perms=u.permissions||[];
const has=p=>perms.includes("*")||perms.includes(p);
CAN_EDIT=has("map_edit");
if(!CAN_EDIT&&!has("map_view")){
$("loginMsg").className="msg err";$("loginMsg").textContent="Bu hesabın harita editörü yetkisi yok";
return;
}
}catch(e){$("loginMsg").className="msg err";$("loginMsg").textContent="Bağlantı hatası";return;}
$("loginCard").classList.add("hide");$("editor").classList.remove("hide");
if(!CAN_EDIT){
document.querySelectorAll('.modebtn[data-mode="region"],.modebtn[data-mode="poi"],.modebtn[data-mode="road"]').forEach(b=>b.style.display="none");
document.querySelector(".sub").textContent="Salt okunur görünüm — etiketleri görebilirsin, değiştiremezsin.";
}
await loadCats();
await loadTags();
await loadLocsRoads();
}
/* ---- veri ---- */
async function loadTags(){
const r=await api("/map-tags");
if(!r.ok){$("listHint").textContent="Yüklenemedi";return;}
tags=r.body;render();
}
async function loadLocsRoads(){
const [l,r]=await Promise.all([api("/map-tags/locations"),api("/map-tags/roads")]);
if(l.ok)locs=l.body;
if(r.ok)roads=r.body;
render();
}
function locByCode(c){return locs.find(l=>l.code===c);}
// Yol ismi: "A'dan B'ye" (basit Türkçe ünlü uyumu — ayrılma + yönelme eki)
function _lastVowel(w){const m=(w||"").toLowerCase().match(/[aeıioöuü]/g);return m?m[m.length-1]:"e";}
function _back(v){return "aıou".includes(v);}
function _ablative(w){ // 'dan/'den/'tan/'ten
const b=_back(_lastVowel(w)); const d=/[çfhkpsşt]$/i.test((w||"").trim())?"t":"d";
return w+"'"+d+(b?"an":"en");
}
function _dative(w){ // 'a/'e/'ya/'ye
const b=_back(_lastVowel(w)); const y=/[aeıioöuü]$/i.test((w||"").trim())?"y":"";
return w+"'"+y+(b?"a":"e");
}
function roadLabel(rd){
const a=locByCode(rd.a),b=locByCode(rd.b);
return _ablative(a?a.name:rd.a)+" "+_dative(b?b.name:rd.b);
}
// yol tipine göre renk
const LOC_COLOR={village:"#e8b923",city:"#f0872a",forest:"#3fa34d",mine:"#9aa7b5",lake:"#3a7bd5",meadow:"#8fce5a",hunting:"#c06a3a"};
function locColor(l){return LOC_COLOR[l&&l.type]||"#c9d4e0";}
// verilen normalize noktaya en yakın lokasyonu bul (eşik içinde), yoksa null
function locAt(pt){
let best=null,bd=0.022;
for(const l of locs){const d=Math.hypot(l.x-pt[0],l.y-pt[1]);if(d<bd){bd=d;best=l;}}
return best;
}
/* ---- koordinat: tıklama -> normalize 0-1 ---- */
function evtToNorm(e){
const rect=$("svg").getBoundingClientRect();
let x=(e.clientX-rect.left)/rect.width, y=(e.clientY-rect.top)/rect.height;
return [Math.min(1,Math.max(0,+x.toFixed(4))),Math.min(1,Math.max(0,+y.toFixed(4)))];
}
function centroid(pts){
let x=0,y=0;for(const p of pts){x+=p[0];y+=p[1];}return [x/pts.length,y/pts.length];
}
function pointInPoly(pt,poly){
let inside=false,x=pt[0],y=pt[1];
for(let i=0,j=poly.length-1;i<poly.length;j=i++){
const xi=poly[i][0],yi=poly[i][1],xj=poly[j][0],yj=poly[j][1];
if(((yi>y)!==(yj>y))&&(x<(xj-xi)*(y-yi)/(yj-yi)+xi))inside=!inside;
}
return inside;
}
// verilen noktayı içeren kıta etiketinin kıta adını döndür (yoksa "")
function continentAt(pt){
for(const t of tags){
if(t.kind==="continent"&&t.points.length>=3&&pointInPoly(pt,t.points))
return t.continent||t.name||"";
}
return "";
}
// verilen noktayı içeren bölge (region) id'lerini döndür — üst üste binenlerin hepsi
function regionsAt(pt){
const out=[];
for(const t of tags){
if(t.kind==="region"&&t.points.length>=3&&pointInPoly(pt,t.points))out.push(t.id);
}
return out;
}
// bölge checkbox listesini çiz (checked = seçili, auto = otomatik tespit edilenler)
function renderRegionChecks(selected,autoIds){
const wrap=$("fRegions");wrap.innerHTML="";
const regs=tags.filter(t=>t.kind==="region");
if(!regs.length){wrap.innerHTML='<div class="gempty">Henüz bölge çizilmemiş.</div>';return;}
const sel=new Set(selected||[]),auto=new Set(autoIds||[]);
for(const r of regs){
const lb=document.createElement("label");
if(auto.has(r.id))lb.className="auto";
lb.innerHTML=`<input type="checkbox" value="${r.id}" ${sel.has(r.id)?"checked":""} ${CAN_EDIT?"":"disabled"}>
<span>${esc(r.name)}${r.continent?" · "+esc(r.continent):""}</span>`;
wrap.appendChild(lb);
}
}
function selectedRegions(){
return [...$("fRegions").querySelectorAll("input:checked")].map(i=>+i.value);
}
function toggleRegionsWrap(kind){
$("regionsWrap").classList.toggle("hide",kind!=="poi");
}
/* ---- render ---- */
function render(){
const svg=$("svg");svg.innerHTML="";
const showLbl=$("showLabels").checked;
// kayıtlı yollar (en altta) — lokasyon uçları + ara noktalardan geçen polyline
if($("showRoads").checked)
for(const rd of roads){
const a=locByCode(rd.a),b=locByCode(rd.b);if(!a||!b)continue;
const pts=[[a.x,a.y],...(rd.waypoints||[]),[b.x,b.y]];
const sel=rd.id===roadEditId;
const ln=el("polyline",{points:pts.map(p=>p.join(",")).join(" "),fill:"none",
stroke:sel?"#fff":"#b98a3a","stroke-width":sel?3.2:2});
ln.setAttribute("pointer-events",mode==="select"?"auto":"none");
ln.style.cursor="pointer";ln.onclick=ev=>{ev.stopPropagation();selectRoad(rd);};
svg.appendChild(ln);
}
// kayıtlı etiketler
for(const t of tags){
if(t.kind==="poi" && !$("showPois").checked) continue;
if(t.kind!=="poi" && !$("showRegions").checked) continue;
const c=colorFor(t),sel=t.id===editingId;
if(t.kind==="poi"){
const [x,y]=t.points[0];
const dot=el("circle",{cx:x,cy:y,r:0.008,fill:c,stroke:sel?"#fff":"#000","stroke-width":sel?2:1});
dot.style.cursor="pointer";dot.onclick=ev=>{ev.stopPropagation();selectTag(t);};
svg.appendChild(dot);
if(showLbl)svg.appendChild(text(x+0.012,y+0.004,(catIcon(t.category)?catIcon(t.category)+" ":"")+t.name));
}else{
const poly=el("polygon",{points:t.points.map(p=>p.join(",")).join(" "),
fill:c+"33",stroke:c,"stroke-width":sel?3:1.6});
poly.style.cursor="pointer";poly.onclick=ev=>{ev.stopPropagation();selectTag(t);};
svg.appendChild(poly);
if(showLbl){const [cx,cy]=centroid(t.points);svg.appendChild(text(cx,cy,(catIcon(t.category)?catIcon(t.category)+" ":"")+t.name,"middle"));}
}
}
// çizim taslağı
if(draft.length){
svg.appendChild(el("polyline",{points:draft.map(p=>p.join(",")).join(" "),
fill:"none",stroke:"#e8b923","stroke-width":2,"stroke-dasharray":"4 3"}));
for(const p of draft)svg.appendChild(el("circle",{cx:p[0],cy:p[1],r:0.006,fill:"#e8b923"}));
}
if(draftPoint)svg.appendChild(el("circle",{cx:draftPoint[0],cy:draftPoint[1],r:0.008,fill:"#e8b923",stroke:"#000"}));
// lokasyon pinleri (yol uçları) — yol modunda tıklanabilir, diğerlerinde referans
for(const l of locs){
const isEnd=mode==="road"&&(l.code===roadA||l.code===roadB);
const c=el("circle",{cx:l.x,cy:l.y,r:0.0075,fill:locColor(l),
stroke:isEnd?"#fff":"#0a0f16","stroke-width":isEnd?2.6:1.4});
c.setAttribute("pointer-events",mode==="road"?"auto":"none");
if(mode==="road"){c.style.cursor="pointer";c.onclick=ev=>{ev.stopPropagation();pickRoadEnd(l);};}
svg.appendChild(c);
if(showLbl&&mode==="road")svg.appendChild(text(l.x,l.y-0.016,l.name,"middle"));
}
// çizilen yol taslağı
if(mode==="road"&&roadA){
const a=locByCode(roadA);
const pts=[[a.x,a.y],...roadWps];
if(roadB)pts.push([locByCode(roadB).x,locByCode(roadB).y]);
svg.appendChild(el("polyline",{points:pts.map(p=>p.join(",")).join(" "),fill:"none",
stroke:"#e8b923","stroke-width":2.5,"stroke-dasharray":"4 3"}));
for(const p of roadWps)svg.appendChild(el("circle",{cx:p[0],cy:p[1],r:0.005,fill:"#e8b923"}));
}
// liste — türe göre 3 grup
const lists={continent:$("listContinent"),region:$("listRegion"),poi:$("listPoi")};
const counts={continent:0,region:0,poi:0};
for(const k in lists)lists[k].innerHTML="";
$("listHint").textContent=tags.length?"":"Henüz etiket yok. Bir mod seç, haritaya çiz.";
// zone↔pin çapraz: her bölgenin içindeki pinler + her pin'in bölgesi
const pinsByRegion={},tagById={};
for(const t of tags)tagById[t.id]=t;
for(const t of tags)if(t.kind==="poi")for(const rid of (t.region_ids||[])){(pinsByRegion[rid]=pinsByRegion[rid]||[]).push(t);}
for(const t of tags){
const row=document.createElement("div");
row.className="tagrow"+(t.id===editingId?" sel":"");
let meta=t.category?(catIcon(t.category)+" "+catLabel(t.category)):"";
if(t.kind==="region"){const ps=pinsByRegion[t.id]||[];if(ps.length)meta+=` · içinde ${ps.map(p=>catIcon(p.category)||"📍").join("")} ${ps.length}`;}
if(t.kind==="poi"){const zs=(t.region_ids||[]).map(id=>tagById[id]).filter(Boolean);if(zs.length)meta+=" · ⤷ "+zs.map(z=>esc(z.name)).join(", ");}
if(t.continent)meta+=(meta?" · ":"")+esc(t.continent);
row.innerHTML=`<span class="swatch" style="background:${colorFor(t)}"></span>
<span>${catIcon(t.category)?catIcon(t.category)+" ":""}${esc(t.name)}</span><span class="knd">${meta}</span>
${CAN_EDIT?'<button class="delx" title="Sil"></button>':""}`;
const dx=row.querySelector(".delx");
if(dx)dx.onclick=ev=>{ev.stopPropagation();delTag(t.id,t.name);};
row.onclick=()=>selectTag(t);
const k=lists[t.kind]?t.kind:"region";
lists[k].appendChild(row);counts[k]++;
}
$("cntContinent").textContent=counts.continent;
$("cntRegion").textContent=counts.region;
$("cntPoi").textContent=counts.poi;
for(const k in lists)
if(!counts[k])lists[k].innerHTML='<div class="gempty">Boş</div>';
// Yollar listesi — "A'dan B'ye"
const lr=$("listRoad");lr.innerHTML="";
for(const rd of roads){
const wpn=(rd.waypoints||[]).length;
const row=document.createElement("div");
row.className="tagrow"+(rd.id===roadEditId?" sel":"");
row.innerHTML=`<span class="swatch" style="background:#b98a3a"></span>
<span>${esc(roadLabel(rd))}</span>
<span class="knd">${rd.step_cost}👣${wpn?" · "+wpn+" nokta":""}</span>
${CAN_EDIT?'<button class="delx" title="Sil"></button>':""}`;
const dx=row.querySelector(".delx");
if(dx)dx.onclick=ev=>{ev.stopPropagation();delRoad(rd.id);};
row.onclick=()=>selectRoad(rd);
lr.appendChild(row);
}
$("cntRoad").textContent=roads.length;
if(!roads.length)lr.innerHTML='<div class="gempty">Boş — 🛣 Yol çiz moduyla ekle</div>';
$("drawInfo").textContent=draft.length+" köşe";
$("finishBtn").disabled=draft.length<3;
}
function el(tag,attrs){const n=document.createElementNS(SVGNS,tag);for(const k in attrs)n.setAttribute(k,attrs[k]);return n;}
function text(x,y,s,anchor){const t=el("text",{x,y,class:"veclabel","text-anchor":anchor||"start"});t.setAttribute("font-size","0.02");t.textContent=s;return t;}
function esc(s){return (s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));}
/* ---- mod ---- */
function setMode(m){
mode=m;draft=[];draftPoint=null;
if(m!=="road"){roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");}
document.querySelectorAll(".modebtn[data-mode]").forEach(b=>b.classList.toggle("on",b.dataset.mode===m));
$("drawCtl").style.display=(m==="region")?"flex":"none";
$("roadCtl").style.display=(m==="road")?"flex":"none";
// 'draw' sınıfı çocukların pointer-event'lerini kapatır — yol modunda pinler tıklanabilir kalmalı
$("svg").classList.toggle("draw",m==="region"||m==="poi");
$("svg").style.cursor=(m==="select")?"pointer":"crosshair";
if(m==="road")updateRoadInfo();
render();
}
function updateRoadInfo(){
let s;
if(!roadA)s="Başlangıç lokasyonuna tıkla";
else if(!roadB)s=`${nm(roadA)} → … (ara nokta koy, sonra varış lokasyonuna tıkla)`;
else s=`${nm(roadA)} → ${nm(roadB)}`;
$("roadInfo").textContent=s;
$("rWpInfo").textContent=roadWps.length+" ara nokta";
}
function nm(code){const l=locByCode(code);return l?l.name:code;}
// yol modunda lokasyon pinine tıklama: 1.=başlangıç, 2.=varış → form aç
function pickRoadEnd(l){
if(!roadA){roadA=l.code;roadWps=[];roadB=null;updateRoadInfo();render();return;}
if(l.code===roadA){return;} // aynı pine tekrar → yoksay
roadB=l.code;
openRoadForm(null);
updateRoadInfo();render();
}
/* ---- çizim ---- */
$("svg").addEventListener("click",e=>{
if(mode==="region"){draft.push(evtToNorm(e));render();}
else if(mode==="poi"){draftPoint=evtToNorm(e);openForm(null,"poi");render();}
else if(mode==="road"){
const pt=evtToNorm(e);
const near=locAt(pt);
if(near){pickRoadEnd(near);return;}
if(roadA&&!roadB){roadWps.push(pt);updateRoadInfo();render();}
}
});
$("undoPt").onclick=()=>{draft.pop();render();};
$("cancelDraw").onclick=()=>{draft=[];render();};
$("finishBtn").onclick=()=>{if(draft.length>=3)openForm(null,"region");};
/* ---- form ---- */
function openForm(tag,kind){
$("formCard").classList.remove("hide");$("formMsg").textContent="";
// salt okunurda kaydet/sil gizli, alanlar kilitli
$("saveBtn").style.display=CAN_EDIT?"":"none";
for(const id of ["fName","fKind","fCategory","fCont","fNote"])$(id).disabled=!CAN_EDIT;
if(tag){ // düzenleme
editingId=tag.id;$("formTitle").textContent="Düzenle: "+tag.name;
$("fName").value=tag.name;$("fKind").value=tag.kind;$("fCont").value=tag.continent||"";
$("fCategory").innerHTML=catOptions(tag.kind,tag.category||"");
$("fNote").value=tag.note||"";$("delBtn").classList.remove("hide");
// POI ise: mevcut bölge seçimini göster; noktanın altındaki bölgeleri "auto" vurgula
const autoR=tag.kind==="poi"&&tag.points[0]?regionsAt(tag.points[0]):[];
renderRegionChecks(tag.region_ids||[],autoR);
toggleRegionsWrap(tag.kind);
}else{ // yeni — çizilen alanın merkezi hangi kıtadaysa onu otomatik seç
editingId=null;$("formTitle").textContent="Yeni etiket";
const pt=(kind==="poi")?draftPoint:(draft.length?centroid(draft):null);
const cont=pt?continentAt(pt):"";
$("fName").value="";$("fKind").value=kind;$("fCont").value=cont;$("fNote").value="";
$("fCategory").innerHTML=catOptions(kind,"");
$("delBtn").classList.add("hide");
// POI: içinde olduğu bölgeleri otomatik seç
const autoR=(kind==="poi"&&pt)?regionsAt(pt):[];
renderRegionChecks(autoR,autoR);
toggleRegionsWrap(kind);
}
$("catWrap").classList.toggle("hide",$("fKind").value==="continent");
if(!CAN_EDIT)$("delBtn").classList.add("hide");
render();
$("formCard").scrollIntoView({behavior:"smooth",block:"nearest"});
$("fName").focus();
}
function selectTag(t){setMode("select");openForm(t,t.kind);}
$("closeForm").onclick=()=>{editingId=null;draft=[];draftPoint=null;$("formCard").classList.add("hide");render();};
$("saveBtn").onclick=async()=>{
const name=$("fName").value.trim();
if(!name){$("formMsg").className="msg err";$("formMsg").textContent="İsim gerekli";return;}
const kind=$("fKind").value;
let points;
if(editingId){ // mevcut geometriyi koru
const cur=tags.find(t=>t.id===editingId);points=cur.points;
}else if(kind==="poi"){
if(!draftPoint){$("formMsg").textContent="Haritada nokta seç";return;}points=[draftPoint];
}else{
if(draft.length<3){$("formMsg").textContent="En az 3 köşe";return;}points=draft.slice();
}
const category=$("fCategory").value||null;
const payload={name,kind,category,continent:$("fCont").value||null,points,
color:cat(category)?cat(category).color:null,
note:$("fNote").value.trim()||null,
region_ids:kind==="poi"?selectedRegions():[]};
$("formMsg").className="msg";$("formMsg").textContent="Kaydediliyor…";
const r=editingId?await api("/map-tags/"+editingId,{method:"POST",body:JSON.stringify(payload)})
:await api("/map-tags",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("formMsg").className="msg err";$("formMsg").textContent=r.body.detail||("Hata "+r.status);return;}
draft=[];draftPoint=null;editingId=null;$("formCard").classList.add("hide");
await loadTags();
};
async function delTag(id,name){
if(!confirm("Silinsin mi? — "+name))return;
const r=await api("/map-tags/"+id+"/delete",{method:"POST"});
if(!r.ok){alert("Silinemedi ("+r.status+")");return;}
if(editingId===id){editingId=null;$("formCard").classList.add("hide");}
await loadTags();
}
$("delBtn").onclick=()=>{if(editingId)delTag(editingId,$("fName").value);};
/* ---- yol formu ---- */
function openRoadForm(rd){
$("roadForm").classList.remove("hide");$("roadMsg").textContent="";
if(rd){ // mevcut yolu düzenle
roadEditId=rd.id;roadA=rd.a;roadB=rd.b;roadWps=(rd.waypoints||[]).map(p=>p.slice());
$("roadTitle").textContent="Yolu düzenle";
$("rCost").value=rd.step_cost;$("rEvent").value=rd.event_chance.toFixed(2);
$("delRoadBtn").classList.remove("hide");
}else{ // yeni yol (roadA/roadB zaten seçili)
roadEditId=null;$("roadTitle").textContent="Yeni yol";
$("delRoadBtn").classList.add("hide");
}
$("roadEnds").innerHTML=`🛣 <b>${esc(roadLabel({a:roadA,b:roadB}))}</b>`;
$("rWpInfo").textContent=roadWps.length+" ara nokta";
$("roadForm").scrollIntoView({behavior:"smooth",block:"nearest"});
}
function selectRoad(rd){setMode("road");openRoadForm(rd);render();}
$("saveRoad").onclick=async()=>{
if(!roadA||!roadB){$("roadMsg").className="msg err";$("roadMsg").textContent="İki uç lokasyon gerekli";return;}
const payload={a:roadA,b:roadB,step_cost:parseInt($("rCost").value)||0,
event_chance:parseFloat($("rEvent").value)||0,waypoints:roadWps};
$("roadMsg").className="msg";$("roadMsg").textContent="Kaydediliyor…";
const r=await api("/map-tags/roads",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("roadMsg").className="msg err";$("roadMsg").textContent=(r.body&&r.body.detail)||("Hata "+r.status);return;}
roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");
await loadLocsRoads();updateRoadInfo();
};
async function delRoad(id){
if(!confirm("Bu yol silinsin mi?"))return;
const r=await api("/map-tags/roads/"+id+"/delete",{method:"POST"});
if(!r.ok){alert("Silinemedi ("+r.status+")");return;}
roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");
await loadLocsRoads();updateRoadInfo();
}
$("delRoadBtn").onclick=()=>{if(roadEditId)delRoad(roadEditId);};
$("closeRoad").onclick=()=>{roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");updateRoadInfo();render();};
$("undoWp").onclick=()=>{roadWps.pop();updateRoadInfo();render();};
$("cancelRoad").onclick=()=>{roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");updateRoadInfo();render();};
/* ---- bağla ---- */
$("loginBtn").onclick=login;
$("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login();});
document.querySelectorAll(".modebtn[data-mode]").forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
$("fKind").addEventListener("change",()=>{
const k=$("fKind").value;
toggleRegionsWrap(k);
$("catWrap").classList.toggle("hide",k==="continent");
$("fCategory").innerHTML=catOptions(k,"");
});
// kategori seçilince isim boşsa otomatik öner: "Orman 3"
$("fCategory").addEventListener("change",()=>{
const c=$("fCategory").value;
if(!c||$("fName").value.trim())return;
const n=tags.filter(t=>t.category===c).length+1;
$("fName").value=catLabel(c)+" "+n;
});
$("showLabels").onchange=render;
$("showRegions").onchange=render;
$("showRoads").onchange=render;
$("showPois").onchange=render;
/* ---- tam ekran düzenleme + pan/zoom (yalnız fullscreen'de aktif) ---- */
(function(){
const area=$("maparea"), view=$("mapview"), zoom=$("mapzoom"), toolbar=document.querySelector(".toolbar");
let fsActive=false, scale=1, tx=0, ty=0, MIN=1; const MAX=8;
function apply(){ zoom.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`; }
function clamp(){
const vw=view.clientWidth, vh=view.clientHeight;
const cw=vw*scale, ch=zoom.offsetHeight*scale;
tx = cw<=vw ? (vw-cw)/2 : Math.max(vw-cw, Math.min(0, tx)); // sığıyorsa ortala
ty = ch<=vh ? (vh-ch)/2 : Math.max(vh-ch, Math.min(0, ty));
}
// tüm haritayı görünüme sığdır (contain), ortala — taban zoom
function fit(){
const baseH=zoom.offsetHeight;
if(!baseH || !view.clientHeight) return;
MIN=Math.min(1, view.clientHeight/baseH); // her şeyi görecek kadar uzaklaş
scale=MIN; tx=0; ty=0; clamp(); apply();
}
function zoomAt(cx,cy,ns){
ns=Math.max(MIN,Math.min(MAX,ns));
const r=view.getBoundingClientRect(), px=cx-r.left, py=cy-r.top;
tx=px-(px-tx)*(ns/scale); ty=py-(py-ty)*(ns/scale);
scale=ns; clamp(); apply();
}
function centerZoom(f){ const r=view.getBoundingClientRect(); zoomAt(r.left+view.clientWidth/2, r.top+view.clientHeight/2, scale*f); }
function fsOpen(){
if(fsActive) return; fsActive=true;
area.classList.add("fs");
toolbar.classList.add("fsfloat"); area.prepend(toolbar); // araçları overlay içine taşı
document.body.style.overflow="hidden";
requestAnimationFrame(fit);
}
function fsClose(){
if(!fsActive) return; fsActive=false;
toolbar.classList.remove("fsfloat"); area.parentNode.insertBefore(toolbar, area); // geri koy
area.classList.remove("fs");
document.body.style.overflow="";
scale=1; tx=0; ty=0; zoom.style.transform=""; // normal görünüme dön
}
$("fsOpenBtn").onclick=fsOpen;
$("fsClose").onclick=fsClose;
$("zoomIn").onclick=()=>centerZoom(1.4);
$("zoomOut").onclick=()=>centerZoom(1/1.4);
$("zoomReset").onclick=fit;
document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&fsActive) fsClose(); });
view.addEventListener("wheel",e=>{ if(!fsActive) return; e.preventDefault(); zoomAt(e.clientX,e.clientY,scale*(e.deltaY<0?1.15:1/1.15)); },{passive:false});
let pts=new Map(), startDist=0, startScale=1, lastMid=null;
view.addEventListener("touchstart",e=>{
if(!fsActive) return;
for(const t of e.changedTouches) pts.set(t.identifier,{x:t.clientX,y:t.clientY});
if(pts.size===2){ const [a,b]=[...pts.values()]; startDist=Math.hypot(a.x-b.x,a.y-b.y); startScale=scale; lastMid={x:(a.x+b.x)/2,y:(a.y+b.y)/2}; }
},{passive:false});
view.addEventListener("touchmove",e=>{
if(!fsActive) return;
for(const t of e.changedTouches) if(pts.has(t.identifier)) pts.set(t.identifier,{x:t.clientX,y:t.clientY});
if(pts.size>=2){
e.preventDefault();
const [a,b]=[...pts.values()];
const dist=Math.hypot(a.x-b.x,a.y-b.y), mid={x:(a.x+b.x)/2,y:(a.y+b.y)/2};
if(lastMid){ tx+=mid.x-lastMid.x; ty+=mid.y-lastMid.y; } lastMid=mid;
if(startDist>0) zoomAt(mid.x,mid.y,startScale*(dist/startDist)); else { clamp(); apply(); }
}
},{passive:false});
function end(e){ for(const t of e.changedTouches) pts.delete(t.identifier); if(pts.size<2){ startDist=0; lastMid=null; } }
view.addEventListener("touchend",end); view.addEventListener("touchcancel",end);
window.addEventListener("resize",()=>{ if(fsActive) fit(); });
})();
if(TOKEN)showEditor();
</script>
</body>
</html>

307
assets/npcs.html Normal file
View file

@ -0,0 +1,307 @@
<!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 — NPC'ler</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}
.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}
.locgroup{margin-bottom:6px}
.locgroup h3{font-size:14px;color:var(--gold);margin:18px 0 4px;border-bottom:1px solid var(--line);padding-bottom:4px}
.npc{border-top:1px solid var(--line);padding:10px 2px}
.npc:first-child{border-top:none}
.npc .head{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.npc .head b{font-size:14px}
.npc .rolepill{font-size:11px;padding:1px 8px;border-radius:10px;background:#243247;color:var(--gold);font-weight:600}
.npc .stancepill{font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600}
.stance-friendly{background:#1c3326;color:#5fd08a} .stance-hostile{background:#3a1f22;color:#e5534b} .stance-neutral{background:#2a3140;color:#9aa7b6}
.npc .lnkline{margin-top:5px;font-size:12px}
.npc .lnkline .lbl{color:var(--muted)}
.npc .qitem{display:inline-block;background:#243247;color:#cbd5e1;border-radius:8px;padding:1px 7px;margin:2px 3px 0 0;font-size:11px}
.npc .qitem.q{background:#2c2440;color:#c9a0ff}
.links{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px}
@media(max-width:700px){.links{grid-template-columns:1fr}}
.links select{width:100%}
.hint{font-size:10px;color:var(--muted);font-weight:400}
.npc .acts{margin-left:auto;display:flex;gap:2px}
.npc .desc{white-space:pre-wrap;word-wrap:break-word;font-size:13px;margin-top:4px}
.npc .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:1fr 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>🧙 NPC'ler</h1>
<div class="sub">Oyun NPC listesi — konum ve temel bilgileriyle. Konumlar map editöründe çizdiğin alanlar (🗺 bölge) ve pinlerdir (📌).</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="card">
<div class="toolbar">
<input type="search" id="q" placeholder="NPC, rol veya yer ara…">
<select id="fStanceF">
<option value="">Tüm taraflar</option>
<option value="friendly">🟢 Dost</option>
<option value="neutral">⚪ Tarafsız</option>
<option value="hostile">🔴 Düşman</option>
</select>
<select id="fLoc"><option value="">Tüm yerler</option></select>
<span class="count" id="cnt"></span>
</div>
<div id="list"></div>
<div class="count" id="empty">Yükleniyor…</div>
</div>
<div class="card" id="formCard">
<h2 id="formTitle">+ Yeni NPC</h2>
<div class="frm">
<div><label>İsim *</label><input type="text" id="fName" placeholder="örn. Tacir Baldor"></div>
<div><label>Rol</label><input type="text" id="fRole" placeholder="örn. Demirci, Görev veren, Haydut"></div>
<div><label>Taraf</label><select id="fStance">
<option value="friendly">🟢 Dost</option>
<option value="neutral" selected>⚪ Tarafsız</option>
<option value="hostile">🔴 Düşman</option>
</select></div>
<div><label>Yer (alan / pin)</label><select id="fLocSel"><option value="">— yok / gezgin —</option></select></div>
</div>
<label>ıklama (görünüş, kişilik, oyundaki işlevi)</label>
<textarea id="fDesc"></textarea>
<label style="margin-top:8px">Not (iç not — diyalog fikirleri, TODO)</label>
<textarea id="fNotes" style="min-height:50px"></textarea>
<div class="links">
<div>
<label>Verdiği görevler <span class="hint">(bir görevi tek NPC verebilir)</span></label>
<select id="fQuests" multiple size="5"></select>
</div>
<div>
<label>Sattığı itemlar <span class="hint">(bir item birden çok NPC'de olabilir)</span></label>
<input type="search" id="qItemFilter" placeholder="item filtrele…" style="width:100%;margin-bottom:4px">
<select id="fItems" multiple size="6"></select>
</div>
</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center">
<button class="btn" id="saveBtn">Kaydet</button>
<button class="btn sec hide" id="cancelBtn">Vazgeç (düzenleme)</button>
<div class="msg" id="fMsg" style="margin:0"></div>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const API="https://api.stepstead.com";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
let NPCS=[],LOCS=[],CATALOG={quests:[],items:[]},editId=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 n_ic(kind){return kind==="poi"?"📌":kind==="region"?"🗺":"🚶";}
const STANCE={friendly:{ic:"🟢",lbl:"Dost"},hostile:{ic:"🔴",lbl:"Düşman"},neutral:{ic:"⚪",lbl:"Tarafsız"}};
let CATS={},VENDOR_CATS=[];
function catIcon(k){return k&&CATS[k]?CATS[k].icon:"";}
function locLabel(x){return (catIcon(x.category)||(x.kind==="poi"?"📌":"🗺"))+" "+x.name+(x.category&&CATS[x.category]?" — "+CATS[x.category].label:"");}
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 [n,l,c,cat]=await Promise.all([api("/npcs"),api("/npcs/locations"),api("/npcs/catalog"),api("/map-tags/categories")]);
if(!n.ok){
if(n.status===401||n.status===403){
$("loginMsg").className="msg err";
$("loginMsg").textContent=n.status===403?"Bu hesabın NPC yetkisi yok":"Giriş gerekli / oturum bitti";
}
return;
}
NPCS=n.body;LOCS=l.ok?l.body:[];CATALOG=c.ok?c.body:{quests:[],items:[]};
if(cat.ok){CATS=cat.body.categories||{};VENDOR_CATS=cat.body.vendor||[];}
$("loginCard").classList.add("hide");$("app").classList.remove("hide");
const opts=LOCS.map(x=>`<option value="${x.id}">${esc(locLabel(x))}${x.continent?" ("+esc(x.continent)+")":""}</option>`).join("");
$("fLoc").innerHTML='<option value="">Tüm yerler</option>'+opts+'<option value="none">— yersiz —</option>';
$("fLocSel").innerHTML='<option value="">— yok / gezgin —</option>'+opts;
buildItemOptions();
render();
}
async function reload(){
const [r,c]=await Promise.all([api("/npcs"),api("/npcs/catalog")]);
NPCS=r.ok?r.body:[];if(c.ok)CATALOG=c.body;render();
}
function buildItemOptions(){
$("fItems").innerHTML=CATALOG.items.map(i=>
`<option value="${i.id}" data-s="${norm(i.name)+' '+norm(i.type)+' '+norm(i.code)}">${esc(i.name)} — ${esc(i.type||"")}</option>`).join("");
}
function filterItemOptions(){
const q=norm($("qItemFilter").value.trim());
for(const o of $("fItems").options)o.hidden=q&&!o.dataset.s.includes(q);
}
function render(){
const q=norm($("q").value.trim()),fl=$("fLoc").value,fs=$("fStanceF").value;
const rows=NPCS.filter(n=>{
if(fs&&n.stance!==fs)return false;
if(fl==="none"&&n.map_tag_id)return false;
if(fl&&fl!=="none"&&n.map_tag_id!==+fl)return false;
if(!q)return true;
return norm(n.name).includes(q)||norm(n.role).includes(q)||norm(n.location_name).includes(q)||norm(n.description).includes(q);
});
$("cnt").textContent=`${rows.length} / ${NPCS.length} NPC`;
$("empty").style.display=rows.length?"none":"block";
$("empty").textContent=NPCS.length?"Eşleşen NPC yok.":"Henüz NPC yok — aşağıdan ekle.";
// yere göre grupla
const groups={},order=[],gkind={},gcat={};
for(const n of rows){
const k=n.location_name||"🚶 Yersiz / Gezgin";
if(!groups[k]){groups[k]=[];order.push(k);gkind[k]=n.location_kind;gcat[k]=n.location_category;}
groups[k].push(n);
}
let html="";
for(const k of order){
const ic=catIcon(gcat[k])||n_ic(gkind[k]);
html+=`<div class="locgroup"><h3>${ic} ${esc(k)} <span class="count">(${groups[k].length})</span></h3>`;
for(const n of groups[k]){
const st=STANCE[n.stance]||STANCE.neutral;
const qh=(n.quests||[]).map(q=>`<span class="qitem q">🗡 ${esc(q.title)}</span>`).join("");
const ih=(n.sells||[]).map(i=>`<span class="qitem">${esc(i.name)}</span>`).join("");
html+=`<div class="npc" data-id="${n.id}">
<div class="head"><b>${esc(n.name)}</b>
${n.role?`<span class="rolepill">${esc(n.role)}</span>`:""}
<span class="stancepill stance-${n.stance}">${st.ic} ${st.lbl}</span>
<span class="acts">
<button class="btn link edit" data-id="${n.id}">düzenle</button>
<button class="btn link del delx" data-id="${n.id}">sil</button>
</span></div>
${n.description?`<div class="desc">${esc(n.description)}</div>`:""}
${n.notes?`<div class="notes">${esc(n.notes)}</div>`:""}
${qh?`<div class="lnkline"><span class="lbl">Görevler:</span> ${qh}</div>`:""}
${ih?`<div class="lnkline"><span class="lbl">Satar (${n.sells.length}):</span> ${ih}</div>`:""}
</div>`;
}
html+=`</div>`;
}
$("list").innerHTML=html;
}
function fillForm(n){
editId=n?n.id:null;
$("formTitle").textContent=n?("✏️ Düzenle: "+n.name):"+ Yeni NPC";
$("fName").value=n?n.name:"";
$("fRole").value=n?(n.role||""):"";
$("fStance").value=n?(n.stance||"neutral"):"neutral";
$("fLocSel").value=n&&n.map_tag_id?n.map_tag_id:"";
$("fDesc").value=n?(n.description||""):"";
$("fNotes").value=n?(n.notes||""):"";
// quest seçimi: başka NPC'ye bağlı görevler disabled; bu NPC'ninkiler seçili
const mine=new Set(n?(n.quests||[]).map(q=>q.id):[]);
$("fQuests").innerHTML=CATALOG.quests.map(q=>{
const takenByOther=q.giver_npc_id&&(!n||q.giver_npc_id!==n.id);
const sel=mine.has(q.id)?" selected":"";
const dis=takenByOther?" disabled":"";
const suf=takenByOther?" — başka NPC'de":"";
return `<option value="${q.id}"${sel}${dis}>${esc(q.title)}${suf}</option>`;
}).join("");
// item seçimi
const sells=new Set(n?(n.sells||[]).map(i=>i.id):[]);
for(const o of $("fItems").options)o.selected=sells.has(+o.value);
$("qItemFilter").value="";filterItemOptions();
$("cancelBtn").classList.toggle("hide",!n);
if(n)$("formCard").scrollIntoView({behavior:"smooth",block:"nearest"});
}
function selectedIds(sel){return Array.from($(sel).selectedOptions).map(o=>+o.value);}
document.addEventListener("click",async e=>{
if(e.target.classList.contains("edit")){
const n=NPCS.find(x=>x.id===+e.target.dataset.id);
if(n)fillForm(n);
}
if(e.target.classList.contains("delx")){
const n=NPCS.find(x=>x.id===+e.target.dataset.id);
if(!n||!confirm(`"${n.name}" silinsin mi?`))return;
const r=await api("/npcs/"+n.id+"/delete",{method:"POST"});
if(r.ok){toast("Silindi");if(editId===n.id)fillForm(null);await reload();}
else toast("⚠ "+(r.body.detail||"Hata"));
}
});
$("saveBtn").onclick=async()=>{
const name=$("fName").value.trim();
if(!name){$("fMsg").className="msg err";$("fMsg").textContent="İsim gerekli";return;}
$("fMsg").className="msg";$("fMsg").textContent="…";
const payload={name,role:$("fRole").value.trim()||null,stance:$("fStance").value,
map_tag_id:$("fLocSel").value?+$("fLocSel").value:null,
description:$("fDesc").value.trim()||null,notes:$("fNotes").value.trim()||null};
const r=editId?await api("/npcs/"+editId,{method:"POST",body:JSON.stringify(payload)})
:await api("/npcs",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("fMsg").className="msg err";$("fMsg").textContent=r.body.detail||"Hata";return;}
const npcId=r.body.id;
// quest + item bağlarını ayarla
const qr=await api("/npcs/"+npcId+"/quests",{method:"POST",body:JSON.stringify({ids:selectedIds("fQuests")})});
if(!qr.ok){$("fMsg").className="msg err";$("fMsg").textContent="Görev bağı: "+(qr.body.detail||"Hata");await reload();return;}
const ir=await api("/npcs/"+npcId+"/items",{method:"POST",body:JSON.stringify({ids:selectedIds("fItems")})});
if(!ir.ok){$("fMsg").className="msg err";$("fMsg").textContent="Item bağı: "+(ir.body.detail||"Hata");await reload();return;}
$("fMsg").className="msg ok";$("fMsg").textContent=editId?"Güncellendi":"Eklendi";
setTimeout(()=>$("fMsg").textContent="",1500);
fillForm(null);
await reload();
};
$("cancelBtn").onclick=()=>fillForm(null);
$("q").addEventListener("input",render);
$("fLoc").addEventListener("change",render);
$("fStanceF").addEventListener("change",render);
$("qItemFilter").addEventListener("input",filterItemOptions);
$("loginBtn").onclick=login;
$("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login();});
if(TOKEN)showApp();
</script>
</body>
</html>

168
assets/prompts.md Normal file
View file

@ -0,0 +1,168 @@
# Stepstead — Görsel Üretim Promptları
Güncelleme: 2026-07-06 (gece). **Tamamlananlar listeden çıkarıldı** (ana atlas,
15 lokasyon kartının TAMAMI, 15 pinin TAMAMI, oyuncu bayrağı, 3 olay kartı).
Her sahne promptuna **ana atlası referans görsel olarak ekle**.
Lokasyon kartları 16:9; pin/ikon/portre görselleri 1:1 ve **şeffaf arka plan** iste.
Hepsinde yazı/etiket YOK.
## Ortak ön ek — sahne görselleri (lokasyon/olay kartı)
```
Using the attached world atlas as the style and geography reference, render a detailed
zoomed-in regional illustration in the exact same hand-drawn parchment cartography style —
same aged parchment texture, fine ink linework, rich watercolor tints, muted earthy palette
with warm golden accents, same ornate border frame. No text, no labels, no letters. Region:
```
## Olay Kartı İllüstrasyonları (17 kaldı + çerçeve + d20)
Ortak şablon (3:4 dikey):
```
Using the attached world atlas as the style reference, render a fantasy event card
illustration in the same ink-and-watercolor style, portrait 3:4, ornate golden card
frame: [SAHNE]. Dramatic but painterly, no text.
```
| Kart | [SAHNE] |
|---|---|
| Gezgin Tacir | a cheerful traveling merchant with an overloaded mule, holding up a trinket by a forest road |
| Dev Ayı | a huge brown bear rearing up on a forest trail, morning mist |
| Mantar Halkası | a perfect circle of glowing mushrooms in a dark clearing, faint fairy light |
| Eski Harabe | crumbling moss-covered stone ruins with a broken archway, ivy, low sun |
| Nehir Geçidi | a fast river crossing with slippery stepping stones and a frayed rope line |
| Gizemli Yaşlı | a hooded elder with a lantern staff standing at a foggy crossroads |
| Kırık Araba | a merchant cart with a shattered wheel, spilled crates, an anxious driver |
| Sağanak | a traveler bracing under a violent downpour, cloak whipping in wind |
| Şanslı Buluş | a coin pouch and a gleaming ring lying in the grass beside the road |
| Kurt Sürüsü | a wolf pack blocking a forest road at dusk, the alpha baring its teeth |
| Vahşi Domuz | a huge scarred wild boar charging through undergrowth |
| Taze Geyik İzi | fresh deer tracks in soft mud leading into golden meadow grass |
| Mağara Örümceği | a giant spider descending in a torch-lit mine tunnel, thick webs |
| Korsan Artığı | a washed-up pirate chest among seaweed and wreckage on a rocky shore |
| Yaşlı Avcı | a grizzled old hunter by a small campfire, offering advice, bow on his back |
| Terk Edilmiş Ağ | a torn fishing net tangled on lake reeds, a few fish still caught |
| Ayı İni | a dark bear den opening between giant tree roots, bones scattered |
**Kart çerçevesi (şeffaf, içi boş):**
```
An ornate fantasy trading card frame, portrait 3:4: aged gold filigree border with
carved corner medallions, inner window fully transparent/empty, parchment-toned outer
edge, painterly style matching the attached atlas. Transparent background inside and
outside the frame, no text.
```
**d20 zarı (2 durum):**
```
A polished d20 dice icon in painterly fantasy style, [emerald green glowing / cracked
dark red], slight 3/4 angle, soft glow, blank faces without numbers, transparent
background, no text, game UI icon, 1:1.
```
## Karakter / NPC Görselleri
Ortak portre şablonu (1:1, omuz üstü):
```
A painterly fantasy character portrait, bust (head and shoulders), warm ink-and-watercolor
style matching the attached atlas, muted earthy palette with golden accents, soft parchment
vignette background, no text, 1:1. Character: [KARAKTER]
```
| Portre | [KARAKTER] |
|---|---|
| Oyuncu avatarı (E) | a determined young wanderer in a hooded travel cloak with a leather satchel and walking staff, friendly eyes |
| Oyuncu avatarı (K) | a confident young female explorer with braided hair, travel cloak and a map case slung across her shoulder |
| Vendor NPC | a plump jovial market merchant with a magnificent mustache, fur-trimmed vest, holding a brass scale |
| Gezgin Tacir | a wiry traveling peddler with a wide-brimmed hat and a backpack full of pots and trinkets |
| Yaşlı Avcı | a weathered old hunter with a grey beard, fur hood, a scar over one eyebrow, longbow visible |
| Gizemli Yaşlı | a mysterious hooded elder, face half in shadow, glowing lantern light from below |
| Haydut | a masked highway bandit with a crooked grin, leather armor, dagger at the collar |
**Tam boy oyuncu figürü (harita/HUD token'ı):**
```
A full-body chibi-proportioned fantasy wanderer character, standing pose, hooded travel
cloak, satchel and walking staff, painterly style matching the attached atlas, clean
silhouette, transparent background, no text. Suitable as a small game map token.
```
## Skill İkonları (10)
Şablon:
```
A round fantasy skill icon, painterly ink-and-watercolor style matching the attached
atlas, gold ring border, parchment inner background, transparent outside the circle,
no text, 1:1. Icon subject: [KONU]
```
| Skill | [KONU] |
|---|---|
| Madencilik | a crossed pickaxe and a glowing copper ore chunk |
| Balıılık | a fishing rod with a leaping fish |
| Toplama | a bundle of herbs and berries in a small basket |
| Ağaç Kesme | a woodcutter's axe embedded in an oak stump |
| Avcılık | a longbow with a nocked arrow and an antler |
| Savaş | two crossed swords over a small round shield |
| Demircilik | a blacksmith hammer striking a glowing anvil |
| Simya | a bubbling potion flask with drifting sparkles |
| Aşçılık | a steaming cooking pot over a small fire |
| İnşaat | a mallet, a saw and a wooden plank frame |
## Item İkonları (aile bazlı sprite sheet)
Şablon — her aile için tek prompt, grid iste:
```
A sprite sheet of [N] fantasy game inventory icons in a consistent painterly
ink-and-watercolor style matching the attached atlas, arranged in a neat grid on a
transparent background, equal spacing, each icon centered in its cell, no text, no frames.
Icons: [LİSTE]
```
Örnek aileler:
- **Odunlar:** `oak log, birch log, pine log, dark walnut log, ebony log — stacked cut logs, tier color darkening left to right`
- **Cevherler:** `copper ore, tin ore, iron ore, silver ore, gold ore — rough rock chunks with visible metal veins`
- **Külçeler:** `copper ingot, bronze ingot, iron ingot, silver ingot, gold ingot — stacked metal bars`
- **Otlar:** `mint sprig, sage bundle, lavender stems, chamomile flowers, wild garlic, nettle leaves`
- **Balıklar:** `carp, trout, perch, pike, salmon — side view, subtle scale detail`
- **Et & deri:** `rabbit meat, venison cut, boar meat, wolf pelt, bear hide`
- **Tool'lar:** `stone axe, copper axe, iron axe, stone pickaxe, copper pickaxe, iron pickaxe, simple fishing rod, sturdy fishing rod, masterwork fishing rod`
- **Bootstrap:** `small grey stone, wooden stick, coiled green vine`
- **Consumable:** `steaming mint tea cup, honey bread, dried meat strips, glowing stamina potion`
## Sistem / UI Görselleri
- **Kamp göstergesi:** `A small pitched canvas camp tent with a tiny campfire, painterly game icon, transparent background, no text, 1:1.`
- **Adım ikonu:** `A single boot footprint icon, gold on transparent background, painterly, clean silhouette, game UI, 1:1.`
- **Gold ikonu:** `A small pile of three gold coins with an embossed compass rose, painterly game icon, transparent background, 1:1.`
- **Günlük ödül:** `A wrapped gift chest with a glowing ribbon, painterly fantasy game icon, transparent background, 1:1.`
- **Streak alevi:** `A small stylized orange-gold flame icon, painterly, transparent background, 1:1.`
- **Liderlik rozetleri:** `Three round laurel-wreath rank medals side by side: gold, silver, bronze — same design, painterly, transparent background, no numbers, no text.`
- **Buton seti:** `A fantasy game UI button set on a transparent background: a wide rounded carved-wood button with gold trim in three states side by side — normal, pressed (darker, inset), disabled (desaturated). Painterly, no text.`
- **Panel çerçevesi (9-slice):** `A fantasy game UI panel frame suitable for 9-slice scaling: dark stained wood border with gold corner fittings, fully transparent empty center, painterly, no text.`
- **Progress bar:** `A fantasy game UI progress bar on a transparent background: ornate gold end caps, dark leather track, glowing amber fill shown at 60%, painterly, no text.`
## Marka
- **Logo amblemi:** `A fantasy game emblem WITHOUT any letters: a winding path of golden footprints crossing a stylized parchment map toward a distant castle silhouette, circular emblem, ink-and-watercolor, transparent background.` *(Yazı ayrıca tipografiyle bindirilecek.)*
- **Splash:** `A wide fantasy landscape: a lone wanderer with a walking staff on a hilltop path at sunrise, looking over a vast hand-drawn map world blending into real terrain — forests, a harbor, a distant volcano; painterly ink-and-watercolor, warm golden light, no text, 16:9.`
- **App ikonu:** `A single gold map pin with a tiny footprint symbol in its circular window, painterly fantasy style, bold and readable at small size, flat warm parchment background filling the square, no text, 1:1.`
## Şablonlar (sonrası için)
**Zindan iç haritası (üstten):**
```
Using the attached world atlas as the style reference, render a top-down dungeon floor map
in the same hand-drawn parchment style: stone corridors and chambers drawn as clean ink
outlines, cracked flagstone floors, [a spider nest chamber / a bear den / flooded tunnels],
torch sconces, rubble, a clearly visible entrance at the bottom and a treasure chamber at
the top. No text, no labels, no grid numbers.
```
**Kasaba/şehir iç haritası (üstten):**
```
Using the attached world atlas as the style reference, render a top-down town map in the
same hand-drawn parchment style: individual buildings with detailed rooftops, a central
plaza, market street, [harbor district / temple hill / craftsmen quarter], garden plots,
perimeter wall with gates, winding streets. No text, no labels.
```

300
assets/quests.html Normal file
View file

@ -0,0 +1,300 @@
<!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 — Görevler</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:60px;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}
.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}
.typegroup h3{font-size:14px;color:var(--gold);margin:18px 0 4px;border-bottom:1px solid var(--line);padding-bottom:4px}
.quest{border-top:1px solid var(--line);padding:10px 2px}
.quest:first-child{border-top:none}
.quest .head{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.quest .head b{font-size:14px}
.quest .pill{font-size:11px;padding:1px 8px;border-radius:10px;background:#243247;color:var(--gold);font-weight:600}
.quest .pill.main{background:#3a2d12;color:#f0c651}
.quest .pill.side{background:#22303f;color:#7fb3e8}
.quest .acts{margin-left:auto;display:flex;gap:2px}
.quest .summary{white-space:pre-wrap;word-wrap:break-word;font-size:13px;margin-top:4px}
.quest .geo{font-size:12px;color:var(--muted);margin-top:5px;display:flex;gap:6px;flex-wrap:wrap}
.quest .geo span{background:#182231;border:1px solid var(--line);border-radius:6px;padding:2px 8px}
.quest .objs{margin:6px 0 0;padding-left:18px;font-size:12.5px}
.quest .objs li{margin:1px 0}
.quest .author{color:#7fd0a0;font-weight:600;font-size:12px}
.frm{display:grid;grid-template-columns:1fr 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%}
.objrow{display:grid;grid-template-columns:1fr 1fr auto;gap:6px;margin-bottom:6px;align-items:center}
@media(max-width:700px){.objrow{grid-template-columns:1fr}}
.objrow select,.objrow input{width:100%}
.objx{background:#2a3648;color:var(--err);border:none;border-radius:6px;padding:6px 10px;cursor:pointer;font-weight:600}
#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>🗡 Görevler</h1>
<div class="sub">Ana ve yan görevler — görevi veren NPC, kıta/bölge, veriliş noktası ve nerede yapılacağı (çok noktalı) ile.</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="card">
<div class="toolbar">
<input type="search" id="q" placeholder="Görev, NPC, bölge ara…">
<select id="fType"><option value="">Tüm türler</option><option value="main">Ana görev</option><option value="side">Yan görev</option></select>
<span class="count" id="cnt"></span>
</div>
<div id="list"></div>
<div class="count" id="empty">Yükleniyor…</div>
</div>
<div class="card" id="formCard">
<h2 id="formTitle">+ Yeni Görev</h2>
<div class="frm">
<div><label>Başlık *</label><input type="text" id="fTitle" placeholder="örn. Kayıp Kervan"></div>
<div><label>Tür</label><select id="fTypeSel"><option value="side">Yan görev</option><option value="main">Ana görev</option></select></div>
<div><label>Görevi veren NPC</label><select id="fGiver"><option value="">— yok —</option></select></div>
</div>
<div class="frm">
<div><label>Kıta</label><select id="fCont"><option value="">— yok —</option></select></div>
<div><label>Bölge</label><select id="fRegion"><option value="">— yok —</option></select></div>
<div><label>Veriliş noktası (alan / pin)</label><select id="fLoc"><option value="">— yok —</option></select></div>
</div>
<label>ıklama / özet</label>
<textarea id="fSummary" placeholder="Görevin konusu, hedefi…"></textarea>
<label style="margin-top:10px">Nerede yapılacak? (çok noktalı — istediğin kadar ekle)</label>
<div id="objList"></div>
<button class="btn sec sm" id="addObjBtn" type="button">+ Nokta ekle</button>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button class="btn" id="saveBtn">Kaydet</button>
<button class="btn sec hide" id="cancelBtn">Vazgeç</button>
<div class="msg" id="fMsg" style="margin:0"></div>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const API="https://api.stepstead.com";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
let QUESTS=[],META={npcs:[],places:[],regions:[],continents:[]},editId=null;
let ME={id:null,admin:false};
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);}
function canEdit(q){return ME.admin||(q.author_id!=null&&q.author_id===ME.id);}
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 [q,m,me]=await Promise.all([api("/quests"),api("/quests/meta"),api("/auth/me")]);
if(!q.ok){
if(q.status===401||q.status===403){
$("loginMsg").className="msg err";
$("loginMsg").textContent=q.status===403?"Bu hesabın görev (lore) yetkisi yok":"Giriş gerekli / oturum bitti";
}
return;
}
QUESTS=q.body;META=m.ok?m.body:META;
if(me.ok){ME.id=me.body.id;ME.admin=me.body.is_admin||(me.body.permissions||[]).includes("*");}
$("loginCard").classList.add("hide");$("app").classList.remove("hide");
fillMetaSelects();
render();
}
async function reload(){const r=await api("/quests");QUESTS=r.ok?r.body:[];render();}
function locOpts(sel){
return '<option value="">— yok —</option>'+
META.places.map(l=>`<option value="${l.id}"${sel==l.id?" selected":""}>${l.kind==="poi"?"📌":"🗺"} ${esc(l.name)}${l.continent?" ("+esc(l.continent)+")":""}</option>`).join("");
}
function fillMetaSelects(){
$("fGiver").innerHTML='<option value="">— yok —</option>'+
META.npcs.map(n=>`<option value="${n.id}">${esc(n.name)}${n.location_name?" · "+esc(n.location_name):""}</option>`).join("");
$("fCont").innerHTML='<option value="">— yok —</option>'+
META.continents.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join("");
$("fLoc").innerHTML=locOpts("");
refreshRegionSel("");
}
function refreshRegionSel(cont,sel){
const list=META.regions.filter(r=>!cont||r.continent===cont);
$("fRegion").innerHTML='<option value="">— yok —</option>'+
list.map(r=>`<option value="${r.id}"${sel==r.id?" selected":""}>${esc(r.name)}${r.continent&&!cont?" ("+esc(r.continent)+")":""}</option>`).join("");
}
function addObjRow(loc,note){
const div=document.createElement("div");div.className="objrow";
div.innerHTML=`<select class="objLoc">${locOpts(loc||"")}</select>
<input type="text" class="objNote" placeholder="ne yapılacak? (ops.)" value="${esc(note||"")}">
<button type="button" class="objx"></button>`;
div.querySelector(".objx").onclick=()=>div.remove();
$("objList").appendChild(div);
}
function readObjs(){
return [...$("objList").querySelectorAll(".objrow")].map(r=>({
map_tag_id:r.querySelector(".objLoc").value?+r.querySelector(".objLoc").value:null,
note:r.querySelector(".objNote").value.trim()||null
})).filter(o=>o.map_tag_id!=null||o.note);
}
function render(){
const qq=norm($("q").value.trim()),ft=$("fType").value;
const rows=QUESTS.filter(x=>{
if(ft&&x.quest_type!==ft)return false;
if(!qq)return true;
return norm(x.title).includes(qq)||norm(x.summary).includes(qq)||norm(x.giver_npc_name).includes(qq)
||norm(x.region_name).includes(qq)||norm(x.continent).includes(qq)||norm(x.location_name).includes(qq);
});
$("cnt").textContent=`${rows.length} / ${QUESTS.length} görev`;
$("empty").style.display=rows.length?"none":"block";
$("empty").textContent=QUESTS.length?"Eşleşen görev yok.":"Henüz görev yok — aşağıdan ekle.";
const groups={main:[],side:[]};
for(const x of rows)(groups[x.quest_type]||groups.side).push(x);
const titles={main:"⭐ Ana Görevler",side:"🗡 Yan Görevler"};
let html="";
for(const t of ["main","side"]){
if(!groups[t].length)continue;
html+=`<div class="typegroup"><h3>${titles[t]} <span class="count">(${groups[t].length})</span></h3>`;
for(const x of groups[t])html+=questHtml(x);
html+=`</div>`;
}
$("list").innerHTML=html;
}
function questHtml(x){
const geo=[];
if(x.giver_npc_name)geo.push(`🧙 ${esc(x.giver_npc_name)}`);
if(x.continent)geo.push(`🌍 ${esc(x.continent)}`);
if(x.region_name)geo.push(`🗺 ${esc(x.region_name)}`);
if(x.location_name)geo.push(`📍 Veriliş: ${esc(x.location_name)}`);
const objs=(x.objectives||[]).map(o=>{
const parts=[];
if(o.location_name)parts.push((o.location_kind==="poi"?"📌 ":"🗺 ")+esc(o.location_name));
if(o.note)parts.push(esc(o.note));
return parts.length?`<li>${parts.join(" — ")}</li>`:"";
}).join("");
return `<div class="quest" data-id="${x.id}">
<div class="head"><b>${esc(x.title)}</b>
<span class="pill ${x.quest_type}">${x.quest_type==="main"?"Ana":"Yan"}</span>
${x.author?`<span class="author">✍ ${esc(x.author)}</span>`:""}
${canEdit(x)?`<span class="acts">
<button class="btn link edit" data-id="${x.id}">düzenle</button>
<button class="btn link del delx" data-id="${x.id}">sil</button>
</span>`:""}</div>
${x.summary?`<div class="summary">${esc(x.summary)}</div>`:""}
${geo.length?`<div class="geo">${geo.map(g=>`<span>${g}</span>`).join("")}</div>`:""}
${objs?`<ul class="objs">${objs}</ul>`:""}
</div>`;
}
function fillForm(x){
editId=x?x.id:null;
$("formTitle").textContent=x?("✏️ Düzenle: "+x.title):"+ Yeni Görev";
$("fTitle").value=x?x.title:"";
$("fTypeSel").value=x?x.quest_type:"side";
$("fGiver").value=x&&x.giver_npc_id?x.giver_npc_id:"";
$("fCont").value=x&&x.continent?x.continent:"";
refreshRegionSel(x&&x.continent?x.continent:"",x&&x.region_tag_id?x.region_tag_id:"");
$("fLoc").value=x&&x.location_tag_id?x.location_tag_id:"";
$("fSummary").value=x?(x.summary||""):"";
$("objList").innerHTML="";
if(x&&x.objectives)for(const o of x.objectives)addObjRow(o.map_tag_id,o.note);
$("cancelBtn").classList.toggle("hide",!x);
if(x)$("formCard").scrollIntoView({behavior:"smooth",block:"nearest"});
}
$("fCont").addEventListener("change",()=>refreshRegionSel($("fCont").value,""));
$("addObjBtn").onclick=()=>addObjRow("","");
document.addEventListener("click",async e=>{
if(e.target.classList.contains("edit")){
const x=QUESTS.find(q=>q.id===+e.target.dataset.id);
if(x)fillForm(x);
}
if(e.target.classList.contains("delx")){
const x=QUESTS.find(q=>q.id===+e.target.dataset.id);
if(!x||!confirm(`"${x.title}" görevi silinsin mi?`))return;
const r=await api("/quests/"+x.id+"/delete",{method:"POST"});
if(r.ok){toast("Silindi");if(editId===x.id)fillForm(null);await reload();}
else toast("⚠ "+(r.body.detail||"Hata"));
}
});
$("saveBtn").onclick=async()=>{
const title=$("fTitle").value.trim();
if(!title){$("fMsg").className="msg err";$("fMsg").textContent="Başlık gerekli";return;}
$("fMsg").className="msg";$("fMsg").textContent="…";
const payload={title,quest_type:$("fTypeSel").value,
summary:$("fSummary").value.trim()||null,
giver_npc_id:$("fGiver").value?+$("fGiver").value:null,
continent:$("fCont").value||null,
region_tag_id:$("fRegion").value?+$("fRegion").value:null,
location_tag_id:$("fLoc").value?+$("fLoc").value:null,
objectives:readObjs()};
const r=editId?await api("/quests/"+editId,{method:"POST",body:JSON.stringify(payload)})
:await api("/quests",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("fMsg").className="msg err";$("fMsg").textContent=r.body.detail||"Hata";return;}
$("fMsg").className="msg ok";$("fMsg").textContent=editId?"Güncellendi":"Eklendi";
setTimeout(()=>$("fMsg").textContent="",1500);
fillForm(null);
await reload();
};
$("cancelBtn").onclick=()=>fillForm(null);
$("q").addEventListener("input",render);
$("fType").addEventListener("change",render);
$("loginBtn").onclick=login;
$("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login();});
if(TOKEN)showApp();
</script>
</body>
</html>

131
assets/upload.html Normal file
View file

@ -0,0 +1,131 @@
<!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 — Asset Yükle</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:640px;margin:0 auto;padding:24px 16px}
h1{font-size:20px;color:var(--gold);margin:0 0 4px}
.sub{color:var(--muted);font-size:13px;margin-bottom:20px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;margin-bottom:16px}
input,button{font-size:14px;border-radius:6px}
input[type=email],input[type=password]{width:100%;background:#222d3d;color:var(--txt);border:1px solid var(--line);padding:9px 10px;margin-bottom:10px}
.btn{background:var(--gold);color:#1a1300;border:none;padding:9px 18px;font-weight:600;cursor:pointer}
.btn:disabled{opacity:.5;cursor:default}
.msg{font-size:13px;margin-top:10px}
.msg.ok{color:var(--ok)} .msg.err{color:var(--err)}
.drop{border:2px dashed var(--line);border-radius:10px;padding:36px 16px;text-align:center;color:var(--muted);cursor:pointer;transition:.15s}
.drop.over{border-color:var(--gold);color:var(--gold)}
table{width:100%;border-collapse:collapse;font-size:13px}
td,th{text-align:left;padding:7px 4px;border-top:1px solid var(--line)}
th{color:var(--muted);font-weight:500;font-size:12px}
a{color:var(--gold)}
.hide{display:none}
.size{color:var(--muted)}
img.thumb{max-height:40px;border-radius:4px;vertical-align:middle}
</style>
<script src="/nav.js?v=8" data-theme="dark" defer></script>
</head>
<body>
<div class="wrap">
<h1>📤 Asset Yükle</h1>
<div class="sub">Harita ve görselleri buradan yükle → <code>https://stepstead.com/assets/uploads/…</code></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 class="card hide" id="upCard">
<div class="drop" id="drop">Dosyayı buraya sürükle ya da tıkla<br><small>png / jpg / webp / svg / json / zip — max 30 MB</small></div>
<input type="file" id="file" hidden accept=".png,.jpg,.jpeg,.webp,.svg,.json,.zip">
<div class="msg" id="upMsg"></div>
</div>
<div class="card hide" id="listCard">
<table><thead><tr><th></th><th>Dosya</th><th>Boyut</th></tr></thead><tbody id="rows"></tbody></table>
</div>
</div>
<script>
const API="https://api.stepstead.com";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
const $=id=>document.getElementById(id);
function fmt(n){return n>1048576?(n/1048576).toFixed(1)+" MB":Math.round(n/1024)+" KB";}
async function api(path,opts={}){
opts.headers=Object.assign({},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);
show();
}else{
$("loginMsg").className="msg err";
$("loginMsg").textContent=b.detail||"Giriş başarısız";
}
}
async function show(){
const r=await api("/admin/uploads");
if(!r.ok){
if(r.status===401||r.status===403){TOKEN="";localStorage.removeItem("ss_upload_token");}
$("loginCard").classList.remove("hide");
$("upCard").classList.add("hide"); $("listCard").classList.add("hide");
if(r.status===403){$("loginMsg").className="msg err";$("loginMsg").textContent="Bu hesabın yükleme yetkisi yok";}
return;
}
$("loginCard").classList.add("hide");
$("upCard").classList.remove("hide"); $("listCard").classList.remove("hide");
const tb=$("rows"); tb.innerHTML="";
for(const f of r.body){
const tr=document.createElement("tr");
const img=/\.(png|jpe?g|webp|svg)$/i.test(f.filename)?`<img class="thumb" src="${f.url}" loading="lazy">`:"📄";
tr.innerHTML=`<td>${img}</td><td><a href="${f.url}" target="_blank">${f.filename}</a></td><td class="size">${fmt(f.size)}</td>`;
tb.appendChild(tr);
}
}
async function upload(file){
if(!file)return;
$("upMsg").className="msg"; $("upMsg").textContent="⏳ Yükleniyor: "+file.name;
const fd=new FormData(); fd.append("file",file);
const r=await api("/admin/upload",{method:"POST",body:fd});
if(r.ok){
$("upMsg").className="msg ok";
$("upMsg").innerHTML=`✅ Yüklendi: <a href="${r.body.url}" target="_blank">${r.body.url}</a>`;
show();
}else{
$("upMsg").className="msg err";
$("upMsg").textContent="⚠ "+(r.body.detail||("Hata "+r.status));
}
}
$("loginBtn").onclick=login;
$("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login();});
$("drop").onclick=()=>$("file").click();
$("file").onchange=e=>upload(e.target.files[0]);
$("drop").addEventListener("dragover",e=>{e.preventDefault();$("drop").classList.add("over");});
$("drop").addEventListener("dragleave",()=>$("drop").classList.remove("over"));
$("drop").addEventListener("drop",e=>{e.preventDefault();$("drop").classList.remove("over");upload(e.dataTransfer.files[0]);});
if(TOKEN)show();
</script>
</body>
</html>

233
assets/users.html Normal file
View file

@ -0,0 +1,233 @@
<!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 — Kullanıcılar</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:960px;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{font-size:13px;border-radius:6px;font-family:inherit}
input,select{background:#222d3d;color:var(--txt);border:1px solid var(--line);padding:7px 9px}
input[type=email],input[type=password]{width:100%;margin-bottom:10px}
.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.danger{background:none;border:1px solid var(--line);color:var(--muted)}
.btn.danger:hover{color:var(--err);border-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}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:8px 6px;border-top:1px solid var(--line);vertical-align:middle}
th{color:var(--muted);font-weight:500;font-size:12px;text-transform:uppercase;letter-spacing:.04em;border-top:none}
tr.inactive td{opacity:.45}
.pill{font-size:11px;padding:1px 8px;border-radius:10px;background:#243247;color:var(--gold);font-weight:600}
.permbox{display:flex;gap:10px;flex-wrap:wrap;margin:6px 0}
.permbox label{display:flex;gap:5px;align-items:center;font-size:12px;color:var(--muted);background:#141c28;border:1px solid var(--line);border-radius:6px;padding:5px 9px;cursor:pointer}
.permbox label:has(input:checked){color:var(--gold);border-color:var(--gold)}
.rolerow{border-top:1px solid var(--line);padding:12px 0}
.rolerow:first-of-type{border-top:none}
.rolehead{display:flex;gap:10px;align-items:center}
.rolehead b{font-size:14px}
.rolehead .cnt{color:var(--muted);font-size:12px}
.rolehead .acts{margin-left:auto;display:flex;gap:6px}
.newrow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:8px}
.newrow input{flex:1 1 140px}
#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>👥 Kullanıcılar & Roller</h1>
<div class="sub">Kullanıcı oluştur, rol ata; rollerin hangi sayfalara/aksiyonlara erişeceğini seç.</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="card">
<h2>Kullanıcılar</h2>
<table>
<thead><tr><th>E-posta</th><th>İsim</th><th>Rol</th><th>Durum</th><th></th></tr></thead>
<tbody id="userRows"></tbody>
</table>
<div class="newrow">
<input type="email" id="nuEmail" placeholder="E-posta">
<input type="text" id="nuName" placeholder="İsim (ops.)">
<input type="password" id="nuPass" placeholder="Şifre (min 8)">
<select id="nuRole"></select>
<button class="btn" id="nuBtn">+ Kullanıcı Oluştur</button>
</div>
<div class="msg" id="uMsg"></div>
</div>
<div class="card">
<h2>Roller</h2>
<div id="roleList"></div>
<div class="newrow">
<input type="text" id="nrName" placeholder="Yeni rol adı">
<button class="btn" id="nrBtn">+ Rol Oluştur</button>
</div>
<div class="msg" id="rMsg"></div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const API="https://api.stepstead.com";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
let ROLES=[],USERS=[],PERMS={};
const $=id=>document.getElementById(id);
function esc(s){return (s??"").toString().replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));}
function toast(m,ms=3000){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 p=await api("/admin/permissions");
if(!p.ok){
$("loginMsg").className="msg err";
$("loginMsg").textContent=p.status===403?"Bu hesabın kullanıcı yönetimi yetkisi yok":"Giriş gerekli / oturum bitti";
return;
}
PERMS=p.body;
$("loginCard").classList.add("hide");$("app").classList.remove("hide");
await loadAll();
}
async function loadAll(){
const [r,u]=await Promise.all([api("/admin/roles"),api("/admin/users")]);
ROLES=r.ok?r.body:[];USERS=u.ok?u.body:[];
render();
}
function roleSelect(id,sel){
const opts=['<option value="">— rol yok (oyuncu) —</option>']
.concat(ROLES.map(r=>`<option value="${r.id}" ${r.id===sel?"selected":""}>${esc(r.name)}</option>`));
return `<select data-uid="${id}" class="uRole">${opts.join("")}</select>`;
}
function render(){
// kullanıcılar
const tb=$("userRows");tb.innerHTML="";
for(const u of USERS){
const tr=document.createElement("tr");
if(!u.is_active)tr.className="inactive";
tr.innerHTML=`<td>${esc(u.email)}</td><td>${esc(u.display_name||"")}</td>
<td>${roleSelect(u.id,u.role_id)}</td>
<td><button class="btn sm sec uAct" data-uid="${u.id}" data-on="${u.is_active?1:0}">${u.is_active?"Aktif":"Pasif"}</button></td>
<td></td>`;
tb.appendChild(tr);
}
// yeni kullanıcı rol seçimi
$("nuRole").innerHTML=['<option value="">— rol yok —</option>']
.concat(ROLES.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`)).join("");
// roller
const box=$("roleList");box.innerHTML="";
for(const r of ROLES){
const div=document.createElement("div");div.className="rolerow";
const permHtml=r.is_system
? `<div class="permbox"><span class="pill">tüm izinler (*)</span></div>`
: `<div class="permbox">`+Object.entries(PERMS).map(([code,lbl])=>
`<label title="${esc(lbl)}"><input type="checkbox" data-rid="${r.id}" value="${code}" ${r.permissions.includes(code)?"checked":""}>${code}</label>`
).join("")+`</div>`;
div.innerHTML=`<div class="rolehead">
<b>${esc(r.name)}</b><span class="pill">${r.is_system?"sistem":"özel"}</span>
<span class="cnt">${r.user_count} kullanıcı</span>
<span class="acts">
${r.is_system?"":`<button class="btn sm rSave" data-rid="${r.id}">Kaydet</button>
<button class="btn sm danger rDel" data-rid="${r.id}">Sil</button>`}
</span>
</div>${permHtml}`;
box.appendChild(div);
}
}
document.addEventListener("change",async e=>{
if(e.target.classList.contains("uRole")){
const uid=+e.target.dataset.uid;
const val=e.target.value?+e.target.value:null;
const r=await api("/admin/users/"+uid,{method:"POST",body:JSON.stringify({role_id:val})});
if(r.ok){toast("✅ Rol güncellendi");await loadAll();}
else{toast("⚠ "+(r.body.detail||"Hata"));render();}
}
});
document.addEventListener("click",async e=>{
if(e.target.classList.contains("uAct")){
const uid=+e.target.dataset.uid, on=e.target.dataset.on==="1";
const r=await api("/admin/users/"+uid,{method:"POST",body:JSON.stringify({is_active:!on})});
if(r.ok){toast(on?"Kullanıcı pasifleştirildi":"Kullanıcı aktifleştirildi");await loadAll();}
else toast("⚠ "+(r.body.detail||"Hata"));
}
if(e.target.classList.contains("rSave")){
const rid=+e.target.dataset.rid;
const role=ROLES.find(x=>x.id===rid);
const perms=[...document.querySelectorAll(`input[data-rid="${rid}"]:checked`)].map(c=>c.value);
const r=await api("/admin/roles/"+rid,{method:"POST",body:JSON.stringify({name:role.name,permissions:perms})});
if(r.ok){toast("✅ Rol kaydedildi");await loadAll();}
else toast("⚠ "+(r.body.detail||"Hata"));
}
if(e.target.classList.contains("rDel")){
const rid=+e.target.dataset.rid;
const role=ROLES.find(x=>x.id===rid);
if(!confirm(`"${role.name}" rolü silinsin mi?`))return;
const r=await api("/admin/roles/"+rid+"/delete",{method:"POST"});
if(r.ok){toast("Rol silindi");await loadAll();}
else toast("⚠ "+(r.body.detail||"Hata"));
}
});
$("nuBtn").onclick=async()=>{
const email=$("nuEmail").value.trim(),pass=$("nuPass").value;
if(!email||pass.length<8){$("uMsg").className="msg err";$("uMsg").textContent="E-posta + min 8 karakter şifre gerekli";return;}
$("uMsg").className="msg";$("uMsg").textContent="…";
const r=await api("/admin/users",{method:"POST",body:JSON.stringify({
email,password:pass,display_name:$("nuName").value.trim()||null,
role_id:$("nuRole").value?+$("nuRole").value:null})});
if(r.ok){$("uMsg").className="msg ok";$("uMsg").textContent="✅ Oluşturuldu: "+r.body.email;
$("nuEmail").value=$("nuName").value=$("nuPass").value="";await loadAll();}
else{$("uMsg").className="msg err";$("uMsg").textContent=r.body.detail||"Hata";}
};
$("nrBtn").onclick=async()=>{
const name=$("nrName").value.trim();
if(name.length<2){$("rMsg").className="msg err";$("rMsg").textContent="Rol adı gerekli";return;}
const r=await api("/admin/roles",{method:"POST",body:JSON.stringify({name,permissions:[]})});
if(r.ok){$("rMsg").className="msg ok";$("rMsg").textContent="✅ Rol oluşturuldu — izinlerini seçip Kaydet'e bas";
$("nrName").value="";await loadAll();}
else{$("rMsg").className="msg err";$("rMsg").textContent=r.body.detail||"Hata";}
};
$("loginBtn").onclick=login;
$("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login();});
if(TOKEN)showApp();
</script>
</body>
</html>

BIN
assets/world_map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

BIN
assets/world_map@4x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

1
db/data/crafts.json Normal file

File diff suppressed because one or more lines are too long

119
db/index.html Normal file
View file

@ -0,0 +1,119 @@
<!doctype html>
<html lang="tr">
<head>
<meta charset="utf-8">
<title>Stepstead — Craft Veritabanı</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{
--bg:#f5f3ee; --card:#fff; --ink:#2d3a2e; --muted:#77806f; --accent:#3a5a40;
--line:#e4e0d6;
--common:#6b7280; --uncommon:#2e8b45; --rare:#2563c9; --epic:#8b3fc9; --legendary:#c9762e;
}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,sans-serif;background:var(--bg);color:var(--ink)}
header{background:var(--accent);color:#fff;padding:20px 16px;position:sticky;top:0;z-index:5;box-shadow:0 2px 12px rgba(0,0,0,.15)}
header .inner{max-width:1000px;margin:0 auto}
header h1{margin:0 0 2px;font-size:24px}
header h1 a{color:#fff;text-decoration:none}
header p{margin:0;font-size:13px;opacity:.85}
.controls{max-width:1000px;margin:16px auto 0;padding:0 16px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
#q{flex:1 1 220px;padding:10px 14px;border:1px solid var(--line);border-radius:8px;font-size:15px;background:var(--card)}
.chip{padding:7px 14px;border-radius:999px;border:1px solid var(--line);background:var(--card);cursor:pointer;font-size:13px;white-space:nowrap}
.chip.on{background:var(--accent);color:#fff;border-color:var(--accent)}
main{max-width:1000px;margin:16px auto 60px;padding:0 16px}
.stats{color:var(--muted);font-size:13px;margin:8px 2px 16px}
h2.group{font-size:19px;margin:28px 0 4px;color:var(--accent);border-bottom:2px solid var(--line);padding-bottom:6px}
h3.family{font-size:14px;margin:16px 0 8px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
.card .name{font-weight:600;font-size:15px}
.card .meta{font-size:12px;color:var(--muted);margin:4px 0 8px;display:flex;gap:10px;flex-wrap:wrap}
.card .ings{font-size:13px;line-height:1.5}
.card .ings b{color:var(--muted);font-weight:500}
.badge{display:inline-block;background:#e8f0e9;color:var(--accent);border-radius:6px;padding:1px 7px;font-size:12px;margin-left:6px}
.r-common{color:var(--common)} .r-uncommon{color:var(--uncommon)} .r-rare{color:var(--rare)}
.r-epic{color:var(--epic)} .r-legendary{color:var(--legendary)}
.empty{color:var(--muted);text-align:center;margin:48px 0}
</style>
<script src="/nav.js?v=8" data-theme="light" defer></script>
</head>
<body>
<header><div class="inner">
<h1><a href="/">Stepstead</a> · Craft Veritabanı</h1>
<p>Tüm üretim tarifleri — malzemeler, skill gereksinimleri, adım maliyetleri</p>
</div></header>
<div class="controls">
<input id="q" type="search" placeholder="Tarif, malzeme veya skill ara… (örn. Meşe, Kılıç, Simya)">
<div id="chips"></div>
</div>
<main>
<div class="stats" id="stats"></div>
<div id="list"></div>
</main>
<script>
let DATA=null, activeGroup="Tümü";
const norm=s=>s.toLocaleLowerCase("tr");
fetch("data/crafts.json").then(r=>r.json()).then(d=>{DATA=d;buildChips();render();});
function buildChips(){
const box=document.getElementById("chips");
const groups=["Tümü",...Object.keys(DATA.groups).sort((a,b)=>DATA.groups[b]-DATA.groups[a])];
box.style.display="flex";box.style.flexWrap="wrap";box.style.gap="8px";
for(const g of groups){
const c=document.createElement("button");
c.className="chip"+(g===activeGroup?" on":"");
c.textContent=g==="Tümü"?`Tümü (${DATA.total})`:`${g} (${DATA.groups[g]})`;
c.onclick=()=>{activeGroup=g;document.querySelectorAll(".chip").forEach(x=>x.classList.remove("on"));c.classList.add("on");render();};
box.appendChild(c);
}
document.getElementById("q").addEventListener("input",render);
}
function matches(r,q){
if(!q)return true;
if(norm(r.name).includes(q)||norm(r.skill).includes(q)||norm(r.family).includes(q)||norm(r.group).includes(q))return true;
return r.ings.some(i=>norm(i.name).includes(q));
}
function card(r){
const eff=r.effect&&r.effect.type==="steps"?`<span class="badge">+${r.effect.amount} adım</span>`:"";
const qty=r.out_qty>1?` ×${r.out_qty}`:"";
const ings=r.ings.map(i=>`${i.qty}× <span class="r-${i.rarity}">${i.name}</span>`).join(", ");
return `<div class="card">
<div class="name"><span class="r-${r.rarity}">${r.name}</span>${qty}${eff}</div>
<div class="meta"><span>⭐ ${r.skill} sv.${r.level}</span><span>👣 ${r.steps} adım</span><span>+${r.xp} XP</span></div>
<div class="ings"><b>Malzeme:</b> ${ings}</div>
</div>`;
}
function render(){
const q=norm(document.getElementById("q").value.trim());
const list=document.getElementById("list");
const rows=DATA.recipes.filter(r=>(activeGroup==="Tümü"||r.group===activeGroup)&&matches(r,q));
document.getElementById("stats").textContent=`${rows.length} tarif gösteriliyor`;
if(!rows.length){list.innerHTML='<div class="empty">Sonuç yok.</div>';return;}
// grup → aile → kartlar
const byGroup={};
for(const r of rows){(byGroup[r.group]??={});(byGroup[r.group][r.family]??=[]).push(r);}
let html="";
for(const g of Object.keys(byGroup).sort()){
html+=`<h2 class="group">${g}</h2>`;
const fams=Object.keys(byGroup[g]).sort((a,b)=>{
const la=Math.min(...byGroup[g][a].map(r=>r.level)), lb=Math.min(...byGroup[g][b].map(r=>r.level));
return la-lb||a.localeCompare(b,"tr");
});
for(const f of fams){
if(f!=="Genel"||fams.length>1)html+=`<h3 class="family">${f} (${byGroup[g][f].length})</h3>`;
html+=`<div class="grid">${byGroup[g][f].sort((a,b)=>a.level-b.level).map(card).join("")}</div>`;
}
}
list.innerHTML=html;
}
</script>
</body>
</html>

20
index.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="tr"><head><meta charset="utf-8"><title>Stepstead</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:system-ui,sans-serif;background:#f5f3ee;color:#2d3a2e;display:flex;flex-direction:column;min-height:100vh;margin:0;text-align:center}
main{flex:1;display:flex;align-items:center;justify-content:center;padding:24px 16px}
.box{max-width:480px;padding:48px 32px;background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.08)}
h1{color:#3a5a40;margin:0 0 16px;font-size:48px;letter-spacing:-1px}
p{color:#666;line-height:1.6}
.links{margin-top:24px;display:flex;gap:12px;justify-content:center}
.links a{background:#3a5a40;color:#fff;text-decoration:none;padding:10px 20px;border-radius:8px;font-size:14px}</style>
<script src="/nav.js?v=8" data-theme="light" defer></script></head>
<body><main><div class="box">
<h1>Stepstead</h1>
<p><strong>Her adımın değer kazanır.</strong></p>
<p>Yakında iOS ve Android'de. Yürü, keşfet, topla, üret.</p>
<div class="links">
<a href="/db/">📖 Craft Veritabanı</a>
</div>
<p style="font-size:12px;color:#999">v0.1 — kurulum aşamasında</p>
</div></main></body></html>

1
items/data/items.json Normal file

File diff suppressed because one or more lines are too long

158
items/index.html Normal file
View file

@ -0,0 +1,158 @@
<!doctype html>
<html lang="tr">
<head>
<meta charset="utf-8">
<title>Stepstead — Eşyalar</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{
--bg:#f5f3ee; --card:#fff; --ink:#2d3a2e; --muted:#77806f; --accent:#3a5a40;
--line:#e4e0d6;
--common:#6b7280; --uncommon:#2e8b45; --rare:#2563c9; --epic:#8b3fc9; --legendary:#c9762e;
}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,sans-serif;background:var(--bg);color:var(--ink)}
header{background:var(--accent);color:#fff;padding:20px 16px;position:sticky;top:0;z-index:5;box-shadow:0 2px 12px rgba(0,0,0,.15)}
header .inner{max-width:1000px;margin:0 auto}
header h1{margin:0 0 2px;font-size:24px}
header h1 a{color:#fff;text-decoration:none}
header p{margin:0;font-size:13px;opacity:.85}
.controls{max-width:1000px;margin:16px auto 0;padding:0 16px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
#q{flex:1 1 220px;padding:10px 14px;border:1px solid var(--line);border-radius:8px;font-size:15px;background:var(--card)}
.row{max-width:1000px;margin:8px auto 0;padding:0 16px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.row .lbl{font-size:12px;color:var(--muted);margin-right:2px}
.chip{padding:7px 14px;border-radius:999px;border:1px solid var(--line);background:var(--card);cursor:pointer;font-size:13px;white-space:nowrap}
.chip.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.chip.cls.on{background:#2f4a6b;border-color:#2f4a6b}
main{max-width:1000px;margin:16px auto 60px;padding:0 16px}
.stats{color:var(--muted);font-size:13px;margin:8px 2px 16px}
h2.group{font-size:19px;margin:28px 0 4px;color:var(--accent);border-bottom:2px solid var(--line);padding-bottom:6px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
.card .name{font-weight:600;font-size:15px}
.card .meta{font-size:12px;color:var(--muted);margin:4px 0 6px;display:flex;gap:10px;flex-wrap:wrap}
.card .line{font-size:12.5px;line-height:1.5;margin-top:3px}
.card .line b{color:var(--muted);font-weight:500}
.tag{display:inline-block;border-radius:6px;padding:1px 7px;font-size:11.5px;margin:1px 3px 1px 0}
.tag.slot{background:#e8f0e9;color:var(--accent)}
.tag.cls{background:#e6ecf5;color:#2f4a6b}
.tag.all{background:#eef2ec;color:var(--muted)}
.tag.src{background:#f3ecdd;color:#8a6a2e}
.tag.eff{background:#e9f3f0;color:#2e7d6b}
.r-common{color:var(--common)} .r-uncommon{color:var(--uncommon)} .r-rare{color:var(--rare)}
.r-epic{color:var(--epic)} .r-legendary{color:var(--legendary)}
.empty{color:var(--muted);text-align:center;margin:48px 0}
</style>
<script src="/nav.js?v=8" data-theme="light" defer></script>
</head>
<body>
<header><div class="inner">
<h1><a href="/">Stepstead</a> · Eşyalar</h1>
<p>Tüm silah, giysi, alet ve maddeler — hangi sınıf kullanır, nereden gelir</p>
</div></header>
<div class="controls">
<input id="q" type="search" placeholder="Eşya ara… (örn. Kılıç, Asa, Bakır, Çanta)">
</div>
<div class="row" id="typechips"></div>
<div class="row" id="clschips"><span class="lbl">Sınıf:</span></div>
<main>
<div class="stats" id="stats"></div>
<div id="list"></div>
</main>
<script>
let DATA=null, activeType="Tümü", activeClass=null;
const norm=s=>s.toLocaleLowerCase("tr");
const RORDER={common:0,uncommon:1,rare:2,epic:3,legendary:4};
fetch("data/items.json").then(r=>r.json()).then(d=>{DATA=d;buildChips();render();});
function chip(text,on,cls,fn){
const c=document.createElement("button");
c.className="chip"+(cls?" cls":"")+(on?" on":"");
c.textContent=text; c.onclick=fn; return c;
}
function buildChips(){
const tb=document.getElementById("typechips");
const types=["Tümü",...Object.keys(DATA.types).sort((a,b)=>DATA.types[b]-DATA.types[a])];
for(const t of types){
tb.appendChild(chip(t==="Tümü"?`Tümü (${DATA.total})`:`${t} (${DATA.types[t]})`,t===activeType,false,()=>{
activeType=t;document.querySelectorAll("#typechips .chip").forEach(x=>x.classList.remove("on"));
event.target.classList.add("on");render();
}));
}
const cb=document.getElementById("clschips");
cb.appendChild(chip("Hepsi",true,true,()=>{
activeClass=null;document.querySelectorAll("#clschips .chip").forEach(x=>x.classList.remove("on"));
event.target.classList.add("on");render();
}));
for(const c of DATA.classes){
cb.appendChild(chip(c,false,true,()=>{
activeClass=c;document.querySelectorAll("#clschips .chip").forEach(x=>x.classList.remove("on"));
event.target.classList.add("on");render();
}));
}
document.getElementById("q").addEventListener("input",render);
}
// sınıf filtresi: eşyayı verilen sınıf kullanabiliyor mu?
function usableBy(it,cls){
if(!cls)return true;
if(it.classes.length===0)return true; // herkese açık
return it.classes.includes(cls);
}
function matches(it,q){
if(!q)return true;
return norm(it.name).includes(q)||norm(it.type).includes(q)||
(it.slot&&norm(it.slot).includes(q))||it.classes.some(c=>norm(c).includes(q))||
it.gather.some(g=>norm(g.loc).includes(q)||norm(g.skill).includes(q));
}
function card(it){
const slot=it.slot?`<span class="tag slot">${it.slot}${it.two_handed?" (çift el)":""}</span>`:"";
let cls="";
if(it.slot_code&&it.slot_code!=="tool"){
cls=it.classes.length===0
? `<span class="tag all">Tüm sınıflar</span>`
: it.classes.map(c=>`<span class="tag cls">${c}</span>`).join("");
}
const src=[];
it.gather.forEach(g=>src.push(`<span class="tag src">📍 ${g.loc} · ${g.skill}</span>`));
if(it.craftable)src.push(`<span class="tag src">🔨 Üretilebilir</span>`);
const eff=it.effect&&it.effect.type==="steps"?`<span class="tag eff">+${it.effect.amount} adım</span>`:"";
const clsLine=(it.slot_code&&it.slot_code!=="tool")?`<div class="line"><b>Kullanabilen:</b> ${cls}</div>`:"";
const srcLine=src.length?`<div class="line"><b>Kaynak:</b> ${src.join(" ")}</div>`:"";
const descLine=it.desc?`<div class="line" style="color:var(--muted)">${it.desc}</div>`:"";
return `<div class="card">
<div class="name"><span class="r-${it.rarity}">${it.name}</span> ${slot} ${eff}</div>
<div class="meta"><span>${it.type}</span><span>🪙 ${it.value}</span></div>
${clsLine}${srcLine}${descLine}
</div>`;
}
function render(){
const q=norm(document.getElementById("q").value.trim());
const list=document.getElementById("list");
let rows=DATA.items.filter(it=>
(activeType==="Tümü"||it.type===activeType)&&usableBy(it,activeClass)&&matches(it,q));
document.getElementById("stats").textContent=
`${rows.length} eşya`+(activeClass?` · ${activeClass} kullanabilir`:"");
if(!rows.length){list.innerHTML='<div class="empty">Sonuç yok.</div>';return;}
const byType={};
for(const it of rows)(byType[it.type]??=[]).push(it);
let html="";
for(const t of Object.keys(byType).sort((a,b)=>byType[b].length-byType[a].length)){
html+=`<h2 class="group">${t} (${byType[t].length})</h2>`;
const sorted=byType[t].sort((a,b)=>
(RORDER[a.rarity]-RORDER[b.rarity])||a.name.localeCompare(b.name,"tr"));
html+=`<div class="grid">${sorted.map(card).join("")}</div>`;
}
list.innerHTML=html;
}
</script>
</body>
</html>

214
nav.js Normal file
View file

@ -0,0 +1,214 @@
/* Stepstead ortak header + footer.
Kullanım: <script src="/nav.js?v=7" data-theme="light|dark" defer></script>
Panel linkleri role/izin bazlı görünür: GET /auth/me permissions[].
İzinler: upload, lore, map_view, map_edit, users ("*" = admin). */
(function(){
"use strict";
const API="https://api.stepstead.com";
const PUBLIC_LINKS=[
{href:"/",label:"🏠 Ana Sayfa"},
{href:"/db/",label:"📖 Craft Veritabanı"},
{href:"/items/",label:"🎒 Eşyalar"},
];
const PANEL_LINKS=[
{href:"/assets/",label:"🎨 Asset Takip",perms:["upload"]},
{href:"/assets/upload.html",label:"📤 Dosya Yükle",perms:["upload"]},
{href:"/assets/lore.html",label:"📜 Lore",perms:["lore"]},
{href:"/assets/quests.html",label:"🗡 Görevler",perms:["lore"]},
{href:"/assets/npcs.html",label:"🧙 NPC'ler",perms:["npc"]},
{href:"/assets/chars.html",label:"⚔ Karakterler",perms:["chars"]},
{href:"/assets/map-editor.html",label:"🗺 Harita Editörü",perms:["map_view","map_edit"]},
{href:"/assets/users.html",label:"👥 Kullanıcılar",perms:["users"]},
];
const script=document.currentScript;
const theme=(script&&script.dataset.theme)==="light"?"light":"dark";
const css=`
.ss-nav{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.4;position:relative}
.ss-nav .ss-in{max-width:1040px;margin:0 auto;padding:0 16px;display:flex;align-items:center;gap:4px;flex-wrap:wrap;min-height:46px}
.ss-nav .ss-brand{font-weight:700;font-size:15px;text-decoration:none;margin-right:10px;letter-spacing:.2px}
.ss-nav a.ss-lnk{text-decoration:none;font-size:13px;padding:5px 10px;border-radius:6px;white-space:nowrap}
.ss-nav .ss-adm{display:none;align-items:center;gap:4px;flex-wrap:wrap}
.ss-nav .ss-adm.on{display:flex}
.ss-nav .ss-admtag{font-size:10px;text-transform:uppercase;letter-spacing:.08em;opacity:.6;margin:0 2px 0 8px}
.ss-nav .ss-authbtn{margin-left:auto;background:none;border:1px solid transparent;font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;white-space:nowrap}
.ss-nav .ss-loginbox{display:none;position:absolute;right:16px;top:100%;margin-top:6px;z-index:50;padding:12px;border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.35);min-width:230px}
.ss-nav .ss-loginbox.on{display:block}
.ss-nav .ss-loginbox input{display:block;width:100%;box-sizing:border-box;margin-bottom:8px;padding:7px 9px;border-radius:6px;font-size:13px}
.ss-nav .ss-loginbox button{width:100%;padding:7px;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer}
.ss-nav .ss-loginmsg{font-size:12px;margin-top:6px;min-height:14px}
.ss-foot{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:12px;margin-top:48px}
.ss-foot .ss-in{max-width:1040px;margin:0 auto;padding:16px;display:flex;gap:14px;flex-wrap:wrap;align-items:center}
.ss-foot a{text-decoration:none}
.ss-foot .ss-sp{margin-left:auto}
/* dark */
.ss-dark.ss-nav{background:#0b1016;border-bottom:1px solid #2c3a4f}
.ss-dark.ss-nav .ss-brand{color:#e8b923}
.ss-dark.ss-nav a.ss-lnk{color:#8b99a8}
.ss-dark.ss-nav a.ss-lnk:hover{color:#e6edf3;background:#1a2230}
.ss-dark.ss-nav a.ss-lnk.on{color:#e8b923;background:#1a2230}
.ss-dark.ss-nav .ss-admtag{color:#8b99a8}
.ss-dark.ss-nav .ss-authbtn{color:#8b99a8;border-color:#2c3a4f;background:#0b1016}
.ss-dark.ss-nav .ss-authbtn:hover{color:#e8b923;border-color:#e8b923}
.ss-dark.ss-nav .ss-loginbox{background:#1a2230;border:1px solid #2c3a4f}
.ss-dark.ss-nav .ss-loginbox input{background:#222d3d;color:#e6edf3;border:1px solid #2c3a4f}
.ss-dark.ss-nav .ss-loginbox button{background:#e8b923;color:#1a1300}
.ss-dark.ss-nav .ss-loginmsg{color:#e5534b}
.ss-dark.ss-foot{background:#0b1016;border-top:1px solid #2c3a4f;color:#8b99a8}
.ss-dark.ss-foot a{color:#8b99a8}
.ss-dark.ss-foot a:hover{color:#e8b923}
/* light */
.ss-light.ss-nav{background:#2f4a35;border-bottom:1px solid #253b2a}
.ss-light.ss-nav .ss-brand{color:#fff}
.ss-light.ss-nav a.ss-lnk{color:rgba(255,255,255,.75)}
.ss-light.ss-nav a.ss-lnk:hover{color:#fff;background:rgba(255,255,255,.12)}
.ss-light.ss-nav a.ss-lnk.on{color:#fff;background:rgba(255,255,255,.18)}
.ss-light.ss-nav .ss-admtag{color:rgba(255,255,255,.6)}
.ss-light.ss-nav .ss-authbtn{color:rgba(255,255,255,.75);border-color:rgba(255,255,255,.3);background:transparent}
.ss-light.ss-nav .ss-authbtn:hover{color:#fff;border-color:#fff}
.ss-light.ss-nav .ss-loginbox{background:#fff;border:1px solid #e4e0d6}
.ss-light.ss-nav .ss-loginbox input{background:#f5f3ee;color:#2d3a2e;border:1px solid #e4e0d6}
.ss-light.ss-nav .ss-loginbox button{background:#3a5a40;color:#fff}
.ss-light.ss-nav .ss-loginmsg{color:#c0392b}
.ss-light.ss-foot{background:transparent;border-top:1px solid #e4e0d6;color:#77806f}
.ss-light.ss-foot a{color:#77806f}
.ss-light.ss-foot a:hover{color:#3a5a40}
`;
let PERMS=[];
function tok(){return localStorage.getItem("ss_upload_token")||"";}
function hasPerm(p){return PERMS.includes("*")||PERMS.includes(p);}
function anyPerm(list){return list.some(hasPerm);}
function norm(p){return p.replace(/index\.html$/,"");}
function link(l){
const a=document.createElement("a");
a.className="ss-lnk"+(norm(location.pathname)===norm(l.href)?" on":"");
a.href=l.href;a.textContent=l.label;
return a;
}
function applyPerms(role){
const adm=document.getElementById("ssAdmLinks");
adm.innerHTML="";
// Panel yetkisi olan (bir rolü/izni bulunan) herkes TÜM sekmeleri görür.
// İşlem yetkisi sayfa/backend tarafında izne göre ayrıca denetlenir.
const isPanel=PERMS.length>0;
if(isPanel){
const tag=document.createElement("span");
tag.className="ss-admtag";tag.textContent=role||"panel";
adm.appendChild(tag);
for(const l of PANEL_LINKS)adm.appendChild(link(l));
adm.classList.add("on");
}else adm.classList.remove("on");
document.getElementById("ssAuthBtn").textContent=PERMS.length?"🚪 Çıkış":"🔑 Giriş";
if(PERMS.length)sessionStorage.setItem("ss_nav_perms",JSON.stringify({p:PERMS,r:role||""}));
else sessionStorage.removeItem("ss_nav_perms");
}
function build(){
const st=document.createElement("style");st.textContent=css;document.head.appendChild(st);
const nav=document.createElement("nav");
nav.className="ss-nav ss-"+theme;
const inner=document.createElement("div");inner.className="ss-in";
const brand=document.createElement("a");
brand.className="ss-brand";brand.href="/";brand.textContent="👣 Stepstead";
inner.appendChild(brand);
for(const l of PUBLIC_LINKS)inner.appendChild(link(l));
const adm=document.createElement("span");adm.className="ss-adm";adm.id="ssAdmLinks";
inner.appendChild(adm);
const authBtn=document.createElement("button");
authBtn.className="ss-authbtn";authBtn.id="ssAuthBtn";authBtn.textContent="🔑 Giriş";
authBtn.onclick=()=>{
if(PERMS.length){
localStorage.removeItem("ss_upload_token");
PERMS=[];applyPerms(null);
return;
}
document.getElementById("ssLoginBox").classList.toggle("on");
document.getElementById("ssLgEmail").focus();
};
inner.appendChild(authBtn);
const box=document.createElement("div");
box.className="ss-loginbox";box.id="ssLoginBox";
box.innerHTML=`
<input type="email" id="ssLgEmail" placeholder="E-posta" autocomplete="username">
<input type="password" id="ssLgPass" placeholder="Şifre" autocomplete="current-password">
<button id="ssLgBtn">Giriş yap</button>
<div class="ss-loginmsg" id="ssLgMsg"></div>`;
nav.appendChild(inner);
nav.appendChild(box);
document.body.prepend(nav);
document.getElementById("ssLgBtn").onclick=doLogin;
document.getElementById("ssLgPass").addEventListener("keydown",e=>{if(e.key==="Enter")doLogin();});
document.addEventListener("click",e=>{
if(!box.contains(e.target)&&e.target!==authBtn)box.classList.remove("on");
});
const foot=document.createElement("footer");
foot.className="ss-foot ss-"+theme;
foot.innerHTML=`<div class="ss-in">
<span>© ${new Date().getFullYear()} Stepstead her adımın değer kazanır</span>
<span class="ss-sp"><a href="/">Ana Sayfa</a></span>
<span><a href="/db/">Craft Veritabanı</a></span>
</div>`;
document.body.appendChild(foot);
}
async function doLogin(){
const msg=document.getElementById("ssLgMsg");
msg.textContent="…";
try{
const r=await fetch(API+"/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({email:document.getElementById("ssLgEmail").value.trim(),
password:document.getElementById("ssLgPass").value})});
const b=await r.json().catch(()=>({}));
if(!r.ok||!b.access_token){msg.textContent=b.detail||"Giriş başarısız";return;}
localStorage.setItem("ss_upload_token",b.access_token);
msg.textContent="";
await refresh();
if(PERMS.length){
document.getElementById("ssLoginBox").classList.remove("on");
document.getElementById("ssLgPass").value="";
}else msg.textContent="Giriş tamam — ama bu hesabın panel yetkisi yok";
}catch(e){msg.textContent="Bağlantı hatası";}
}
async function refresh(){
if(!tok()){PERMS=[];applyPerms(null);return;}
try{
const r=await fetch(API+"/auth/me",{headers:{Authorization:"Bearer "+tok()}});
if(r.ok){
const u=await r.json();
PERMS=u.permissions||[];
applyPerms(u.role);
}else{
if(r.status===401)localStorage.removeItem("ss_upload_token");
PERMS=[];applyPerms(null);
}
}catch(e){
// geçici ağ hatası: cache'e güven
try{
const c=JSON.parse(sessionStorage.getItem("ss_nav_perms")||"null");
if(c){PERMS=c.p;applyPerms(c.r);return;}
}catch(_){}
PERMS=[];applyPerms(null);
}
}
window.ssNavRefresh=refresh;
function init(){
build();
try{
const c=JSON.parse(sessionStorage.getItem("ss_nav_perms")||"null");
if(c&&tok()){PERMS=c.p;applyPerms(c.r);}
}catch(_){}
refresh();
}
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",init);
else init();
})();