diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a8e06db
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+# Server-side auth and user data (local only)
+server/users.json
+server/sessions.json
+server/userdata.json
diff --git a/app.js b/app.js
index 22017fd..bce1003 100644
--- a/app.js
+++ b/app.js
@@ -57,6 +57,376 @@
}
function write(key, val){localStorage.setItem(key,JSON.stringify(val))}
+ const FEDL_USER_ACCOUNTS = 'fedl_user_accounts';
+ const FEDL_USER_ACCOUNT_ACTIVE = 'fedl_user_account_active';
+
+ function fedlAccountId(){
+ try {
+ return localStorage.getItem(FEDL_USER_ACCOUNT_ACTIVE) || '';
+ } catch (e) {
+ return '';
+ }
+ }
+
+ function fedlSetActiveAccountId(id){
+ try {
+ if (id) {
+ localStorage.setItem(FEDL_USER_ACCOUNT_ACTIVE, id);
+ } else {
+ localStorage.removeItem(FEDL_USER_ACCOUNT_ACTIVE);
+ }
+ } catch (e) {}
+ }
+
+ function fedlListAccounts(){
+ return read(FEDL_USER_ACCOUNTS, []);
+ }
+
+ function fedlSaveAccountsList(accounts){
+ write(FEDL_USER_ACCOUNTS, accounts);
+ }
+
+ function fedlNewAccountId(){
+ return `u_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
+ }
+
+ function fedlEmptyRouletteSlots(){
+ return { '1': null, '2': null, '3': null };
+ }
+
+ function fedlDefaultUserData(){
+ return { roulettePick: null, levelPercents: {}, savedRuns: [], rouletteSlots: fedlEmptyRouletteSlots() };
+ }
+
+ function fedlGetAccountPayload(accountId){
+ const raw = read(`fedl_user_data_${accountId}`, fedlDefaultUserData());
+ if (!Array.isArray(raw.savedRuns)) {
+ raw.savedRuns = [];
+ }
+ if (!raw.levelPercents || typeof raw.levelPercents !== 'object') {
+ raw.levelPercents = {};
+ }
+ if (!raw.rouletteSlots || typeof raw.rouletteSlots !== 'object') {
+ raw.rouletteSlots = fedlEmptyRouletteSlots();
+ }
+ ['1', '2', '3'].forEach(k => {
+ if (!Object.prototype.hasOwnProperty.call(raw.rouletteSlots, k)) {
+ raw.rouletteSlots[k] = null;
+ }
+ });
+ return raw;
+ }
+
+ function fedlNextPercentHint(inputValue){
+ const raw = String(inputValue || '').trim().replace(',', '.');
+ if (!raw) {
+ return {
+ kind: 'muted',
+ text: 'Enter your current best %, then tap Submit % to save and see the next % to aim for (+1% roulette step).'
+ };
+ }
+ const n = parseFloat(raw);
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
+ return { kind: 'error', text: 'Enter a number from 0 to 100.' };
+ }
+ if (n >= 100) {
+ return {
+ kind: 'success',
+ text: 'You are at 100%. Beat the level, then spin — your next demon usually adds +1% to your roulette target.'
+ };
+ }
+ const next = Math.min(100, Math.floor(n) + 1);
+ if (next >= 100) {
+ return { kind: 'success', text: 'Saved. Next goal on this level: 100% (full completion).' };
+ }
+ return {
+ kind: 'success',
+ text: `Saved. Next % to hit on this level: ${next}% (classic +1% roulette step).`
+ };
+ }
+
+ async function fedlReadJsonResponse(r){
+ const text = await r.text();
+ let data = {};
+ try {
+ data = text ? JSON.parse(text) : {};
+ } catch (e) {
+ data = {};
+ }
+ const msg = (data && data.error && String(data.error)) || (data && data.message && String(data.message)) || '';
+ const plain = String(text || '').trim();
+ return { data, message: msg || plain || r.statusText || `Error ${r.status}` };
+ }
+
+ function fedlAddSavedRun(accountId, fields){
+ if (!accountId || accountId !== fedlServerUserId) {
+ return { ok: false, error: 'Sign in to save runs to your account.' };
+ }
+ const playerName = String(fields.playerName || '').trim();
+ const levelTitle = String(fields.levelTitle || '').trim();
+ if (!playerName || !levelTitle) {
+ return { ok: false, error: 'Player name and level are required to save a run.' };
+ }
+ const p = fedlGetAccountPayload(accountId);
+ const list = p.savedRuns.slice();
+ const id = `sv_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
+ list.unshift({
+ id,
+ playerName,
+ levelTitle,
+ videoUrl: String(fields.videoUrl || '').trim(),
+ percent: String(fields.percent != null ? fields.percent : '100').trim() || '100',
+ rawFootageUrl: String(fields.rawFootageUrl || '').trim(),
+ notes: String(fields.notes || '').trim(),
+ savedAt: new Date().toISOString()
+ });
+ p.savedRuns = list.slice(0, 48);
+ fedlSaveAccountPayload(accountId, p);
+ return { ok: true };
+ }
+
+ function fedlRemoveSavedRun(accountId, runId){
+ if (!accountId || accountId !== fedlServerUserId || !runId) {
+ return;
+ }
+ const p = fedlGetAccountPayload(accountId);
+ p.savedRuns = (p.savedRuns || []).filter(r => r && r.id !== runId);
+ fedlSaveAccountPayload(accountId, p);
+ }
+
+ function fedlSaveAccountPayload(accountId, payload){
+ write(`fedl_user_data_${accountId}`, payload);
+ fedlSchedulePushUserState(accountId);
+ }
+
+ let fedlServerUserId = null;
+ let fedlServerUsername = null;
+ const FEDL_AUTH_TOKEN_KEY = 'fedl_auth_token';
+
+ function fedlGetAuthToken(){
+ try {
+ return localStorage.getItem(FEDL_AUTH_TOKEN_KEY) || '';
+ } catch (e) {
+ return '';
+ }
+ }
+
+ function fedlSetAuthToken(token){
+ try {
+ if (token) {
+ localStorage.setItem(FEDL_AUTH_TOKEN_KEY, token);
+ } else {
+ localStorage.removeItem(FEDL_AUTH_TOKEN_KEY);
+ }
+ } catch (e) {}
+ }
+
+ function fedlClearServerSession(){
+ fedlServerUserId = null;
+ fedlServerUsername = null;
+ fedlSetAuthToken('');
+ }
+
+ function fedlDataUserId(){
+ if (fedlServerUserId) {
+ return fedlServerUserId;
+ }
+ return fedlAccountId();
+ }
+
+ let fedlPushStateTimer = null;
+ function fedlSchedulePushUserState(accountId){
+ if (!accountId || !fedlGetAuthToken() || accountId !== fedlServerUserId || !canUseLiveServer) {
+ return;
+ }
+ if (fedlPushStateTimer) {
+ clearTimeout(fedlPushStateTimer);
+ }
+ fedlPushStateTimer = setTimeout(()=>{
+ const payload = fedlGetAccountPayload(accountId);
+ fetch(`${liveServerBase}/api/user/state`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${fedlGetAuthToken()}`
+ },
+ body: JSON.stringify({ data: payload })
+ }).catch(()=>{});
+ }, 450);
+ }
+
+ function fedlRefreshAuthState(){
+ const t = fedlGetAuthToken();
+ if (!t || !canUseLiveServer) {
+ fedlServerUserId = null;
+ fedlServerUsername = null;
+ return Promise.resolve(null);
+ }
+ return fetch(`${liveServerBase}/api/auth/me`, {
+ headers: { Authorization: `Bearer ${t}` },
+ cache: 'no-store'
+ }).then(r=>{
+ if (!r.ok) {
+ throw new Error('auth');
+ }
+ return r.json();
+ }).then(j=>{
+ fedlServerUserId = j.userId;
+ fedlServerUsername = j.username;
+ return j;
+ }).catch(()=>{
+ fedlClearServerSession();
+ return null;
+ });
+ }
+
+ function fedlPullUserStateToLocal(userId){
+ const t = fedlGetAuthToken();
+ if (!t || !userId || !canUseLiveServer) {
+ return Promise.resolve();
+ }
+ return fetch(`${liveServerBase}/api/user/state`, {
+ headers: { Authorization: `Bearer ${t}` },
+ cache: 'no-store'
+ }).then(r=>{
+ if (!r.ok) {
+ return null;
+ }
+ return r.json();
+ }).then(j=>{
+ if (j && j.data) {
+ write(`fedl_user_data_${userId}`, j.data);
+ }
+ }).catch(()=>{});
+ }
+
+ function injectFedlAuthNav(){
+ const nav = document.querySelector('header nav');
+ if (!nav || nav.querySelector('.fedl-auth-nav')) {
+ return;
+ }
+ const wrap = document.createElement('span');
+ wrap.className = 'fedl-auth-nav';
+ nav.appendChild(wrap);
+ }
+
+ function fedlUpdateAuthNav(){
+ const wrap = document.querySelector('.fedl-auth-nav');
+ if (!wrap) {
+ return;
+ }
+ wrap.textContent = '';
+ if (fedlServerUsername) {
+ const label = document.createElement('span');
+ label.className = 'fedl-auth-label muted';
+ label.appendChild(document.createTextNode('Hi, '));
+ const strong = document.createElement('strong');
+ strong.textContent = fedlServerUsername;
+ label.appendChild(strong);
+ wrap.appendChild(label);
+ wrap.appendChild(document.createTextNode(' '));
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'btn ghost-btn small-btn fedl-logout-btn';
+ btn.textContent = 'Log out';
+ btn.addEventListener('click', ()=>{
+ const tok = fedlGetAuthToken();
+ if (tok && canUseLiveServer) {
+ fetch(`${liveServerBase}/api/auth/logout`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${tok}` }
+ }).catch(()=>{});
+ }
+ fedlClearServerSession();
+ fedlUpdateAuthNav();
+ document.dispatchEvent(new CustomEvent('fedl-auth-updated'));
+ window.location.reload();
+ });
+ wrap.appendChild(btn);
+ } else {
+ const a1 = document.createElement('a');
+ a1.href = 'login.html';
+ a1.textContent = 'Log in';
+ wrap.appendChild(a1);
+ wrap.appendChild(document.createTextNode(' '));
+ const a2 = document.createElement('a');
+ a2.href = 'signup.html';
+ a2.textContent = 'Sign up';
+ wrap.appendChild(a2);
+ }
+ }
+
+ function fedlNormalizeLevelKey(title){
+ return String(title || '').trim().toLowerCase();
+ }
+
+ function fedlGetLevelPercent(accountId, title){
+ if (!accountId) {
+ return '';
+ }
+ const p = fedlGetAccountPayload(accountId);
+ const k = fedlNormalizeLevelKey(title);
+ return (p.levelPercents && p.levelPercents[k]) ? String(p.levelPercents[k]) : '';
+ }
+
+ function fedlSetLevelPercent(accountId, title, percent){
+ if (!accountId) {
+ return;
+ }
+ const p = fedlGetAccountPayload(accountId);
+ if (!p.levelPercents) {
+ p.levelPercents = {};
+ }
+ const k = fedlNormalizeLevelKey(title);
+ const v = String(percent || '').trim();
+ if (v) {
+ p.levelPercents[k] = v;
+ } else {
+ delete p.levelPercents[k];
+ }
+ if (p.roulettePick && fedlNormalizeLevelKey(p.roulettePick.title) === k) {
+ p.roulettePick.percent = v;
+ }
+ fedlSaveAccountPayload(accountId, p);
+ }
+
+ function fedlSaveRoulettePick(accountId, pick){
+ if (!accountId || !pick) {
+ return;
+ }
+ const p = fedlGetAccountPayload(accountId);
+ p.roulettePick = {
+ title: pick.title,
+ position: pick.position,
+ level: pick.level,
+ url: pick.url,
+ levelId: pick.levelId,
+ noteSource: pick.noteSource,
+ percent: String(pick.percent || '').trim()
+ };
+ if (p.roulettePick.title && p.roulettePick.percent) {
+ if (!p.levelPercents) {
+ p.levelPercents = {};
+ }
+ p.levelPercents[fedlNormalizeLevelKey(p.roulettePick.title)] = p.roulettePick.percent;
+ }
+ fedlSaveAccountPayload(accountId, p);
+ }
+
+ function fedlCreateAccount(displayName){
+ const name = String(displayName || '').trim();
+ if (!name) {
+ return null;
+ }
+ const accounts = fedlListAccounts();
+ const id = fedlNewAccountId();
+ accounts.push({ id, name, createdAt: new Date().toISOString() });
+ fedlSaveAccountsList(accounts);
+ fedlSetActiveAccountId(id);
+ fedlSaveAccountPayload(id, fedlDefaultUserData());
+ return { id, name };
+ }
+
function parseData(txt){
return txt.split(/\r?\n/).map(l=>l.trim()).filter(Boolean).map(l=>{
const parts = l.split('|').map(p=>p.trim());
@@ -252,6 +622,53 @@
return m ? m[1] : '';
}
+ function updateAccountProgressInModal(modal, item){
+ if (!modal) {
+ return;
+ }
+ const inner = modal.querySelector('.inner');
+ if (!inner) {
+ return;
+ }
+ let accBar = modal.querySelector('.modal-account-progress');
+ if (!accBar) {
+ accBar = document.createElement('div');
+ accBar.className = 'modal-account-progress';
+ const runsWrap = inner.querySelector('.modal-runs-wrap');
+ if (runsWrap) {
+ inner.insertBefore(accBar, runsWrap);
+ } else {
+ inner.appendChild(accBar);
+ }
+ }
+ const accId = fedlDataUserId();
+ if (!accId || !item || !item.title) {
+ accBar.hidden = true;
+ accBar.innerHTML = '';
+ return;
+ }
+ accBar.hidden = false;
+ const cur = fedlGetLevelPercent(accId, item.title);
+ const labelText = fedlServerUserId
+ ? 'Your progress (synced to your account)'
+ : 'Your progress (saved on this device)';
+ accBar.innerHTML =
+ '
' +
+ labelText +
+ '
' +
+ '' +
+ '' +
+ '%' +
+ '
';
+ const input = accBar.querySelector('.modal-account-pct-input');
+ if (input) {
+ input.value = cur;
+ input.addEventListener('change', ()=>{
+ fedlSetLevelPercent(accId, item.title, input.value);
+ });
+ }
+ }
+
function openVideoModal(item, options){
const config = Object.assign({showRuns:false}, options || {});
const url = item && item.url;
@@ -286,6 +703,7 @@
if(config.showRuns && runsList){
renderApprovedRunsForLevel(item, runsList);
}
+ updateAccountProgressInModal(modal, item);
modal.style.display = 'flex';
}
@@ -354,8 +772,145 @@
const idEl = qs('roulette-level-id');
const noteEl = qs('roulette-note');
const openEl = qs('roulette-open');
+ const pctInput = qs('roulette-percent');
+ const pctRow = qs('roulette-progress-row');
+ const accountSelect = qs('roulette-account-select');
+ const accountNewInput = qs('roulette-account-new');
+ const accountCreateBtn = qs('roulette-account-create');
+ const restoreBtn = qs('roulette-restore');
+ const pctHint = qs('roulette-percent-hint');
+ const loginSyncHint = qs('roulette-login-sync-hint');
+ const slotsHintEl = qs('roulette-slots-hint');
+ const pctSubmitBtn = qs('roulette-percent-submit');
+
+ let lastRoulette = { item: null, meta: null };
+
+ function setPercentHint(text, kind){
+ if(!pctHint) return;
+ pctHint.textContent = text || '';
+ pctHint.className =
+ 'small roulette-percent-hint ' +
+ (kind === 'error' ? 'error-text' : kind === 'success' ? 'success-text' : 'muted');
+ }
+
+ function resetPercentHint(){
+ const h = fedlNextPercentHint('');
+ setPercentHint(h.text, h.kind);
+ }
+
+ function refreshRouletteSlotsUi(){
+ const aid = fedlDataUserId();
+ ['1', '2', '3'].forEach(k=>{
+ const saveB = qs(`roulette-slot-save-${k}`);
+ const loadB = qs(`roulette-slot-load-${k}`);
+ const lab = qs(`roulette-slot-label-${k}`);
+ if(saveB) saveB.disabled = !aid;
+ if(loadB) loadB.disabled = !aid;
+ if(lab){
+ if(!aid){
+ lab.textContent = '—';
+ }else{
+ const slot = fedlGetAccountPayload(aid).rouletteSlots[k];
+ if(slot && slot.title){
+ const pct = slot.percent ? ` @ ${slot.percent}%` : '';
+ const t = String(slot.title);
+ const short = t.length > 36 ? `${t.slice(0, 34)}…` : t;
+ lab.textContent = short + pct;
+ }else{
+ lab.textContent = 'Empty';
+ }
+ }
+ }
+ });
+ if(slotsHintEl){
+ if(!aid){
+ slotsHintEl.textContent = 'Create a profile below or log in to use save slots.';
+ }else{
+ slotsHintEl.textContent = 'Save the demon on screen into a slot, or load a slot to swap demons.';
+ }
+ }
+ }
+
+ function syncPercentRow(){
+ if(!pctRow) return;
+ const aid = fedlDataUserId();
+ if(!aid || !lastRoulette.item){
+ pctRow.hidden = true;
+ if(pctInput) pctInput.value = '';
+ if(pctHint) pctHint.textContent = '';
+ return;
+ }
+ pctRow.hidden = false;
+ if(pctInput){
+ pctInput.value = fedlGetLevelPercent(aid, lastRoulette.item.title) || '';
+ }
+ resetPercentHint();
+ }
+
+ function refreshRouletteAccountUi(){
+ const panel = document.querySelector('.roulette-account-panel');
+ const serverMode = !!fedlServerUsername;
+ if(loginSyncHint){
+ loginSyncHint.hidden = !!fedlServerUserId;
+ }
+ if(panel){
+ const controls = panel.querySelector('.roulette-account-controls');
+ const createRow = panel.querySelector('.roulette-account-create-row');
+ const selLabel = panel.querySelector('.roulette-account-label');
+ let note = panel.querySelector('.fedl-server-account-note');
+ if(serverMode){
+ if(controls) controls.style.display = 'none';
+ if(createRow) createRow.style.display = 'none';
+ if(selLabel) selLabel.style.display = 'none';
+ if(!note){
+ note = document.createElement('p');
+ note.className = 'muted fedl-server-account-note';
+ const heading = panel.querySelector('.roulette-account-heading');
+ if(heading){
+ heading.insertAdjacentElement('afterend', note);
+ }else{
+ panel.appendChild(note);
+ }
+ }
+ note.textContent = `Signed in as ${fedlServerUsername}. Progress syncs to this server (this browser keeps a copy).`;
+ note.style.display = '';
+ }else{
+ if(controls) controls.style.display = '';
+ if(createRow) createRow.style.display = '';
+ if(selLabel) selLabel.style.display = '';
+ if(note) note.style.display = 'none';
+ }
+ }
+ if(!accountSelect || serverMode){
+ if(restoreBtn){
+ const id = fedlDataUserId();
+ const pick = id ? fedlGetAccountPayload(id).roulettePick : null;
+ restoreBtn.hidden = !pick || !pick.title;
+ }
+ refreshRouletteSlotsUi();
+ syncPercentRow();
+ return;
+ }
+ const accounts = fedlListAccounts();
+ const active = fedlAccountId();
+ accountSelect.innerHTML = '';
+ accounts.forEach(a=>{
+ const opt = document.createElement('option');
+ opt.value = a.id;
+ opt.textContent = a.name;
+ if(a.id === active) opt.selected = true;
+ accountSelect.appendChild(opt);
+ });
+ if(restoreBtn){
+ const pick = active ? fedlGetAccountPayload(active).roulettePick : null;
+ restoreBtn.hidden = !pick || !pick.title;
+ }
+ refreshRouletteSlotsUi();
+ syncPercentRow();
+ }
function showPick(item, meta){
+ lastRoulette = { item, meta };
statusEl.textContent = 'Your demon is:';
titleEl.textContent = item.title;
rankEl.textContent = `Rank: #${item.position}`;
@@ -374,8 +929,148 @@
openEl.hidden = true;
openEl.onclick = null;
}
+ const aid = fedlDataUserId();
+ if(aid){
+ const pct = fedlGetLevelPercent(aid, item.title) || '';
+ fedlSaveRoulettePick(aid, {
+ title: item.title,
+ position: item.position,
+ level: item.level,
+ url: item.url,
+ levelId: meta.levelId,
+ noteSource: meta.source,
+ percent: pct
+ });
+ refreshRouletteAccountUi();
+ }
+ syncPercentRow();
}
+ function saveRouletteSlot(slotKey){
+ const aid = fedlDataUserId();
+ if(!aid || !lastRoulette.item){
+ if(slotsHintEl) slotsHintEl.textContent = 'Spin a demon first, and use a profile or log in.';
+ return;
+ }
+ const p = fedlGetAccountPayload(aid);
+ const pct = pctInput ? String(pctInput.value || '').trim() : '';
+ p.rouletteSlots[slotKey] = {
+ title: lastRoulette.item.title,
+ position: lastRoulette.item.position,
+ level: lastRoulette.item.level,
+ url: lastRoulette.item.url,
+ levelId: lastRoulette.meta && lastRoulette.meta.levelId,
+ noteSource: lastRoulette.meta && lastRoulette.meta.source,
+ percent: pct,
+ savedAt: new Date().toISOString()
+ };
+ fedlSaveAccountPayload(aid, p);
+ if(pct){
+ fedlSetLevelPercent(aid, lastRoulette.item.title, pct);
+ }
+ refreshRouletteSlotsUi();
+ }
+
+ function loadRouletteSlot(slotKey){
+ const aid = fedlDataUserId();
+ if(!aid) return;
+ const slot = fedlGetAccountPayload(aid).rouletteSlots[slotKey];
+ if(!slot || !slot.title){
+ if(slotsHintEl) slotsHintEl.textContent = 'That slot is empty.';
+ return;
+ }
+ const pctStr = String(slot.percent != null ? slot.percent : '').trim();
+ if(pctStr){
+ fedlSetLevelPercent(aid, slot.title, pctStr);
+ }
+ const item = {
+ title: slot.title,
+ position: slot.position,
+ level: slot.level,
+ url: slot.url
+ };
+ const meta = {
+ levelId: slot.levelId,
+ source: slot.noteSource === 'api' ? 'api' : 'file'
+ };
+ showPick(item, meta);
+ if(pctInput){
+ pctInput.value = pctStr || fedlGetLevelPercent(aid, slot.title) || '';
+ }
+ resetPercentHint();
+ refreshRouletteSlotsUi();
+ }
+
+ if(pctInput){
+ pctInput.addEventListener('change', ()=>{
+ const aid = fedlDataUserId();
+ if(!aid || !lastRoulette.item) return;
+ fedlSetLevelPercent(aid, lastRoulette.item.title, pctInput.value);
+ });
+ }
+ if(pctSubmitBtn){
+ pctSubmitBtn.addEventListener('click', ()=>{
+ const aid = fedlDataUserId();
+ if(!aid || !lastRoulette.item){
+ setPercentHint('Spin a demon and use a profile or log in to track %.', 'error');
+ return;
+ }
+ fedlSetLevelPercent(aid, lastRoulette.item.title, pctInput ? pctInput.value : '');
+ const h = fedlNextPercentHint(pctInput ? pctInput.value : '');
+ setPercentHint(h.text, h.kind);
+ });
+ }
+ ['1', '2', '3'].forEach(k=>{
+ const sb = qs(`roulette-slot-save-${k}`);
+ const lb = qs(`roulette-slot-load-${k}`);
+ if(sb) sb.addEventListener('click', ()=> saveRouletteSlot(k));
+ if(lb) lb.addEventListener('click', ()=> loadRouletteSlot(k));
+ });
+ if(accountSelect){
+ accountSelect.addEventListener('change', ()=>{
+ fedlSetActiveAccountId(accountSelect.value || '');
+ refreshRouletteAccountUi();
+ });
+ }
+ if(accountCreateBtn && accountNewInput){
+ accountCreateBtn.addEventListener('click', ()=>{
+ const name = String(accountNewInput.value || '').trim();
+ if(!name) return;
+ fedlCreateAccount(name);
+ accountNewInput.value = '';
+ refreshRouletteAccountUi();
+ });
+ }
+ if(restoreBtn){
+ restoreBtn.addEventListener('click', ()=>{
+ const aid = fedlDataUserId();
+ if(!aid) return;
+ const pick = fedlGetAccountPayload(aid).roulettePick;
+ if(!pick || !pick.title) return;
+ const item = {
+ title: pick.title,
+ position: pick.position,
+ level: pick.level,
+ url: pick.url
+ };
+ const meta = {
+ levelId: pick.levelId,
+ source: pick.noteSource === 'api' ? 'api' : 'file'
+ };
+ showPick(item, meta);
+ });
+ }
+
+ document.addEventListener('fedl-auth-updated', ()=>{
+ refreshRouletteAccountUi();
+ });
+ fedlRefreshAuthState()
+ .then(()=> fedlPullUserStateToLocal(fedlServerUserId))
+ .finally(()=>{
+ refreshRouletteAccountUi();
+ fedlUpdateAuthNav();
+ });
+
spinBtn.addEventListener('click', ()=>{
statusEl.textContent = 'Spinning...';
titleEl.textContent = 'Choosing a demon';
@@ -383,6 +1078,8 @@
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'Checking your local file and API if needed.';
openEl.hidden = true;
+ if(pctRow) pctRow.hidden = true;
+ if(pctHint) pctHint.textContent = '';
Promise.all([loadItems(), loadLevelMeta()]).then(([items, metaMap])=>{
if(!items.length){
statusEl.textContent = 'No demons found.';
@@ -714,9 +1411,14 @@
let controlsBound = false;
// Load hard-coded data file data.txt (category|position|title|url per line)
function loadData(){
- loadItems().then(items=>{
- applyItems(items);
- }).catch(err=>{listArea.innerHTML='Failed to load list data - run via the Node server.
'; console.error(err)});
+ const run = ()=>{
+ loadItems().then(items=>{
+ applyItems(items);
+ }).catch(err=>{listArea.innerHTML='Failed to load list data - run via the Node server.
'; console.error(err)});
+ };
+ fedlRefreshAuthState()
+ .then(()=> fedlPullUserStateToLocal(fedlServerUserId))
+ .finally(run);
}
function computeCategories(items){
@@ -779,15 +1481,36 @@
}).sort((a,b)=> (Number(a.position)||0)-(Number(b.position)||0));
const tbody = qs('list-area'); tbody.innerHTML='';
+ const accId = fedlDataUserId();
filtered.forEach(it=>{
const tr = document.createElement('tr');
const tdNum = document.createElement('td'); tdNum.textContent = it.position;
const tdTitle = document.createElement('td'); tdTitle.textContent = it.title;
+ const tdPct = document.createElement('td');
+ tdPct.className = 'list-my-progress-cell';
+ if(accId){
+ const inp = document.createElement('input');
+ inp.type = 'text';
+ inp.className = 'list-progress-input';
+ inp.value = fedlGetLevelPercent(accId, it.title) || '';
+ inp.placeholder = '%';
+ inp.title = 'Your progress for this level (this browser)';
+ inp.addEventListener('change', ()=>{
+ fedlSetLevelPercent(accId, it.title, inp.value);
+ });
+ tdPct.appendChild(inp);
+ }else{
+ const span = document.createElement('span');
+ span.className = 'muted';
+ span.textContent = '—';
+ span.title = 'Create a profile on the Roulette page to save progress';
+ tdPct.appendChild(span);
+ }
const tdAct = document.createElement('td');
const a = document.createElement('a'); a.textContent='Open'; a.href='#'; a.className='btn';
a.addEventListener('click', (e)=>{e.preventDefault(); openVideoModal(it, {showRuns:true})});
tdAct.appendChild(a);
- tr.appendChild(tdNum); tr.appendChild(tdTitle); tr.appendChild(tdAct);
+ tr.appendChild(tdNum); tr.appendChild(tdTitle); tr.appendChild(tdPct); tr.appendChild(tdAct);
tbody.appendChild(tr);
});
}
@@ -812,6 +1535,9 @@
// Lists page
if(page==='lists'){
const listPage = initListPage();
+ document.addEventListener('fedl-auth-updated', ()=>{
+ loadItems().then(items=>listPage.applyItems(items)).catch(()=>{});
+ });
bindLiveUpdates();
onLiveUpdate(function(updatedItems){
listPage.applyItems(updatedItems);
@@ -1566,11 +2292,15 @@
const listStatusEl = qs('run-list-status');
const submissionsEl = qs('run-submissions');
const levelOptionsEl = qs('run-level-options');
+ const myRunsSection = qs('run-my-runs-section');
+ const savedRunsListEl = qs('run-saved-runs-list');
+ const saveAccountBtn = qs('run-save-account');
- function setRunFormStatus(message, isError){
+ function setRunFormStatus(message, isError, isSuccess){
if(!formStatusEl) return;
formStatusEl.textContent = message;
formStatusEl.classList.toggle('error-text', !!isError);
+ formStatusEl.classList.toggle('success-text', !!isSuccess);
}
function setRunListStatus(message, isError){
@@ -1588,12 +2318,15 @@
runs.slice(0, 8).forEach(run=>{
const card = document.createElement('article');
card.className = 'submission-card';
+ const acct = run.accountUsername
+ ? ` • Account: ${escapeHtml(run.accountUsername)}`
+ : '';
card.innerHTML = `
${escapeHtml(run.levelTitle || 'Untitled')}
${escapeHtml(run.status || 'pending')}
- By ${escapeHtml(run.playerName || 'Unknown')} • ${escapeHtml(run.percent || '100')}% • ${escapeHtml(new Date(run.submittedAt).toLocaleString())}
+ By ${escapeHtml(run.playerName || 'Unknown')} • ${escapeHtml(run.percent || '100')}% • ${escapeHtml(new Date(run.submittedAt).toLocaleString())}${acct}
${escapeHtml(run.reviewNotes || run.notes || 'No notes yet.')}
Watch run
@@ -1603,6 +2336,81 @@
});
}
+ function renderMySavedRuns(){
+ if(!myRunsSection || !savedRunsListEl) return;
+ if(!fedlServerUserId){
+ myRunsSection.hidden = true;
+ if(saveAccountBtn) saveAccountBtn.hidden = true;
+ return;
+ }
+ myRunsSection.hidden = false;
+ if(saveAccountBtn) saveAccountBtn.hidden = false;
+ const p = fedlGetAccountPayload(fedlServerUserId);
+ const runs = p.savedRuns || [];
+ savedRunsListEl.textContent = '';
+ if(!runs.length){
+ const empty = document.createElement('p');
+ empty.className = 'muted';
+ empty.textContent = 'No runs saved yet. Fill the form and use “Save to my account” to keep drafts, or submit to the live queue.';
+ savedRunsListEl.appendChild(empty);
+ return;
+ }
+ runs.forEach(entry=>{
+ const card = document.createElement('article');
+ card.className = 'submission-card run-saved-card';
+ const top = document.createElement('div');
+ top.className = 'submission-card-top';
+ const strong = document.createElement('strong');
+ strong.textContent = entry.levelTitle || 'Untitled';
+ top.appendChild(strong);
+ const pill = document.createElement('span');
+ pill.className = 'status-pill status-pending';
+ pill.textContent = 'Saved';
+ top.appendChild(pill);
+ card.appendChild(top);
+ const meta = document.createElement('p');
+ meta.className = 'submission-meta';
+ meta.textContent = `${entry.playerName || '—'} • ${entry.percent || '100'}% • ${entry.savedAt ? new Date(entry.savedAt).toLocaleString() : ''}`;
+ card.appendChild(meta);
+ if(entry.videoUrl){
+ const link = document.createElement('a');
+ link.className = 'text-link';
+ link.href = entry.videoUrl;
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.textContent = 'Video link';
+ card.appendChild(link);
+ }
+ const actions = document.createElement('div');
+ actions.className = 'run-saved-card-actions';
+ const fillBtn = document.createElement('button');
+ fillBtn.type = 'button';
+ fillBtn.className = 'btn ghost-btn small-btn';
+ fillBtn.textContent = 'Load into form';
+ fillBtn.addEventListener('click', ()=>{
+ qs('run-player-name').value = entry.playerName || '';
+ qs('run-level-title').value = entry.levelTitle || '';
+ qs('run-video-url').value = entry.videoUrl || '';
+ qs('run-percent').value = entry.percent || '100';
+ qs('run-raw-footage-url').value = entry.rawFootageUrl || '';
+ qs('run-notes').value = entry.notes || '';
+ setRunFormStatus('Loaded this run into the form. Submit or edit, then save or send to the queue.', false, false);
+ });
+ const delBtn = document.createElement('button');
+ delBtn.type = 'button';
+ delBtn.className = 'btn ghost-btn small-btn';
+ delBtn.textContent = 'Remove';
+ delBtn.addEventListener('click', ()=>{
+ fedlRemoveSavedRun(fedlServerUserId, entry.id);
+ renderMySavedRuns();
+ });
+ actions.appendChild(fillBtn);
+ actions.appendChild(delBtn);
+ card.appendChild(actions);
+ savedRunsListEl.appendChild(card);
+ });
+ }
+
function loadRunPage(){
loadItems().then(items=>{
const titles = items.map(item=>item.title).filter(Boolean);
@@ -1620,6 +2428,30 @@
});
}
+ if(saveAccountBtn){
+ saveAccountBtn.addEventListener('click', ()=>{
+ const fields = {
+ playerName: qs('run-player-name').value.trim(),
+ levelTitle: qs('run-level-title').value.trim(),
+ videoUrl: qs('run-video-url').value.trim(),
+ percent: qs('run-percent').value.trim(),
+ rawFootageUrl: qs('run-raw-footage-url').value.trim(),
+ notes: qs('run-notes').value.trim()
+ };
+ const res = fedlAddSavedRun(fedlServerUserId, fields);
+ if(!res.ok){
+ setRunFormStatus(res.error, true);
+ return;
+ }
+ setRunFormStatus('Run saved to your account. You can keep multiple saved runs and load them anytime.', false, true);
+ renderMySavedRuns();
+ });
+ }
+
+ document.addEventListener('fedl-auth-updated', ()=>{
+ renderMySavedRuns();
+ });
+
form.addEventListener('submit', function(event){
event.preventDefault();
if(!canUseLiveServer){
@@ -1635,19 +2467,30 @@
notes: qs('run-notes').value.trim()
};
setRunFormStatus('Sending your run to the live queue...');
+ const headers = { 'Content-Type': 'application/json' };
+ const tok = fedlGetAuthToken();
+ if(tok){
+ headers.Authorization = `Bearer ${tok}`;
+ }
fetch(liveRunsUrl, {
method:'POST',
- headers:{'Content-Type':'application/json'},
+ headers,
body: JSON.stringify(payload)
- }).then(r=>{
- if(!r.ok) throw new Error('Submission failed');
+ }).then(async r=>{
+ if(!r.ok){
+ const { message } = await fedlReadJsonResponse(r);
+ throw new Error(message);
+ }
clearRunsCache();
form.reset();
- setRunFormStatus('Run submitted. The admin panel can review it now.');
+ const okMsg = fedlServerUsername
+ ? `Run submitted successfully. It is linked to your account (${fedlServerUsername}) for moderators.`
+ : 'Run submitted successfully. The admin panel can review it now.';
+ setRunFormStatus(okMsg, false, true);
return refreshRuns();
}).catch(err=>{
console.error(err);
- setRunFormStatus('Could not submit the run. Check the server and try again.', true);
+ setRunFormStatus(err.message || 'Could not submit the run. Check the server and try again.', true);
});
});
@@ -1658,9 +2501,137 @@
setRunListStatus('Recent submissions reloaded from the live server.');
});
+ fedlRefreshAuthState()
+ .then(()=> fedlPullUserStateToLocal(fedlServerUserId))
+ .finally(()=>{
+ renderMySavedRuns();
+ fedlUpdateAuthNav();
+ });
+
loadRunPage();
}
+ const FEDL_USERNAME_RE = /^[a-z0-9_]{3,24}$/;
+ const FEDL_AUTH_REDIRECT_MS = 1400;
+ if (page === 'signup') {
+ const form = qs('signup-form');
+ const statusEl = qs('signup-status');
+ const submitBtn = qs('signup-submit');
+ function setSignupStatus(msg, kind){
+ statusEl.textContent = msg || '';
+ statusEl.className =
+ kind === 'error' ? 'muted error-text' : kind === 'success' ? 'muted success-text' : 'muted';
+ }
+ form.addEventListener('submit', function(ev){
+ ev.preventDefault();
+ if (!canUseLiveServer) {
+ setSignupStatus('Open this site through the FEDL server (not as a local file) to sign up.', 'error');
+ return;
+ }
+ const username = String(qs('signup-username').value || '').trim().toLowerCase();
+ const password = qs('signup-password').value || '';
+ const password2 = qs('signup-password2').value || '';
+ if (!FEDL_USERNAME_RE.test(username)) {
+ setSignupStatus('Use 3–24 characters: lowercase letters, numbers, or underscore only.', 'error');
+ return;
+ }
+ if (password.length < 8) {
+ setSignupStatus('Password must be at least 8 characters.', 'error');
+ return;
+ }
+ if (password !== password2) {
+ setSignupStatus('Passwords do not match.', 'error');
+ return;
+ }
+ submitBtn.disabled = true;
+ setSignupStatus('Creating account…');
+ fetch(liveApiPath('/api/auth/signup'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ }).then(async r=>{
+ const { data, message } = await fedlReadJsonResponse(r);
+ if (!r.ok) {
+ throw new Error(message || 'Sign up failed');
+ }
+ fedlSetAuthToken(data.token);
+ fedlServerUserId = data.userId;
+ fedlServerUsername = data.username;
+ document.dispatchEvent(new CustomEvent('fedl-auth-updated'));
+ setSignupStatus('Account created successfully. Loading your data…', 'success');
+ return fedlPullUserStateToLocal(data.userId);
+ }).then(()=>{
+ setSignupStatus('You are signed in. Redirecting to the roulette page…', 'success');
+ setTimeout(()=>{
+ window.location.href = 'roulette.html';
+ }, FEDL_AUTH_REDIRECT_MS);
+ }).catch(err=>{
+ console.error(err);
+ setSignupStatus(err.message || 'Could not sign up.', 'error');
+ submitBtn.disabled = false;
+ });
+ });
+ }
+
+ if (page === 'login') {
+ const form = qs('login-form');
+ const statusEl = qs('login-status');
+ const submitBtn = qs('login-submit');
+ function setLoginStatus(msg, kind){
+ statusEl.textContent = msg || '';
+ statusEl.className =
+ kind === 'error' ? 'muted error-text' : kind === 'success' ? 'muted success-text' : 'muted';
+ }
+ form.addEventListener('submit', function(ev){
+ ev.preventDefault();
+ if (!canUseLiveServer) {
+ setLoginStatus('Open this site through the FEDL server to log in.', 'error');
+ return;
+ }
+ const username = String(qs('login-username').value || '').trim().toLowerCase();
+ const password = qs('login-password').value || '';
+ if (!username) {
+ setLoginStatus('Enter your username.', 'error');
+ return;
+ }
+ submitBtn.disabled = true;
+ setLoginStatus('Signing in…');
+ fetch(liveApiPath('/api/auth/login'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ }).then(async r=>{
+ const { data, message } = await fedlReadJsonResponse(r);
+ if (!r.ok) {
+ throw new Error(message || 'Log in failed');
+ }
+ fedlSetAuthToken(data.token);
+ fedlServerUserId = data.userId;
+ fedlServerUsername = data.username;
+ document.dispatchEvent(new CustomEvent('fedl-auth-updated'));
+ setLoginStatus('Signed in successfully. Loading your data…', 'success');
+ return fedlPullUserStateToLocal(data.userId);
+ }).then(()=>{
+ setLoginStatus('Welcome back. Redirecting to the roulette page…', 'success');
+ setTimeout(()=>{
+ window.location.href = 'roulette.html';
+ }, FEDL_AUTH_REDIRECT_MS);
+ }).catch(err=>{
+ console.error(err);
+ setLoginStatus(err.message || 'Could not log in.', 'error');
+ submitBtn.disabled = false;
+ });
+ });
+ }
+
+ injectFedlAuthNav();
+ fedlRefreshAuthState().finally(()=>{
+ fedlUpdateAuthNav();
+ if ((page === 'signup' || page === 'login') && fedlServerUsername) {
+ window.location.replace('roulette.html');
+ }
+ });
+
// Utility
function escapeHtml(s){return String(s).replace(/[&<>"']/g, c=>({"&":"&","<":"<",">":">","\"":""","'":"'"})[c])}
function escapeAttr(s){return escapeHtml(String(s == null ? '' : s))}
diff --git a/guess.html b/guess.html
index 3aed39b..9461230 100644
--- a/guess.html
+++ b/guess.html
@@ -11,12 +11,12 @@
Guess The Rank
diff --git a/index.html b/index.html
index 0b81265..ccb04e3 100644
--- a/index.html
+++ b/index.html
@@ -12,13 +12,13 @@
@@ -30,6 +30,7 @@
- Click Open on any row to watch the linked run.
+ Click Open to watch the video. With a profile from Roulette, use My % to track progress per level (this device only).
- | # | Title | Action |
+ | # | Title | My % | Action |
diff --git a/login.html b/login.html
new file mode 100644
index 0000000..c23557a
--- /dev/null
+++ b/login.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
Log in — GD fedl
+
+
+
+
+
+
+ FEDL account
+ Welcome back
+ Sign in to load your saved roulette pick and per-level progress from the server.
+
+
+
+
+
+
diff --git a/players.html b/players.html
index 31c3958..14aa33d 100644
--- a/players.html
+++ b/players.html
@@ -12,11 +12,11 @@
diff --git a/roulette.html b/roulette.html
index 589b51b..29943b3 100644
--- a/roulette.html
+++ b/roulette.html
@@ -12,15 +12,57 @@
+
+
+ Your profile
+ Save roulette & progress
+ Profiles are stored only in this browser. Each profile remembers your last roulette demon and a progress % per level (also shown on the list page).
+
+ Log in or sign up to sync roulette, save slots, and % across browsers.
+
+
+
Roulette save slots
+
Use three slots to park different demons. Requires an active profile or a signed-in account.
+
+
+ Slot 1
+ Empty
+
+
+
+
+ Slot 2
+ Empty
+
+
+
+
+ Slot 3
+ Empty
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -37,9 +79,19 @@
Rank: -
Level ID: -
Level IDs come from your local file first, then API lookup if needed.
+
+
+
+
+ %
+
+
+
+
View Video
+