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

307 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="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>