307 lines
16 KiB
HTML
307 lines
16 KiB
HTML
<!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>Açı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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|