initial commit
This commit is contained in:
530
web/index.html
Normal file
530
web/index.html
Normal file
@@ -0,0 +1,530 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>Guildlib Editor</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0d0e11; --bg2:#13151a; --bg3:#1c1f27; --bg4:#252930;
|
||||
--border:#2e3340; --border2:#3d4455;
|
||||
--accent:#e8ff6e; --accent2:#6ef0ff; --accent3:#ff6e8a;
|
||||
--text:#d4d9e8; --text2:#7c859e; --text3:#4a5068;
|
||||
--mono:'JetBrains Mono',monospace; --display:'Syne',sans-serif;
|
||||
--r:6px;
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;overflow:hidden}
|
||||
body{background:var(--bg);color:var(--text);font-family:var(--mono);font-size:13px;line-height:1.5}
|
||||
#root{height:100vh;display:flex;flex-direction:column}
|
||||
::-webkit-scrollbar{width:5px;height:5px}
|
||||
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
|
||||
|
||||
/* topbar */
|
||||
.topbar{height:48px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:16px;flex-shrink:0;z-index:10}
|
||||
.logo{font-family:var(--display);font-weight:800;font-size:16px;color:var(--accent);display:flex;align-items:center;gap:8px}
|
||||
.logo span{color:var(--text2);font-weight:400;font-size:12px}
|
||||
.sep{width:1px;height:20px;background:var(--border)}
|
||||
.status{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)}
|
||||
.dot{width:6px;height:6px;border-radius:50%;background:var(--text3);transition:background .3s}
|
||||
.dot.ok{background:#6effb4}.dot.err{background:var(--accent3)}
|
||||
.right{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||
input.srv{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--text);font-family:var(--mono);font-size:11px;padding:5px 10px;width:220px;outline:none}
|
||||
input.srv:focus{border-color:var(--accent)}
|
||||
|
||||
/* buttons */
|
||||
.btn{background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);color:var(--text);font-family:var(--mono);font-size:12px;padding:6px 14px;cursor:pointer;transition:all .15s;white-space:nowrap}
|
||||
.btn:hover{background:var(--bg4);border-color:var(--accent);color:var(--accent)}
|
||||
.btn.pri{background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:700}
|
||||
.btn.pri:hover{background:#d4eb5a}
|
||||
.btn.danger{border-color:var(--accent3);color:var(--accent3)}
|
||||
.btn.danger:hover{background:rgba(255,110,138,.1)}
|
||||
.btn:disabled{opacity:.35;cursor:not-allowed}
|
||||
.btn.sm{font-size:11px;padding:3px 10px}
|
||||
|
||||
/* layout */
|
||||
.layout{display:grid;grid-template-columns:220px 280px 1fr;flex:1;overflow:hidden}
|
||||
.panel{border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
|
||||
.ph{padding:12px 16px;border-bottom:1px solid var(--border);font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text3);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.pb{flex:1;overflow-y:auto}
|
||||
|
||||
/* shard list */
|
||||
.shard-item{padding:10px 16px;cursor:pointer;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--border);transition:background .1s}
|
||||
.shard-item:hover,.shard-item.active{background:var(--bg3)}
|
||||
.shard-item.active .sname{color:var(--accent)}
|
||||
.sdot{width:8px;height:8px;border-radius:2px;flex-shrink:0}
|
||||
.sname{font-size:12px;font-weight:500}
|
||||
.scnt{margin-left:auto;font-size:10px;color:var(--text3);background:var(--bg4);padding:1px 6px;border-radius:10px}
|
||||
|
||||
/* type tree */
|
||||
.type-item{padding:8px 16px 8px 28px;cursor:pointer;display:flex;align-items:center;gap:8px;border-bottom:1px solid rgba(46,51,64,.5);transition:background .1s}
|
||||
.type-item:hover{background:var(--bg3)}
|
||||
.type-item.active{background:var(--bg3);border-left:2px solid var(--accent);padding-left:26px}
|
||||
.tname{font-size:12px}
|
||||
.tbadge{margin-left:auto;font-size:9px;padding:1px 5px;border-radius:3px;background:var(--bg4);color:var(--text3);text-transform:lowercase}
|
||||
.tbadge.child{background:rgba(232,255,110,.1);color:var(--accent)}
|
||||
|
||||
/* entry list */
|
||||
.search-bar{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:8px;flex-shrink:0}
|
||||
.sinput{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--text);font-family:var(--mono);font-size:12px;padding:6px 10px;outline:none}
|
||||
.sinput:focus{border-color:var(--accent2)}
|
||||
.erow{padding:10px 16px;cursor:pointer;border-bottom:1px solid var(--border);transition:background .1s}
|
||||
.erow:hover{background:var(--bg3)}
|
||||
.erow.active{background:var(--bg3);border-left:2px solid var(--accent2);padding-left:14px}
|
||||
.ename{font-size:12px;font-weight:500;margin-bottom:3px}
|
||||
.emeta{font-size:10px;color:var(--text3);display:flex;gap:8px;flex-wrap:wrap}
|
||||
.chip{font-size:9px;padding:1px 5px;border-radius:3px;background:var(--bg4);color:var(--text3);white-space:nowrap}
|
||||
.chip.plat{background:rgba(110,240,255,.1);color:var(--accent2)}
|
||||
.chip.tag{background:rgba(232,255,110,.08);color:#b8cc55}
|
||||
|
||||
/* form */
|
||||
.form-area{flex:1;overflow:hidden;display:flex;flex-direction:column}
|
||||
.ftopbar{padding:12px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0;background:var(--bg2)}
|
||||
.ftitle{font-family:var(--display);font-size:15px;font-weight:600}
|
||||
.fsub{font-size:11px;color:var(--text3);margin-left:auto}
|
||||
.fbody{flex:1;overflow-y:auto;padding:24px}
|
||||
.fsave{padding:16px 24px;border-top:1px solid var(--border);display:flex;gap:10px;flex-shrink:0}
|
||||
|
||||
/* field rows */
|
||||
.fgroup{margin-bottom:22px}
|
||||
.fsec{font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text3);margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
|
||||
.fsec-badge{font-size:9px;background:var(--bg4);color:var(--text3);padding:1px 6px;border-radius:3px;text-transform:none;letter-spacing:0}
|
||||
.frow{display:grid;grid-template-columns:160px 1fr;align-items:start;gap:12px;margin-bottom:10px}
|
||||
.flabel{font-size:12px;color:var(--text2);padding-top:7px;display:flex;flex-direction:column;gap:2px}
|
||||
.fhint{font-size:10px;color:var(--text3)}
|
||||
.finput{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--text);font-family:var(--mono);font-size:12px;padding:7px 10px;outline:none;width:100%;transition:border-color .2s}
|
||||
.finput:focus{border-color:var(--accent2);background:var(--bg2)}
|
||||
.finput[type=number]::-webkit-inner-spin-button{opacity:.3}
|
||||
select.finput{cursor:pointer}
|
||||
textarea.finput{resize:vertical;min-height:80px}
|
||||
|
||||
/* checkbox toggle */
|
||||
.toggle-wrap{display:flex;align-items:center;gap:8px;padding-top:6px}
|
||||
.toggle{position:relative;width:34px;height:18px;flex-shrink:0}
|
||||
.toggle input{opacity:0;width:0;height:0;position:absolute}
|
||||
.toggle-track{position:absolute;inset:0;background:var(--bg4);border:1px solid var(--border2);border-radius:9px;transition:background .2s}
|
||||
.toggle input:checked+.toggle-track{background:var(--accent);border-color:var(--accent)}
|
||||
.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .2s;pointer-events:none}
|
||||
.toggle input:checked~.toggle-thumb{transform:translateX(16px)}
|
||||
.toggle-label{font-size:12px;color:var(--text)}
|
||||
|
||||
/* platform/tag editor */
|
||||
.tag-grid{display:flex;flex-wrap:wrap;gap:6px;padding-top:4px}
|
||||
.tag-chip{display:flex;align-items:center;gap:5px;padding:3px 8px;border-radius:4px;border:1px solid var(--border);cursor:pointer;font-size:11px;color:var(--text2);transition:all .15s;user-select:none}
|
||||
.tag-chip:hover{border-color:var(--border2);color:var(--text)}
|
||||
.tag-chip.on{border-color:var(--accent2);color:var(--accent2);background:rgba(110,240,255,.08)}
|
||||
.tag-chip.on.tag-type{border-color:var(--accent);color:var(--accent);background:rgba(232,255,110,.06)}
|
||||
.tag-dot{width:5px;height:5px;border-radius:50%;background:currentColor}
|
||||
|
||||
/* empty */
|
||||
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--text3);font-size:12px}
|
||||
.eicon{font-size:28px;opacity:.25}
|
||||
.lbar{height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:scan 1.2s linear infinite;overflow:hidden}
|
||||
@keyframes scan{0%{transform:translateX(-100%)}100%{transform:translateX(200%)}
|
||||
}
|
||||
/* toast */
|
||||
.toast-host{position:fixed;bottom:20px;right:20px;display:flex;flex-direction:column;gap:8px;z-index:999;pointer-events:none}
|
||||
.toast{background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);padding:10px 16px;font-size:12px;display:flex;align-items:center;gap:10px;animation:tin .2s ease;box-shadow:0 4px 24px rgba(0,0,0,.5)}
|
||||
.toast.ok{border-color:#6effb4}.toast.err{border-color:var(--accent3)}
|
||||
@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||||
.hint-bar{padding:12px 16px;border-top:1px solid var(--border);flex-shrink:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script type="text/babel" data-presets="react">
|
||||
const { useState, useEffect, useCallback, useMemo, useRef } = React;
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
const SHARD_COLORS = ['#ff8c42','#6ef0ff','#c06eff','#6effb4','#ff6e8a','#ffe06e','#6ea8ff'];
|
||||
function shardColor(name, allShards) {
|
||||
const i = allShards.indexOf(name);
|
||||
return SHARD_COLORS[i % SHARD_COLORS.length] || '#7c859e';
|
||||
}
|
||||
|
||||
function makeApi(base, key) {
|
||||
const h = { 'Content-Type':'application/json', ...(key ? {'X-Api-Key':key} : {}) };
|
||||
return {
|
||||
get: p => fetch(base+p,{headers:h}).then(r=>r.json()),
|
||||
post: (p,b)=> fetch(base+p,{method:'POST', headers:h,body:JSON.stringify(b)}).then(r=>r.json()),
|
||||
put: (p,b)=> fetch(base+p,{method:'PUT', headers:h,body:JSON.stringify(b)}).then(r=>r.json()),
|
||||
delete: p => fetch(base+p,{method:'DELETE',headers:h}).then(r=>r.json()),
|
||||
};
|
||||
}
|
||||
|
||||
function useToasts() {
|
||||
const [toasts, set] = useState([]);
|
||||
const push = useCallback((msg, type='ok') => {
|
||||
const id = Date.now();
|
||||
set(t=>[...t,{id,msg,type}]);
|
||||
setTimeout(()=>set(t=>t.filter(x=>x.id!==id)),3000);
|
||||
},[]);
|
||||
return { toasts, push };
|
||||
}
|
||||
|
||||
// ── TagEditor ─────────────────────────────────────────────────────────────────
|
||||
function TagEditor({ label, available, selected, onChange, colorClass }) {
|
||||
return (
|
||||
<div className="fgroup">
|
||||
<div className="fsec">{label}</div>
|
||||
<div className="frow">
|
||||
<label className="flabel">{label.toLowerCase()}<span className="fhint">click to toggle</span></label>
|
||||
<div className="tag-grid">
|
||||
{available.map(v => {
|
||||
const on = selected.includes(v) || selected.includes('all');
|
||||
return (
|
||||
<div
|
||||
key={v}
|
||||
className={`tag-chip ${on?'on':''} ${colorClass||''}`}
|
||||
onClick={() => {
|
||||
if (v === 'all') { onChange(['all']); return; }
|
||||
const next = selected.filter(s=>s!=='all');
|
||||
onChange(next.includes(v) ? next.filter(s=>s!==v) : [...next, v]);
|
||||
}}
|
||||
>
|
||||
<span className="tag-dot"/>
|
||||
{v}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FieldInput ────────────────────────────────────────────────────────────────
|
||||
function FieldInput({ field, value, onChange }) {
|
||||
// enum → dropdown
|
||||
if (field.enumOptions?.length) return (
|
||||
<select className="finput" value={value??''} onChange={e=>onChange(e.target.value)}>
|
||||
<option value="">— select —</option>
|
||||
{field.enumOptions.map(o=><option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
);
|
||||
// bool → toggle
|
||||
if (field.type==='bool') return (
|
||||
<div className="toggle-wrap">
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={!!value} onChange={e=>onChange(e.target.checked)}/>
|
||||
<div className="toggle-track"/>
|
||||
<div className="toggle-thumb"/>
|
||||
</label>
|
||||
<span className="toggle-label">{value ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
);
|
||||
// number
|
||||
if (['int','long','float','double'].includes(field.type)) return (
|
||||
<input type="number" className="finput"
|
||||
step={['int','long'].includes(field.type)?1:0.001}
|
||||
value={value??''}
|
||||
onChange={e=>onChange(['int','long'].includes(field.type)?parseInt(e.target.value):parseFloat(e.target.value))}/>
|
||||
);
|
||||
// string[] → textarea one-per-line
|
||||
if (field.type==='string[]') return (
|
||||
<textarea className="finput" rows={3}
|
||||
placeholder="One value per line"
|
||||
value={Array.isArray(value)?value.join('\n'):(value||'')}
|
||||
onChange={e=>onChange(e.target.value.split('\n').filter(Boolean))}/>
|
||||
);
|
||||
// multiline
|
||||
if (field.isMultiline) return (
|
||||
<textarea className="finput" rows={4}
|
||||
value={value??''}
|
||||
onChange={e=>onChange(e.target.value)}/>
|
||||
);
|
||||
return (
|
||||
<input type="text" className="finput"
|
||||
value={value??''}
|
||||
onChange={e=>onChange(e.target.value)}/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EntryForm ─────────────────────────────────────────────────────────────────
|
||||
function EntryForm({ schema, shard, entryId, typeSchema, api, onSaved, onDelete }) {
|
||||
const [data, setData] = useState({});
|
||||
const [plats, setPlats] = useState(typeSchema?.defaultPlatforms ?? ['all']);
|
||||
const [tags, setTags] = useState(typeSchema?.defaultTags ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isNew = !entryId;
|
||||
|
||||
const allFields = typeSchema?.fields ?? [];
|
||||
const ownFields = allFields.filter(f=>!f.isInherited);
|
||||
const inheritedFlds = allFields.filter(f=>f.isInherited);
|
||||
|
||||
const availPlats = schema?.platformConfig?.platforms ?? [];
|
||||
const availTags = schema?.platformConfig?.tags ?? [];
|
||||
|
||||
useEffect(()=>{
|
||||
if (!entryId){setData({});setPlats(typeSchema?.defaultPlatforms??['all']);setTags(typeSchema?.defaultTags??[]);return;}
|
||||
setLoading(true);
|
||||
api.get(`/entries/${shard}/${entryId}`)
|
||||
.then(r=>{setData(r.data||{});setPlats(r.platforms||['all']);setTags(r.tags||[]);})
|
||||
.finally(()=>setLoading(false));
|
||||
},[entryId, shard]);
|
||||
|
||||
function setField(name,val){setData(d=>({...d,[name]:val}));}
|
||||
|
||||
async function save(){
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isNew) await api.post(`/entries/${shard}`,{typeName:typeSchema.typeName,platforms:plats,tags,data});
|
||||
else await api.put(`/entries/${shard}/${entryId}`,{platforms:plats,tags,data});
|
||||
onSaved();
|
||||
} finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function del(){
|
||||
if(!confirm('Delete this entry?')) return;
|
||||
await api.delete(`/entries/${shard}/${entryId}`);
|
||||
onDelete();
|
||||
}
|
||||
|
||||
if(loading) return <div className="form-area"><div className="lbar"/></div>;
|
||||
|
||||
const renderFields = (fields, sectionLabel, inh) => {
|
||||
if (!fields.length) return null;
|
||||
return (
|
||||
<div className="fgroup" key={sectionLabel}>
|
||||
<div className="fsec">
|
||||
{sectionLabel}
|
||||
{inh && <span className="fsec-badge">inherited</span>}
|
||||
</div>
|
||||
{fields.map(f=>(
|
||||
<div className="frow" key={f.name}>
|
||||
<label className="flabel">
|
||||
{f.name}
|
||||
<span className="fhint">
|
||||
{f.refShard ? `→ ${f.refShard}` : f.type}
|
||||
{f.isDisplayName ? ' · display name' : ''}
|
||||
</span>
|
||||
</label>
|
||||
<FieldInput field={f} value={data[f.name]} onChange={v=>setField(f.name,v)}/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-area">
|
||||
<div className="ftopbar">
|
||||
<div className="ftitle">{isNew?`New ${typeSchema?.displayName}`:(data.name||data.entryId||entryId)}</div>
|
||||
<span className="chip">{typeSchema?.displayName}</span>
|
||||
<span className="fsub">{isNew?'unsaved':`v${data.rowVersion||'?'}`}</span>
|
||||
</div>
|
||||
<div className="fbody">
|
||||
{availPlats.length > 0 && (
|
||||
<TagEditor
|
||||
label="Platforms"
|
||||
available={['all',...availPlats]}
|
||||
selected={plats}
|
||||
onChange={setPlats}
|
||||
colorClass=""
|
||||
/>
|
||||
)}
|
||||
{availTags.length > 0 && (
|
||||
<TagEditor
|
||||
label="Tags"
|
||||
available={availTags}
|
||||
selected={tags}
|
||||
onChange={setTags}
|
||||
colorClass="tag-type"
|
||||
/>
|
||||
)}
|
||||
{typeSchema?.description && (
|
||||
<div style={{marginBottom:16,padding:'10px 14px',background:'var(--bg3)',borderRadius:'var(--r)',fontSize:11,color:'var(--text2)'}}>
|
||||
{typeSchema.description}
|
||||
</div>
|
||||
)}
|
||||
{renderFields(inheritedFlds, 'Inherited fields', true)}
|
||||
{renderFields(ownFields, `${typeSchema?.displayName||'Entry'} fields`, false)}
|
||||
</div>
|
||||
<div className="fsave">
|
||||
<button className="btn pri" onClick={save} disabled={saving}>
|
||||
{saving?'Saving…':(isNew?'Create entry':'Save changes')}
|
||||
</button>
|
||||
{!isNew && <button className="btn danger" onClick={del}>Delete</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App ───────────────────────────────────────────────────────────────────────
|
||||
function App() {
|
||||
const [serverUrl, setServerUrl] = useState(localStorage.getItem('gl_srv')||'http://localhost:3000');
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('gl_key')||'');
|
||||
const [schema, setSchema] = useState(null);
|
||||
const [serverOk, setServerOk] = useState(null);
|
||||
|
||||
const [selShard, setSelShard] = useState(null);
|
||||
const [selType, setSelType] = useState(null);
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [selEntry, setSelEntry] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loadingE, setLoadingE] = useState(false);
|
||||
|
||||
const { toasts, push } = useToasts();
|
||||
const api = useMemo(()=>makeApi(serverUrl,apiKey),[serverUrl,apiKey]);
|
||||
|
||||
useEffect(()=>localStorage.setItem('gl_srv',serverUrl),[serverUrl]);
|
||||
useEffect(()=>localStorage.setItem('gl_key',apiKey),[apiKey]);
|
||||
|
||||
async function connect(){
|
||||
try {
|
||||
await api.get('/health');
|
||||
setServerOk(true);
|
||||
const s = await api.get('/schema');
|
||||
if(s.types){ setSchema(s); if(!selShard&&s.shards?.length) setSelShard(s.shards[0]); push('Connected','ok'); }
|
||||
else { setServerOk(true); push('Connected — no schema yet','ok'); }
|
||||
} catch(e){ setServerOk(false); push(`Failed: ${e.message}`,'err'); }
|
||||
}
|
||||
|
||||
async function loadEntries(){
|
||||
if(!selShard||!selType) return;
|
||||
setLoadingE(true);
|
||||
try { const r=await api.get(`/entries/${selShard}?type=${encodeURIComponent(selType)}`); setEntries(Array.isArray(r)?r:[]); }
|
||||
catch(e){ push(`Load failed: ${e.message}`,'err'); }
|
||||
finally { setLoadingE(false); }
|
||||
}
|
||||
useEffect(()=>{loadEntries();},[selShard,selType]);
|
||||
|
||||
const allShards = schema?.shards || [];
|
||||
const typesForShard = useMemo(()=>schema?schema.types.filter(t=>t.shard===selShard):[],[schema,selShard]);
|
||||
const selTypeSchema = useMemo(()=>schema?.types.find(t=>t.typeName===selType),[schema,selType]);
|
||||
const rootTypes = typesForShard.filter(t=>!t.parentType);
|
||||
const childOf = p=>typesForShard.filter(t=>t.parentType===p.typeName);
|
||||
const filtered = entries.filter(e=>{
|
||||
const n=(e.data?.name||e.id||'').toLowerCase();
|
||||
return n.includes(search.toLowerCase());
|
||||
});
|
||||
|
||||
function getDisplayName(entry){
|
||||
// Find the [GuildDisplayName] field from schema
|
||||
const schema2 = selTypeSchema;
|
||||
if(schema2){
|
||||
const dnField = schema2.fields.find(f=>f.isDisplayName);
|
||||
if(dnField && entry.data?.[dnField.name]) return entry.data[dnField.name];
|
||||
}
|
||||
return entry.data?.name || entry.id;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<div className="logo">◈ Guildlib <span>editor</span></div>
|
||||
<div className="sep"/>
|
||||
<div className="status">
|
||||
<div className={`dot ${serverOk===true?'ok':serverOk===false?'err':''}`}/>
|
||||
{serverOk===null?'not connected':serverOk?'connected':'unreachable'}
|
||||
</div>
|
||||
<div className="right">
|
||||
<input className="srv" value={serverUrl} onChange={e=>setServerUrl(e.target.value)}
|
||||
onKeyDown={e=>e.key==='Enter'&&connect()} placeholder="http://localhost:3000"/>
|
||||
<button className="btn pri" onClick={connect}>Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="layout">
|
||||
{/* Col 1 — shards + type tree */}
|
||||
<div className="panel">
|
||||
<div className="ph">Shards</div>
|
||||
<div className="pb">
|
||||
{allShards.length===0&&<div className="empty"><div className="eicon">◈</div>Connect to load</div>}
|
||||
{allShards.map(sh=>(
|
||||
<React.Fragment key={sh}>
|
||||
<div className={`shard-item${selShard===sh?' active':''}`}
|
||||
onClick={()=>{setSelShard(sh);setSelType(null);setSelEntry(null);}}>
|
||||
<div className="sdot" style={{background:shardColor(sh,allShards)}}/>
|
||||
<span className="sname">{sh}</span>
|
||||
<span className="scnt">{schema?.types.filter(t=>t.shard===sh).length||0}</span>
|
||||
</div>
|
||||
{selShard===sh&&rootTypes.map(rt=>(
|
||||
<div key={rt.typeName}>
|
||||
<div className={`type-item${selType===rt.typeName?' active':''}`}
|
||||
onClick={()=>{setSelType(rt.typeName);setSelEntry(null);}}>
|
||||
<span className="tname">{rt.displayName}</span>
|
||||
{rt.childTypes.length>0&&<span className="tbadge">{rt.childTypes.length} sub</span>}
|
||||
</div>
|
||||
{childOf(rt).map(ct=>(
|
||||
<div key={ct.typeName}
|
||||
className={`type-item${selType===ct.typeName?' active':''}`}
|
||||
style={{paddingLeft:44}}
|
||||
onClick={()=>{setSelType(ct.typeName);setSelEntry(null);}}>
|
||||
<span style={{color:'var(--text3)',marginRight:4}}>└</span>
|
||||
<span className="tname">{ct.displayName}</span>
|
||||
<span className="tbadge child">child</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Col 2 — entry list */}
|
||||
<div className="panel">
|
||||
<div className="ph">
|
||||
{selType?selTypeSchema?.displayName:'Entries'}
|
||||
{selType&&<button className="btn sm pri" onClick={()=>setSelEntry('new')}>+ New</button>}
|
||||
</div>
|
||||
{selType&&<div className="search-bar"><input className="sinput" placeholder="Search…" value={search} onChange={e=>setSearch(e.target.value)}/></div>}
|
||||
<div className="pb">
|
||||
{!selType&&<div className="empty"><div className="eicon">↑</div>Select a type</div>}
|
||||
{selType&&loadingE&&<div className="lbar"/>}
|
||||
{selType&&!loadingE&&filtered.length===0&&<div className="empty"><div className="eicon">□</div>No entries</div>}
|
||||
{filtered.map(e=>(
|
||||
<div key={e.id} className={`erow${selEntry===e.id?' active':''}`} onClick={()=>setSelEntry(e.id)}>
|
||||
<div className="ename">{getDisplayName(e)}</div>
|
||||
<div className="emeta">
|
||||
<span className="chip">{e.id?.slice(0,10)}</span>
|
||||
<span>v{e.row_version}</span>
|
||||
{(e.platforms||[]).filter(p=>p!=='all').slice(0,3).map(p=><span key={p} className="chip plat">{p}</span>)}
|
||||
{(e.tags||[]).slice(0,2).map(t=><span key={t} className="chip tag">{t}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hint-bar">
|
||||
{selType&&<button className="btn" style={{width:'100%'}} onClick={()=>setSelEntry('new')}>+ Create {selTypeSchema?.displayName}</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Col 3 — form */}
|
||||
<div style={{display:'flex',flexDirection:'column',overflow:'hidden'}}>
|
||||
{!selType&&<div className="empty"><div className="eicon">◈</div>{schema?'Select a type':'Connect to a server'}</div>}
|
||||
{selType&&(selEntry==='new'||selEntry)&&(
|
||||
<EntryForm
|
||||
key={selEntry+selType}
|
||||
schema={schema}
|
||||
shard={selShard}
|
||||
entryId={selEntry==='new'?null:selEntry}
|
||||
typeSchema={selTypeSchema}
|
||||
api={api}
|
||||
onSaved={()=>{push('Saved ✓','ok');setSelEntry(null);loadEntries();}}
|
||||
onDelete={()=>{push('Deleted','ok');setSelEntry(null);loadEntries();}}
|
||||
/>
|
||||
)}
|
||||
{selType&&!selEntry&&<div className="empty"><div className="eicon">→</div>Select or create an entry</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toast-host">
|
||||
{toasts.map(t=><div key={t.id} className={`toast ${t.type}`}>{t.type==='ok'?'✓':'✗'} {t.msg}</div>)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user