@@ -18,6 +18,11 @@
# dialogue li [ data-role = "user" ] { font-weight : 650 ; }
# dialogue li [ data-role = "assistant" ] { padding-inline-start : .75 rem ; border-inline-start : .2 rem solid currentColor ; }
# message { width : 100 % ; min-height : 3.5 rem ; box-sizing : border-box ; }
# admin-content { display : grid ; gap : 1 rem ; }
# admin-content section , # admin-content article { border : 1 px solid currentColor ; border-radius : .35 rem ; padding : .75 rem ; }
# admin-content pre { white-space : pre-wrap ; overflow-wrap : anywhere ; max-height : 32 rem ; overflow : auto ; }
# admin-toolbar { display : flex ; flex-wrap : wrap ; gap : .5 rem ; margin-block : 1 rem ; }
# player-search { display : flex ; flex-wrap : wrap ; align-items : end ; gap : .5 rem ; }
. sr-only { position : absolute ; width : 1 px ; height : 1 px ; padding : 0 ; margin : -1 px ; overflow : hidden ; clip : rect ( 0 , 0 , 0 , 0 ) ; white-space : nowrap ; border : 0 ; }
[ hidden ] { display : none !important ; }
footer { margin-top : 2 rem ; border-top : 1 px solid ; padding-top : .5 rem ; color : GrayText ; font-size : .875 rem ; }
@@ -43,11 +48,25 @@
< span id = "message-help" class = "sr-only" > Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.< / span >
< / form >
< / section >
< section id = "admin-view" aria-labelledby = "admin-heading" hidden >
< h1 id = "admin-heading" > Administration< / h1 >
< p > Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.< / p >
< nav id = "admin-toolbar" aria-label = "Administrationsvyer" >
< button type = "button" data-admin-view = "players" > Spelare< / button >
< button type = "button" data-admin-view = "prompts" > Prompter< / button >
< button type = "button" data-admin-view = "rules" > Luau-regler< / button >
< button type = "button" data-admin-view = "audit" > Revisionslogg< / button >
< button type = "button" id = "admin-logout" > Logga ut< / button >
< / nav >
< div id = "admin-content" aria-live = "polite" aria-busy = "false" > < / div >
< / section >
< / main >
< footer id = "version-footer" > Frontend < span id = "frontend-version" > __FRONTEND_VERSION__< / span > · API < span id = "api-version" > hämtar version…< / span > < / footer >
< script >
const auth = document . querySelector ( '#auth' ) ;
const play = document . querySelector ( '#play' ) ;
const adminView = document . querySelector ( '#admin-view' ) ;
const adminContent = document . querySelector ( '#admin-content' ) ;
const transcript = document . querySelector ( '#dialogue' ) ;
const messageField = document . querySelector ( '#message' ) ;
const messageLabel = document . querySelector ( '#message-label' ) ;
@@ -57,6 +76,8 @@
let gameState = null ;
let submitBusy = false ;
let pendingDialogue = null ;
let adminOffset = 0 ;
const adminPageSize = 50 ;
async function request ( path , options = { } ) {
const response = await fetch ( path , { credentials : 'same-origin' , ... options } ) ;
@@ -88,15 +109,104 @@
function showAuth ( ) {
stage = 'auth' ;
play . hidden = true ;
adminView . hidden = true ;
auth . hidden = false ;
document . querySelector ( '#auth-heading' ) . textContent = location . pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator' ;
document . querySelector ( '#version-footer' ) . hidden = false ;
}
function showPlay ( ) {
auth . hidden = true ;
adminView . hidden = true ;
play . hidden = false ;
document . querySelector ( '#version-footer' ) . hidden = true ;
messageField . focus ( ) ;
}
function showAdmin ( ) {
auth . hidden = true ;
play . hidden = true ;
adminView . hidden = false ;
document . querySelector ( '#version-footer' ) . hidden = true ;
}
function adminElement ( tag , text , parent = adminContent ) {
const element = document . createElement ( tag ) ;
if ( text !== undefined ) element . textContent = text ;
parent . append ( element ) ;
return element ;
}
function adminJson ( title , value , parent = adminContent ) {
const details = adminElement ( 'details' , undefined , parent ) ;
adminElement ( 'summary' , title , details ) ;
adminElement ( 'pre' , JSON . stringify ( value , null , 2 ) , details ) ;
return details ;
}
async function loadAdminPlayers ( query = '' , offset = 0 ) {
adminOffset = offset ;
adminContent . replaceChildren ( ) ;
const search = adminElement ( 'form' ) ;
search . id = 'player-search' ;
const label = adminElement ( 'label' , 'Sök efter användarnamn eller karaktär' , search ) ;
const field = document . createElement ( 'input' ) ;
field . type = 'search' ; field . name = 'q' ; field . value = query ; field . maxLength = 100 ;
label . append ( field ) ;
const submit = adminElement ( 'button' , 'Sök' , search ) ; submit . type = 'submit' ;
search . addEventListener ( 'submit' , event => { event . preventDefault ( ) ; loadAdminPlayers ( field . value , 0 ) ; } ) ;
const result = await request ( ` /api/admin/players?q= ${ encodeURIComponent ( query ) } &limit= ${ adminPageSize } &offset= ${ offset } ` ) ;
adminElement ( 'p' , ` ${ result . total } spelare. Visar ${ result . offset + 1 } – ${ Math . min ( result . total , result . offset + result . players . length ) } . ` , adminContent ) ;
const list = adminElement ( 'ul' ) ;
for ( const player of result . players ) {
const item = adminElement ( 'li' , undefined , list ) ;
const button = adminElement ( 'button' , ` ${ player . username } · ${ player . character _name || 'ingen karaktär' } · världsversion ${ player . world _version ? ? 'saknas' } ` , item ) ;
button . type = 'button' ; button . addEventListener ( 'click' , ( ) => openAdminPlayer ( player . account _id ) ) ;
}
const paging = adminElement ( 'div' ) ;
const previous = adminElement ( 'button' , 'Föregående sida' , paging ) ;
previous . type = 'button' ; previous . disabled = offset <= 0 ; previous . addEventListener ( 'click' , ( ) => loadAdminPlayers ( query , Math . max ( 0 , offset - adminPageSize ) ) ) ;
const next = adminElement ( 'button' , 'Nästa sida' , paging ) ;
next . type = 'button' ; next . disabled = offset + result . players . length >= result . total ; next . addEventListener ( 'click' , ( ) => loadAdminPlayers ( query , offset + adminPageSize ) ) ;
}
async function openAdminPlayer ( accountId ) {
adminContent . replaceChildren ( ) ;
const back = adminElement ( 'button' , 'Tillbaka till spelare' ) ; back . type = 'button' ; back . addEventListener ( 'click' , ( ) => loadAdminPlayers ( '' , adminOffset ) ) ;
adminElement ( 'h2' , 'Spelarprofil' ) ;
const profile = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } ` ) ;
adminJson ( ` ${ profile . username } · profil och biografi ` , profile ) ;
const trace = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } /trace?limit=200 ` ) ;
const entries = [
... trace . events . map ( event => ( { kind : 'Världshändelse' , at : event . recorded _at , order : event . commit _sequence , data : event } ) ) ,
... trace . ai _interactions . map ( interaction => ( { kind : ` AI ${ interaction . prompt _id } · ${ interaction . outcome } ` , at : interaction . requested _at , order : interaction . correlation _id , data : interaction } ) )
] . sort ( ( a , b ) => String ( a . at ) . localeCompare ( String ( b . at ) ) ) ;
adminElement ( 'h2' , 'Kronologiskt spelarspår' ) ;
adminElement ( 'p' , 'Registreringstid visas separat från världstid och commitordning. AI-frågor, svar och världshändelser är korrelerade där korrelations-ID finns.' ) ;
for ( const [ index , entry ] of entries . entries ( ) ) {
const status = entry . data . execution _mode || entry . data . metadata ? . decision || entry . data . outcome || 'status okänd' ;
adminJson ( ` ${ index + 1 } . ${ entry . kind } · ${ status } · ${ entry . at } · ${ entry . order } ` , entry . data ) ;
}
}
async function loadAdminPrompts ( ) {
adminContent . replaceChildren ( ) ;
const result = await request ( '/api/admin/prompts' ) ;
adminElement ( 'h2' , 'Prompttillgångar' ) ;
for ( const prompt of result . prompts ) adminJson ( ` ${ prompt . id } · version ${ prompt . version } ` , prompt . yaml ) ;
}
async function loadAdminRules ( ) {
adminContent . replaceChildren ( ) ;
const result = await request ( '/api/admin/rules' ) ;
adminElement ( 'h2' , 'Luau-regler' ) ;
for ( const rule of result . rules ) adminJson ( ` ${ rule . rule _key } · v ${ rule . version } · ${ rule . active ? 'aktiv' : 'inaktiv' } · ${ rule . source _hash } ` , { source : rule . source , validation _results : rule . validation _results , created _at : rule . created _at } ) ;
}
async function loadAdminAudit ( ) {
adminContent . replaceChildren ( ) ;
const result = await request ( '/api/admin/audit?limit=200' ) ;
adminElement ( 'h2' , 'Revisionslogg' ) ;
for ( const entry of result . entries ) adminJson ( ` ${ entry . created _at } · ${ entry . action } · ${ entry . admin _account _id || entry . operator _identity } ` , entry ) ;
}
async function openAdmin ( ) {
showAdmin ( ) ;
adminContent . setAttribute ( 'aria-busy' , 'true' ) ;
try { await loadAdminPlayers ( ) ; }
catch ( error ) { adminContent . replaceChildren ( ) ; adminElement ( 'p' , ` Administrationsvyn kunde inte öppnas: ${ error . message } ` ) ; }
finally { adminContent . setAttribute ( 'aria-busy' , 'false' ) ; }
}
function appendInitialPrompt ( ) {
appendLine ( 'assistant' , 'Välkommen. Skriv fritt om personen du vill spela. Jag föreslår en vuxen biografi som du kan ändra innan du godkänner den.' ) ;
setStage ( 'biography' ) ;
@@ -278,7 +388,7 @@
try {
await request ( ` /api/ ${ intent } ` , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { username : data . get ( 'username' ) , password : data . get ( 'password' ) } ) } ) ;
form . reset ( ) ;
await openGame ( ) ;
if ( location . pathname === '/admin' ) await openAdmin ( ) ; else await openGame ( ) ;
} catch ( error ) {
window . alert ( ` Kunde inte logga in: ${ error . message } ` ) ;
}
@@ -300,6 +410,17 @@
if ( ! play . hidden && ! messageField . disabled ) messageField . focus ( ) ;
}
} ) ;
document . querySelectorAll ( '[data-admin-view]' ) . forEach ( button => button . addEventListener ( 'click' , async ( ) => {
adminContent . setAttribute ( 'aria-busy' , 'true' ) ;
try {
if ( button . dataset . adminView === 'players' ) await loadAdminPlayers ( ) ;
else if ( button . dataset . adminView === 'prompts' ) await loadAdminPrompts ( ) ;
else if ( button . dataset . adminView === 'rules' ) await loadAdminRules ( ) ;
else await loadAdminAudit ( ) ;
} catch ( error ) { adminContent . replaceChildren ( ) ; adminElement ( 'p' , ` Vyn kunde inte läsas: ${ error . message } ` ) ; }
finally { adminContent . setAttribute ( 'aria-busy' , 'false' ) ; }
} ) ) ;
document . querySelector ( '#admin-logout' ) . addEventListener ( 'click' , handleLogout ) ;
messageField . addEventListener ( 'keydown' , ( event ) => {
if ( event . key === 'Enter' && ! event . shiftKey && ! event . isComposing ) {
event . preventDefault ( ) ;
@@ -311,7 +432,7 @@
} ) . catch ( ( ) => {
document . querySelector ( '#api-version' ) . textContent = 'otillgänglig' ;
} ) ;
request ( '/api/me' ) . then ( openGame ) . catch ( showAuth ) ;
request ( '/api/me' ) . then ( ( ) => location . pathname === '/admin' ? openAdmin ( ) : openGame ( ) ) . catch ( showAuth ) ;
< / script >
< / body >
< / html >