feat: Project finished.

This commit is contained in:
KyMAN 2026-09-20 17:12:09 +02:00
parent 978e4f3819
commit 91a645af4b
16 changed files with 3226 additions and 2 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/Public/data

View File

@ -0,0 +1,300 @@
"use strict";
const JudgePole = (function(){
const JudgePole = function(){
const self = this,
screen = {x : 0, y : 0};
let cells = 40,
thread_interval = null,
fps = 12;
this.item_self = document;
this.settings = new SettingsManager(self);
this.i18n = new I18NManager(self);
this.db = new DatabaseManager(self);
this.session = new SessionManager(self);
this.views = {};
this.content_area = null;
const constructor = () => {
const position = self.settings.get("position", null, "body");
let target, preload = setInterval(() => {
if(target = document.querySelector(position)){
clearInterval(preload);
self.item_self = target.appendChild(document.createElement("div"));
self.item_self.className = self.item_self.id = self.settings.get("class_name", null, "judge-pole");
self.item_self.setAttribute("data-cells", cells = self.settings.get("cells", null, cells));
thread_interval = setInterval(thread, 1000 / fps);
build();
};
}, fps = self.settings.get(["frames_per_second", "fps"], null, fps));
Common.SCSS(SCSS);
};
const thread = () => {
if(screen.x != self.item_self.offsetWidth || screen.y != self.item_self.offsetHeight){
screen.x = self.item_self.offsetWidth;
screen.y = self.item_self.offsetHeight;
self.item_self.style.fontSize = ((screen.x < screen.y ? screen.x : screen.y) / cells) + "px";
};
};
const build = () => {
self.views.users = new UsersView(self);
self.views.projects = new ProjectsView(self);
self.views.votes = new VotesView(self);
self.render_header();
self.content_area = self.html(["main", { "class" : "jp-content-area" }])[0];
self.item_self.appendChild(self.content_area);
switch_view("votes");
};
// 1. Declarar render_header expuesto en 'this' / 'self'
this.render_header = () => {
const existing_header = self.item_self.querySelector("header.jp-header");
if(existing_header) existing_header.remove();
const is_admin = self.session.is_admin();
const auth_required = self.session.is_auth_required();
const nav_buttons = [
["button", { "class" : "jp-btn" + (self.current_view === "users" ? " is-active" : ""), "data-i18n" : "nav_users", "onclick" : (btn) => switch_view("users", btn) }, self.i18n.get("nav_users")],
["button", { "class" : "jp-btn" + (self.current_view === "projects" ? " is-active" : ""), "data-i18n" : "nav_projects", "onclick" : (btn) => switch_view("projects", btn) }, self.i18n.get("nav_projects")],
["button", { "class" : "jp-btn" + (self.current_view === "votes" ? " is-active" : ""), "data-i18n" : "nav_votes", "onclick" : (btn) => switch_view("votes", btn) }, self.i18n.get("nav_votes")]
];
// Botón de Base64 sólo para administradores
if(is_admin){
nav_buttons.push(["button", { "class" : "jp-btn jp-btn-secondary", "data-i18n" : "nav_export", "onclick" : () => show_export_modal() }, self.i18n.get("nav_export")]);
}
// Botón de Login / Logout si la base de datos requiere autenticación
if(auth_required){
if(is_admin){
nav_buttons.push(["button", {
"type" : "button",
"class" : "jp-btn-danger",
"data-i18n" : "nav_logout",
"style" : "margin-left:0.5em;",
"onclick" : () => {
self.session.logout();
self.render_header();
self.views[self.current_view].go();
}
}, self.i18n.get("nav_logout")]);
} else {
nav_buttons.push(["button", {
"type" : "button",
"class" : "jp-btn-primary",
"data-i18n" : "nav_login",
"style" : "margin-left:0.5em;",
"onclick" : () => show_login_modal()
}, self.i18n.get("nav_login")]);
}
}
const current_lang = self.i18n.get_current_language();
const lang_options = self.i18n.get_languages().map(lang_key => {
const label_key = "lang_" + lang_key;
return ["option", {
"value" : lang_key,
"data-i18n" : label_key,
...(lang_key === current_lang ? { "selected" : "selected" } : {})
}, self.i18n.get(label_key, null, lang_key)];
});
const lang_select = self.html(["select", {
"class" : "jp-lang-select",
"onchange" : (select) => {
self.i18n.set_language(select.value);
if(self.current_view && self.views[self.current_view]){
self.views[self.current_view].go();
}
}
}, lang_options])[0];
const header = self.html(["header", { "class" : "jp-header" }, [
["h1", { "class" : "jp-title", "data-i18n" : "judge_pole" }, self.i18n.get("judge_pole")],
["div", { "style" : "display:flex; align-items:center; gap:0.6em;" }, [
["nav", { "class" : "jp-nav" }, nav_buttons],
lang_select
]]
]])[0];
self.item_self.insertBefore(header, self.content_area || null);
};
const show_login_modal = () => {
const close_modal = () => {
if(modal_overlay && modal_overlay.parentNode) modal_overlay.remove();
};
const modal_overlay = self.html(["div", {
"class" : "jp-modal-overlay",
"onclick" : (overlay, e) => { if(e.target === overlay) close_modal(); }
}, [
["div", { "class" : "jp-modal-window", "style" : "max-width:24em;" }, [
["div", { "class" : "jp-modal-header" }, [
["h3", { "data-i18n" : "login_modal_title" }, self.i18n.get("login_modal_title")],
["button", { "type" : "button", "class" : "jp-btn-danger", "onclick" : () => close_modal() }, "✕"]
]],
["div", { "class" : "jp-modal-body" }, [
["form", {
"onsubmit" : (f, e) => {
e.preventDefault();
const nick = f.querySelector("[name='nick']").value;
const pass = f.querySelector("[name='password']").value;
if(self.session.login(nick, pass)){
close_modal();
self.render_header();
self.views[self.current_view].go();
} else {
alert(self.i18n.get("login_error"));
}
}
}, [
["div", { "class" : "jp-field" }, [
["label", { "data-i18n" : "login_nick" }, self.i18n.get("login_nick")],
["input", { "type" : "text", "name" : "nick", "required" : "required" }]
]],
["div", { "class" : "jp-field" }, [
["label", { "data-i18n" : "login_pass" }, self.i18n.get("login_pass")],
["input", { "type" : "password", "name" : "password", "required" : "required" }]
]],
["div", { "style" : "display:flex; justify-content:flex-end; gap:0.5em; margin-top:1em;" }, [
["button", { "type" : "button", "class" : "jp-btn", "onclick" : () => close_modal() }, "Cancelar"],
["button", { "type" : "submit", "class" : "jp-btn-primary", "data-i18n" : "login_btn" }, self.i18n.get("login_btn")]
]]
]]
]]
]]
]])[0];
self.item_self.appendChild(modal_overlay);
};
const switch_view = (view_name, active_btn) => {
self.current_view = view_name;
self.item_self.querySelectorAll(".jp-nav .jp-btn").forEach(b => b.classList.remove("is-active"));
if(active_btn) active_btn.classList.add("is-active");
if(self.views[view_name])
self.views[view_name].go();
};
const show_export_modal = () => {
const b64 = self.db.export_base64();
const close_modal = () => {
if(modal_overlay && modal_overlay.parentNode)
modal_overlay.remove();
};
const modal_overlay = self.html(["div", {
"class" : "jp-modal-overlay",
"onclick" : (overlay, event) => {
// Cerrar sólo si se hace clic sobre el fondo oscuro exterior
if(event.target === overlay) close_modal();
}
}, [
["div", { "class" : "jp-modal-window", "style" : "max-width:32em;" }, [
["div", { "class" : "jp-modal-header" }, [
["h3", {"data-i18n" : "db_modal_title"}, self.i18n.get("db_modal_title")],
["button", { "type" : "button", "class" : "jp-btn-danger", "onclick" : () => close_modal() }, "✕"]
]],
["div", { "class" : "jp-modal-body", "style" : "display:flex; flex-direction:column; gap:0.6em;" }, [
["p", { "class" : "jp-desc", "style" : "margin:0;" }, "Copia esta constante para sobreescribir /Public/data/database.ecma.js:"],
["textarea", {
"style" : "width:100%; height:8em; box-sizing:border-box; font-family:monospace; font-size:0.75em; background:#0d1117; color:#58a6ff; border:0.05em solid #30363d; padding:0.5em; border-radius:0.2em; resize:none;",
"readonly" : "readonly"
}, `const database = "${b64}";`],
["div", { "style" : "display:flex; justify-content:flex-end; gap:0.5em; margin-top:0.5em;" }, [
["button", { "type" : "button", "class" : "jp-btn-primary", "data-i18n" : "db_copy_btn", "onclick" : () => {
navigator.clipboard.writeText(`const database = "${b64}";`);
alert(self.i18n.get("db_copied"));
}}, self.i18n.get("db_copy_btn")],
["button", { "type" : "button", "class" : "jp-btn-secondary", "data-i18n" : "db_import_btn", "onclick" : () => {
const input = prompt("Pega el Base64:");
if(input && self.db.import_base64(input)){
alert(self.i18n.get("db_imported"));
self.views.users.go();
close_modal();
}
}}, self.i18n.get("db_import_btn")],
["button", { "type" : "button", "class" : "jp-btn", "onclick" : () => close_modal() }, "Cerrar"]
]]
]]
]]
]])[0];
self.item_self.appendChild(modal_overlay);
};
/**
* @param {...(HTMLElement|string|Array.<any|null>)} inputs
* @returns {Array.<HTMLElement>}
* @access public
* @static
*/
this.html = (...inputs) => {
const fragment = new DocumentFragment(),
master = (
inputs[0] && (inputs[0].tagName || inputs[0].nodeName) ? inputs[0] :
typeof inputs[0] == "string" ? document.querySelector(inputs[0]) :
null),
items = [];
(master ? inputs.slice(1) : inputs).forEach(subinputs => {
if(subinputs && (subinputs.tagName || subinputs.nodeName))
fragment.appendChild(subinputs);
else if(typeof subinputs == "string")
fragment.appendChild(document.createTextNode(subinputs));
else if(subinputs instanceof Array){
const [tag, attributes, children] = subinputs.concat([null, null]).slice(0, 3),
item = document.createElement(tag);
attributes && attributes.constructor == Object && Object.entries(attributes).forEach(([key, value]) => {
if(/^on[_\-]?/i.test(key) && typeof value == "function")
item.addEventListener(key.replace(/^on[_\-]?/i, "").replace(/[^a-z0-9]+/gi, "").toLowerCase(), event => {
value(item, event);
});
else if(value !== null)
item.setAttribute(key.replace(/[^a-z0-9]+/gi, "-"), value);
});
if(children instanceof Array)
self.html(...children).forEach(child => item.appendChild(child));
else if(typeof children == "string")
item.innerHTML += children;
fragment.appendChild(item);
};
});
items.push(...fragment.childNodes);
master && master.appendChild(fragment);
return items;
};
constructor();
};
return JudgePole;
})();

View File

@ -0,0 +1,221 @@
"use strict";
const DatabaseManager = (function(){
const DatabaseManager = function(judge_pole){
const self = this;
let data = {
users : [],
projects : [],
criteria : [],
votes : {} // { project_id: { user_id: { criterion_name: score } } }
};
const constructor = () => {
// Inicializar criterios por defecto si no existen
const default_crit = judge_pole.settings.get("default_criteria", null, []);
default_crit.forEach(crit => self.add_criterion(crit));
// Carga de base de datos externa inyectada
if(typeof database != "undefined" && typeof database == "string"){
self.import_base64(database);
}
};
this.get_data = () => data;
this.add_criterion = (criterion_name) => {
criterion_name = ("" + criterion_name).trim();
if(criterion_name && !data.criteria.includes(criterion_name)){
data.criteria.push(criterion_name);
return true;
}
return false;
};
this.update_criterion = (old_name, new_name) => {
old_name = ("" + old_name).trim();
new_name = ("" + new_name).trim();
if(!new_name || old_name === new_name) return false;
const index = data.criteria.indexOf(old_name);
if(index === -1) return false;
// Reemplazar en la lista maestra
data.criteria[index] = new_name;
// Propagar en cascada en todos los votos existentes
Object.keys(data.votes).forEach(project_id => {
const project_votes = data.votes[project_id];
if(project_votes){
Object.keys(project_votes).forEach(user_id => {
if(project_votes[user_id] && project_votes[user_id][old_name] !== undefined){
project_votes[user_id][new_name] = project_votes[user_id][old_name];
delete project_votes[user_id][old_name];
}
});
}
});
return true;
};
this.delete_criterion = (criterion_name) => {
criterion_name = ("" + criterion_name).trim();
const initial_len = data.criteria.length;
data.criteria = data.criteria.filter(c => c !== criterion_name);
if(data.criteria.length !== initial_len){
// Purgar el criterio de todos los votos guardados
Object.keys(data.votes).forEach(project_id => {
const project_votes = data.votes[project_id];
if(project_votes){
Object.keys(project_votes).forEach(user_id => {
if(project_votes[user_id] && project_votes[user_id][criterion_name] !== undefined){
delete project_votes[user_id][criterion_name];
}
});
}
});
return true;
}
return false;
};
this.save_votes = (project_id, vote_package) => {
// vote_package: { scores, judge_weights, criteria_weights }
data.votes[project_id] = vote_package;
};
this.export_base64 = () => {
const json_string = JSON.stringify(data, null, 4);
return btoa(unescape(encodeURIComponent(json_string)));
};
this.import_base64 = (base64_string) => {
try {
const json_string = decodeURIComponent(escape(atob(base64_string.trim())));
data = JSON.parse(json_string);
return true;
} catch(error) {
console.error("Error al importar Base64:", error);
return false;
}
};
this.delete_user = (user_id) => {
const initial_length = data.users.length;
data.users = data.users.filter(u => u.id !== user_id);
if(data.users.length !== initial_length){
// Limpieza de proyectos huérfanos asociados a este usuario
const removed_project_ids = data.projects
.filter(p => p.user_id === user_id)
.map(p => p.id);
data.projects = data.projects.filter(p => p.user_id !== user_id);
// Limpieza de votos del usuario o de proyectos eliminados
removed_project_ids.forEach(p_id => {
delete data.votes[p_id];
});
Object.keys(data.votes).forEach(p_id => {
if(data.votes[p_id] && data.votes[p_id][user_id]){
delete data.votes[p_id][user_id];
}
});
return true;
}
return false;
};
this.add_user = (user_data) => {
const now = new Date().toISOString();
const user = {
id : "usr_" + Date.now() + "_" + Math.floor(Math.random() * 1000),
type : user_data.type || "human", // "human" | "ai"
nick : user_data.nick || "Anon",
password : user_data.password ? user_data.password.trim() : "",
description : user_data.description || "",
avatar : user_data.avatar || "",
links : user_data.links || [],
created_at : now,
updated_at : now
};
data.users.push(user);
return user;
};
this.update_user = (user_id, updated_fields) => {
const user = data.users.find(u => u.id === user_id);
if(!user) return null;
user.type = updated_fields.type !== undefined ? updated_fields.type : (user.type || "human");
user.nick = updated_fields.nick !== undefined ? updated_fields.nick : user.nick;
user.password = updated_fields.password !== undefined ? updated_fields.password.trim() : (user.password || "");
user.description = updated_fields.description !== undefined ? updated_fields.description : user.description;
user.avatar = updated_fields.avatar !== undefined ? updated_fields.avatar : user.avatar;
user.links = updated_fields.links !== undefined ? updated_fields.links : user.links;
user.updated_at = new Date().toISOString();
return user;
};
this.add_project = (project_data) => {
const now = new Date().toISOString();
const project = {
id : "prj_" + Date.now() + "_" + Math.floor(Math.random() * 1000),
user_ids : project_data.user_ids || (project_data.user_id ? [project_data.user_id] : []),
name : project_data.name,
description : project_data.description || "",
avatar : project_data.avatar || "",
tags : project_data.tags || [],
technologies : project_data.technologies || [],
links : project_data.links || [],
created_at : now,
updated_at : now
};
data.projects.push(project);
return project;
};
this.update_project = (project_id, updated_fields) => {
const project = data.projects.find(p => p.id === project_id);
if(!project) return null;
project.user_ids = updated_fields.user_ids !== undefined ? updated_fields.user_ids : (project.user_ids || (project.user_id ? [project.user_id] : []));
project.name = updated_fields.name !== undefined ? updated_fields.name : project.name;
project.description = updated_fields.description !== undefined ? updated_fields.description : project.description;
project.avatar = updated_fields.avatar !== undefined ? updated_fields.avatar : project.avatar;
project.tags = updated_fields.tags !== undefined ? updated_fields.tags : project.tags;
project.technologies = updated_fields.technologies !== undefined ? updated_fields.technologies : project.technologies;
project.links = updated_fields.links !== undefined ? updated_fields.links : project.links;
project.updated_at = new Date().toISOString();
return project;
};
this.delete_project = (project_id) => {
const initial_length = data.projects.length;
data.projects = data.projects.filter(p => p.id !== project_id);
if(data.projects.length !== initial_length){
// Limpieza de las puntuaciones asociadas al proyecto eliminado
if(data.votes[project_id]){
delete data.votes[project_id];
}
return true;
}
return false;
};
constructor();
};
return DatabaseManager;
})();

View File

@ -0,0 +1,81 @@
"use strict";
const I18NManager = (function(){
const I18NManager = function(judge_pole){
const self = this,
sentences = {};
let language = "espanol";
const constructor = () => {
[i18n, typeof custom_i18n != "undefined" ? custom_i18n : {}].forEach(block => {
self.add(block, true);
});
self.set_language(judge_pole.settings.get("language"), null, Object.keys(sentences)[0] || language);
};
this.add = (new_sentences, overwrite = true) => {
for(const language in new_sentences){
if(!sentences[language])
sentences[language] = {};
for(const key in new_sentences[language])
if(overwrite || sentences[language][key] === undefined)
sentences[language][key] = new_sentences[language][key];
};
};
const get_sentence = (strings, _default) => {
const done = [];
if(typeof strings == "string")
strings = [strings];
for(const current of [language].concat(Object.keys(sentences)))
if(!done.includes(current)){
done.push(current);
for(const key of strings)
if(sentences[current][key] !== undefined)
return sentences[current][key];
};
if(_default !== null)
return _default;
for(const text of strings)
return text;
return "";
};
this.get = (strings, inputs = null, _default = null) => Common.string_variables(get_sentence(strings, _default), inputs);
this.update = () => {
document.querySelectorAll("[data-i18n]").forEach(element => {
const text = self.get(element.getAttribute("data-i18n"));
if(element.getAttribute("data-i18n-without") != "true")
element.textContent = text;
["title", "alt", "placeholder"].forEach(key => {
element.hasAttribute(key) && element.setAttribute(key, text);
});
})
};
this.set_language = new_language => {
if(language != new_language && sentences[new_language]){
language = new_language;
self.update();
};
};
this.get_languages = () => Object.keys(sentences);
this.get_current_language = () => language;
constructor();
};
return I18NManager;
})();

View File

@ -0,0 +1,45 @@
"use strict";
const SessionManager = (function(){
const SessionManager = function(judge_pole){
const self = this;
let current_user = null;
// Comprueba si existe al menos un usuario con contraseña registrada
this.is_auth_required = () => {
const users = judge_pole.db.get_data().users || [];
return users.some(u => u.password && u.password.trim().length > 0);
};
// Si no hay usuarios con contraseña, cualquiera es admin por defecto
this.is_admin = () => {
if(!self.is_auth_required()) return true;
return current_user !== null;
};
this.get_user = () => current_user;
this.login = (nick, password) => {
const users = judge_pole.db.get_data().users || [];
const user = users.find(u =>
u.nick.trim().toLowerCase() === nick.trim().toLowerCase() &&
u.password === password
);
if(user){
current_user = user;
return true;
}
return false;
};
this.logout = () => {
current_user = null;
};
};
return SessionManager;
})();

View File

@ -0,0 +1,27 @@
"use strict";
const SettingsManager = (function(){
const SettingsManager = function(judge_pole){
const self = this,
settings = {};
const constructor = () => {
self.add(settings, true);
};
this.add = (block, overwrite = true) => {
for(const key in block)
if(overwrite || settings[key] === undefined)
settings[key] = block[key];
};
this.get = (keys, inputs = null, _default = null) => Common.get_value(keys, [inputs, settings], _default);
constructor();
};
return SettingsManager;
})();

View File

@ -0,0 +1,127 @@
"use strict";
const Common = (function(){
const Common = function(){};
Common.get_keys = (...items) => items.reduce((keys, item) => {
if(item){
if(typeof item == "string" && /^[a-z_][a-z0-9_]*$/i.test(item) && !keys.includes(item))
keys.push(item);
else if(item instanceof Array)
for(const key of Common.get_keys(...item))
keys.includes(item) || keys.push(key);
};
return keys;
}, []);
Common.get_dictionaries = (...items) => items.reduce((dictionaries, item) => {
if(item){
if(item.constructor == Object)
dictionaries.push(item);
else if(item instanceof Array)
dictionaries.push(...Common.get_dictionaries(...item));
};
return dictionaries;
}, []);
Common.get_value = (keys, inputs, _default = null) => {
if((keys = Common.get_keys(keys)).length)
for(const subinputs of Common.get_dictionaries(inputs))
for(const key of keys)
if(subinputs[key] !== undefined)
return subinputs[key];
return _default;
};
Common.get_dictionary = (items, overwrite = false) => {
if(items){
if(items.constructor == Object)
return items;
else if(items instanceof Array)
return items.reduce((dictionary, item) => {
Object.entries(Common.get_dictionary(item)).forEach(([key, value]) => {
if(overwrite || dictionary[key] === undefined)
dictionary[key] = value;
});
return dictionary;
}, {});
};
return {};
};
Common.string_variables = (string, variables, _default = null) => {
variables = Common.get_dictionary(variables);
return ("" + string).replace(/\{([^\{\}]+)\}/g, (all, key) => (
variables[key] !== undefined ? variables[key] :
_default !== null ? _default :
all));
};
Common.execute_array = (array, each_callback, end_callback, i = 0) => {
if(i == array.length)
end_callback();
else
each_callback(array[i], () => {
Common.execute_array(array, each_callback, end_callback, i + 1);
});
};
Common.SCSS = (data, selector = "") => {
/** @type {Arrya.<string>} */
const childs = [];
/** @type {string} */
let css = ``;
Object.entries(data).forEach(([key, value]) => {
if(value && value.constructor == Object)
key.split(",").forEach(key => {
/** @type {string} */
const subselector = `${(selector ? selector + (
key[0] == "&" ? "" : " "
) : "") + (
key[0] == "&" ? key.substring(1) : key
)}`,
/** @type {Array<string>} */
subcss = Common.SCSS(value, subselector);
childs.push(`\n\n${subselector}{${subcss[0]}\n}`, ...subcss.slice(1));
});
else
css += `\n ${key.replace(/[^a-z0-9]+/gi, "-")} : ${value === null ? "none" : value};`;
});
if(!selector){
/** @type {HTMLStyleElement} */
let style = document.querySelector("head>style");
if(!style){
style = document.querySelector("head").appendChild(document.createElement("style"));
[
["data-type", "text/css;charset=utf-8"],
["data-language", "CSS3"],
["charset", "utf-8"]
].forEach(([key, value]) => style.setAttribute(key, value === null ? "none" : value));
};
style.appendChild(document.createTextNode(css + childs.join("")));
};
return [css, ...childs];
};
return Common;
})();

View File

@ -0,0 +1,187 @@
"use strict";
/** @type {Dictionary.<string, Dictionary.<string, string>>} */
const i18n = {
espanol : {
"judge_pole" : "JudgePole",
"lang_espanol" : "Español 🇪🇸",
"lang_english" : "English 🇬🇧",
"nav_users" : "Usuarios",
"nav_projects" : "Proyectos",
"nav_votes" : "Votaciones",
"nav_export" : "Base64 DB",
"users_title" : "Gestión de Jueces y Creadores",
"user_nick" : "Nick:",
"user_desc" : "Descripción / Bio:",
"user_avatar" : "Avatar (Ruta local / URL):",
"user_links" : "Enlaces (Nombre y URL):",
"btn_add_link" : "+ Enlace",
"btn_save_user" : "Guardar Usuario",
"no_users" : "No hay participantes registrados todavía.",
"btn_edit" : "Editar",
"btn_delete" : "Eliminar",
"btn_update_user" : "Actualizar Usuario",
"btn_cancel_edit" : "Cancelar Edición",
"user_editing_mode" : "Editando a: {nick}",
"confirm_delete_user" : "¿Estás seguro de eliminar a '{nick}'? Se eliminarán también sus proyectos y votos asociados.",
"btn_new_user" : "+ Añadir Usuario",
"modal_new_user_title" : "Nuevo Participante",
"modal_edit_user_title" : "Editar Participante: {nick}",
"btn_close_modal" : "Cerrar",
"search_users_placeholder" : "Buscar usuarios por nick o bio...",
"no_users_found" : "No se encontraron participantes que coincidan con la búsqueda.",
"projects_title" : "Proyectos de la Jam",
"project_author" : "Autor del proyecto:",
"project_name" : "Nombre del Proyecto:",
"project_desc" : "Descripción:",
"project_avatar" : "Imagen / Logo del proyecto:",
"project_tags" : "Tags (separados por coma):",
"project_tech" : "Tecnologías (separadas por coma):",
"project_links" : "Enlaces del proyecto:",
"btn_save_project" : "Guardar Proyecto",
"no_projects" : "No hay proyectos registrados.",
"select_author" : "-- Selecciona un autor --",
"btn_update_project" : "Actualizar Proyecto",
"project_editing_mode" : "Editando proyecto: {name}",
"confirm_delete_project" : "¿Estás seguro de eliminar el proyecto '{name}'? Se perderán también sus calificaciones registradas.",
"btn_new_project" : "+ Añadir Proyecto",
"modal_new_project_title" : "Nuevo Proyecto Jam",
"modal_edit_project_title" : "Editar Proyecto: {name}",
"search_projects_placeholder" : "Buscar proyectos por nombre, autor, tags o tech...",
"no_projects_found" : "No se encontraron proyectos que coincidan con la búsqueda.",
"search_authors_placeholder" : "Filtrar participantes...",
"user_type" : "Tipo de participante:",
"user_type_human" : "Humano 👤",
"user_type_ai" : "Inteligencia Artificial 🤖",
"project_authors" : "Autores del proyecto:",
"no_authors_selected" : "Debes seleccionar al menos un autor para el proyecto.",
"votes_title" : "Tribunal de Evaluación",
"select_project" : "Proyecto a Juzgar:",
"select_project_option" : "-- Seleccionar proyecto --",
"participating_judges" : "Jueces que votarán (incluido el autor):",
"evaluation_criteria" : "Criterios de Evaluación:",
"btn_new_criterion" : "+ Crear Criterio Personalizado",
"new_criterion_prompt" : "Nombre del nuevo criterio:",
"btn_save_scores" : "Registrar Veredicto",
"score_matrix_title" : "Papeleta de Calificaciones (0 a 10):",
"final_ranking" : "Ranking y Puntuaciones Finales",
"score_total" : "Media General:",
"created_at" : "Alta:",
"updated_at" : "Modif:",
"db_modal_title" : "Base de Datos en Base64 (database.ecma.js)",
"db_copy_btn" : "Copiar al Portapapeles",
"db_import_btn" : "Cargar Base64",
"db_copied" : "¡Copiado con éxito!",
"db_imported" : "¡Datos cargados con éxito!",
"btn_new_vote" : "+ Registrar Votación",
"search_rankings_placeholder" : "Buscar proyecto en el ranking...",
"rank_global" : "Global",
"rank_filtered" : "Filtro",
"rank_project" : "Proyecto",
"rank_average" : "Media General",
"rank_actions" : "Acciones",
"rank_criteria_breakdown" : "Desglose de medias por criterio:",
"search_criteria_placeholder" : "Filtrar criterios...",
"modal_new_vote_title" : "Tribunal: Evaluar Proyecto",
"modal_edit_vote_title" : "Tribunal: Modificar Notas de {project}",
"user_average_row" : "Media Juez",
"criterion_average_col" : "Media Criterio",
"confirm_delete_vote" : "¿Eliminar todas las calificaciones del proyecto '{project}'?",
"no_votes_registered" : "Aún no se ha calificado ningún proyecto.",
"edit_criterion_prompt" : "Nuevo nombre para el criterio:",
"confirm_delete_criterion" : "¿Seguro que quieres eliminar el criterio '{criterion}'? Se borrará de todas las calificaciones existentes.",
"weight_label" : "Peso:",
"crit_avg_natural" : "Media Nat.",
"crit_avg_weighted" : "Media Pond.",
"judge_avg_natural" : "Media Juez (Nat.)",
"judge_avg_weighted" : "Media Juez (Pond.)",
"global_natural_label" : "Nat:",
"global_weighted_label" : "Pond:",
"nav_login" : "Acceder 🔐",
"nav_logout" : "Salir 🚪",
"login_modal_title" : "Acceso Administrativo",
"login_nick" : "Nick de Usuario:",
"login_pass" : "Contraseña:",
"login_btn" : "Iniciar Sesión",
"login_error" : "Credenciales incorrectas o usuario sin permisos.",
"user_pass_label" : "Contraseña (dejar vacío si no requiere login):",
"user_pass_placeholder" : "Opcional: define clave admin",
"rank_view_full_matrix" : "Ver Matriz Completa",
"rank_hide_full_matrix" : "Ocultar Matriz Completa"
},
english : {
"judge_pole" : "JudgePole",
"lang_espanol" : "Español 🇪🇸",
"lang_english" : "English 🇬🇧",
"nav_users" : "Judges",
"nav_projects" : "Projects",
"nav_votes" : "Voting",
"nav_export" : "Base64 DB",
"users_title" : "Judges & Creators Management",
"user_nick" : "Nick:",
"user_desc" : "Description / Bio:",
"user_avatar" : "Avatar (Local path / URL):",
"user_links" : "Links (Name and URL):",
"user_type" : "Participant Type:",
"user_type_human" : "Human 👤",
"user_type_ai" : "Artificial Intelligence 🤖",
"btn_add_link" : "+ Link",
"btn_save_user" : "Save Judge",
"btn_new_user" : "+ Add Judge",
"btn_edit" : "Edit",
"btn_delete" : "Delete",
"btn_update_user" : "Update Judge",
"btn_cancel_edit" : "Cancel",
"no_users" : "No registered participants yet.",
"search_users_placeholder" : "Search judges by nick or bio...",
"no_users_found" : "No participants match your search.",
"confirm_delete_user" : "Delete '{nick}'? Their projects and votes will be removed.",
"projects_title" : "Jam Projects",
"project_authors" : "Project Authors:",
"project_name" : "Project Name:",
"project_desc" : "Description:",
"project_avatar" : "Project Logo / Image:",
"project_tags" : "Tags (comma separated):",
"project_tech" : "Technologies (comma separated):",
"project_links" : "Project Links:",
"btn_save_project" : "Save Project",
"btn_new_project" : "+ Add Project",
"btn_update_project" : "Update Project",
"no_projects" : "No registered projects.",
"search_projects_placeholder" : "Search projects...",
"no_projects_found" : "No projects found.",
"confirm_delete_project" : "Delete project '{name}'?",
"search_authors_placeholder" : "Filter authors...",
"no_authors_selected" : "Please select at least one author.",
"votes_title" : "Evaluation Tribunal",
"select_project" : "Project to Judge:",
"select_project_option" : "-- Select project --",
"participating_judges" : "Voting Judges:",
"evaluation_criteria" : "Evaluation Criteria:",
"btn_new_criterion" : "+ New Criterion",
"new_criterion_prompt" : "Criterion name:",
"btn_save_scores" : "Save Verdict",
"score_matrix_title" : "Scorecard (0 to 10):",
"final_ranking" : "Final Standings",
"created_at" : "Created:",
"updated_at" : "Updated:",
"db_modal_title" : "Base64 Database",
"db_copy_btn" : "Copy to Clipboard",
"db_import_btn" : "Load Base64",
"db_copied" : "Copied successfully!",
"db_imported" : "Data loaded successfully!"
}
};

View File

@ -0,0 +1,20 @@
"use strict";
/** @type {Dictionary.<string, any|null>} */
const settings = {
cells : 40,
position : "body",
class_name : "judge-pole",
frames_per_second : 12,
language : "espanol",
db_storage_key : "judge_pole_db_data",
default_criteria : [
"Vibe & Originalidad",
"Calidad Técnica / No Explota",
"Factor Troll / Meme",
"Diseño / Estética"
],
...(typeof custom_settings == "undefined" ? {} : custom_settings)
};

View File

@ -0,0 +1,567 @@
"use strict";
/** @type {Dictionary.<string, any|null>} */
const SCSS = {
...{
"body,html" : {
height : "100%",
margin : "0em",
background : "#0f111a",
color : "#e6edf3",
font_family : "system-ui, -apple-system, sans-serif"
}
},
...{
".judge-pole" : {
position : "relative",
top : "0em",
left : "0em",
width : "100%",
height : "100%",
box_sizing : "border-box",
display : "flex",
flex_direction : "column",
overflow : "hidden",
".jp-header" : {
display : "flex",
align_items : "center",
justify_content : "space-between",
padding : "0.5em 1em",
background : "#161b22",
border_bottom : "0.05em solid #30363d",
".jp-title" : {
margin : "0em",
font_size : "1.2em",
color : "#58a6ff"
},
".jp-nav" : {
display : "flex",
gap : "0.5em"
}
},
".jp-content-area" : {
flex : "1",
overflow_y : "auto",
padding : "1em",
box_sizing : "border-box"
},
".jp-btn, button" : {
background : "#21262d",
color : "#c9d1d9",
border : "0.05em solid #30363d",
padding : "0.4em 0.8em",
font_size : "0.8em",
cursor : "pointer",
border_radius : "0.2em",
"&:hover" : {
background : "#30363d"
},
"&.is-active" : {
background : "#1f6feb",
color : "#ffffff",
border_color : "#388bfd"
}
},
".jp-btn-primary" : {
background : "#238636",
color : "#ffffff",
border_color : "#2ea043",
"&:hover" : { background : "#2ea043" }
},
".jp-btn-danger" : {
background : "#da3633",
color : "#ffffff",
border_color : "#f85149"
},
".jp-form-card, .jp-panel-controls, .jp-matrix-card, .jp-ranking-box" : {
background : "#161b22",
border : "0.05em solid #30363d",
padding : "1em",
margin_bottom : "1em",
border_radius : "0.3em"
},
".jp-field" : {
margin_bottom : "0.8em",
display : "flex",
flex_direction : "column",
gap : "0.3em",
"label" : {
font_size : "0.8em",
color : "#8b949e",
font_weight : "bold"
},
"input, textarea, select" : {
background : "#0d1117",
border : "0.05em solid #30363d",
color : "#e6edf3",
padding : "0.4em",
font_size : "0.8em",
border_radius : "0.2em"
}
},
".jp-cards-grid" : {
display : "flex",
flex_wrap : "wrap",
justify_content : "center", // Centra la lista de tarjetas en la pantalla
gap : "1em",
width : "100%",
box_sizing : "border-box"
},
".jp-card" : {
flex : "1 1 12em",
max_width : "16em", // Evita que una sola tarjeta se vuelva gigantesca a lo ancho
position : "relative",
background : "#161b22",
border : "0.05em solid #30363d",
padding : "0.8em",
border_radius : "0.3em",
box_sizing : "border-box",
".jp-avatar, .jp-avatar-placeholder" : {
width : "100%",
height : "5em",
border_radius : "0.2em",
margin_bottom : "0.5em",
box_sizing : "border-box"
},
".jp-avatar" : {
object_fit : "cover"
},
".jp-avatar-placeholder" : {
display : "flex",
align_items : "center",
justify_content : "center",
background : "#0d1117",
border : "0.05em dashed #30363d",
color : "#484f58",
font_size : "1.2em",
font_weight : "bold",
letter_spacing : "0.05em",
user_select : "none"
},
"h3" : { margin : "0em 0em 0.3em 0em", font_size : "1em", padding_right : "1.5em" },
".jp-desc" : { font_size : "0.75em", color : "#8b949e" },
// Botón Eliminar: Esquina superior derecha
".jp-card-btn-delete" : {
position : "absolute",
top : "0.4em",
right : "0.4em",
width : "1.6em",
height : "1.6em",
border_radius : "50%",
background : "#da3633",
color : "#ffffff",
border : "0.05em solid #f85149",
display : "flex",
align_items : "center",
justify_content : "center",
font_size : "0.75em",
padding : "0em",
cursor : "pointer",
box_shadow : "0 0.1em 0.3em rgba(0,0,0,0.5)",
"&:hover" : {
background : "#f85149",
transform : "scale(1.1)"
}
},
// Botón Editar: Esquina inferior derecha
".jp-card-btn-edit" : {
position : "absolute",
bottom : "0.4em",
right : "0.4em",
width : "1.6em",
height : "1.6em",
border_radius : "50%",
background : "#21262d",
color : "#58a6ff",
border : "0.05em solid #388bfd",
display : "flex",
align_items : "center",
justify_content : "center",
font_size : "0.75em",
padding : "0em",
cursor : "pointer",
box_shadow : "0 0.1em 0.3em rgba(0,0,0,0.5)",
"&:hover" : {
background : "#1f6feb",
color : "#ffffff",
transform : "scale(1.1)"
}
}
},
".jp-tags-group" : {
display : "flex",
flex_wrap : "wrap",
gap : "0.3em",
margin : "0.4em 0em"
},
".jp-badge" : {
font_size : "0.65em",
padding : "0.1em 0.4em",
border_radius : "0.2em",
"&.jp-badge-tag" : { background : "#388bfd33", color : "#58a6ff" },
"&.jp-badge-tech" : { background : "#a371f733", color : "#bc8cff" }
},
".jp-tag-link" : {
color : "#58a6ff",
text_decoration : "none",
font_size : "0.75em",
border_bottom : "0.05em dotted #58a6ff"
},
".jp-table" : {
width : "100%",
border_collapse : "collapse",
margin : "0.8em 0em",
"th, td" : {
border : "0.05em solid #30363d",
padding : "0.4em",
text_align : "center",
font_size : "0.8em"
},
"th" : { background : "#21262d" },
".jp-score-input" : {
width : "3.5em",
text_align : "center"
}
},
".jp-rank-row" : {
display : "flex",
justify_content : "space-between",
padding : "0.4em",
border_bottom : "0.05em solid #30363d",
font_size : "0.85em",
".jp-rank-avg" : { color : "#3fb950", font_weight : "bold" }
},
".jp-card-actions" : {
display : "flex",
gap : "0.5em",
margin_top : "0.8em"
},
".jp-meta" : {
display : "flex",
flex_direction : "column",
gap : "0.2em",
margin_top : "0.5em",
color : "#8b949e",
font_size : "0.7em"
},
".jp-notice-badge" : {
display : "flex",
justify_content : "space-between",
align_items : "center",
background : "#1f6feb22",
border : "0.05em solid #388bfd",
padding : "0.4em 0.8em",
margin_bottom : "0.8em",
border_radius : "0.2em",
font_size : "0.8em",
color : "#58a6ff"
},
".jp-author-tag" : {
margin : "0em 0em 0.4em 0em",
font_size : "0.75em",
color : "#58a6ff",
font_weight : "normal"
},
".jp-modal-overlay" : {
position : "fixed",
top : "0em",
left : "0em",
width : "100%",
height : "100%",
background : "rgba(0, 0, 0, 0.75)",
display : "flex",
align_items : "center",
justify_content : "center",
z_index : "1000",
padding : "1em",
box_sizing : "border-box",
".jp-modal-window" : {
background : "#161b22",
border : "0.05em solid #30363d",
border_radius : "0.4em",
width : "100%",
max_width : "28em",
max_height : "90%",
display : "flex",
flex_direction : "column",
box_shadow : "0 0.5em 2em rgba(0,0,0,0.8)",
overflow : "hidden",
".jp-modal-header" : {
display : "flex",
align_items : "center",
justify_content : "space-between",
padding : "0.8em 1em",
border_bottom : "0.05em solid #30363d",
background : "#0d1117",
"h3" : { margin : "0em", font_size : "1em", color : "#58a6ff" }
},
".jp-modal-body" : {
padding : "1em",
overflow_y : "auto",
flex : "1"
}
}
},
".jp-view-topbar" : {
display : "flex",
justify_content : "space-between",
align_items : "center",
margin_bottom : "1em"
},
".jp-search-input" : {
background : "#0d1117",
border : "0.05em solid #30363d",
color : "#e6edf3",
padding : "0.4em 0.8em",
font_size : "0.8em",
border_radius : "0.2em",
width : "16em",
box_sizing : "border-box",
"&:focus" : {
border_color : "#58a6ff",
outline : "none"
}
},
".jp-badge-type-human" : {
background : "#23863633",
color : "#3fb950",
border : "0.05em solid #2ea04366"
},
".jp-badge-type-ai" : {
background : "#a371f733",
color : "#d2a8ff",
border : "0.05em solid #a371f766"
},
".jp-authors-select-box" : {
display : "flex",
flex_wrap : "wrap",
gap : "0.5em",
background : "#0d1117",
border : "0.05em solid #30363d",
padding : "0.5em",
border_radius : "0.2em",
max_height : "6em",
overflow_y : "auto"
},
".jp-check-label" : {
display : "flex",
align_items : "center",
gap : "0.3em",
font_size : "0.75em",
color : "#c9d1d9",
cursor : "pointer",
user_select : "none",
"input" : {
cursor : "pointer"
}
},
".jp-authors-picker" : {
display : "flex",
flex_direction : "column",
background : "#0d1117",
border : "0.05em solid #30363d",
border_radius : "0.2em",
overflow : "hidden",
".jp-authors-search-input" : {
background : "#161b22",
border : "none",
border_bottom : "0.05em solid #30363d",
color : "#e6edf3",
padding : "0.4em 0.6em",
font_size : "0.75em",
box_sizing : "border-box",
width : "100%",
outline : "none",
"&:focus" : {
background : "#1c2128"
}
},
".jp-authors-select-box" : {
display : "flex",
flex_direction : "column",
gap : "0.4em",
padding : "0.5em",
max_height : "7em", // Altura máxima fija
overflow_y : "auto", // Scroll vertical si excede
box_sizing : "border-box"
}
},
".jp-lang-select" : {
background : "#0d1117",
border : "0.05em solid #30363d",
color : "#e6edf3",
padding : "0.3em 0.6em",
font_size : "0.75em",
border_radius : "0.2em",
cursor : "pointer",
outline : "none",
"&:hover, &:focus" : {
border_color : "#58a6ff"
}
},
".jp-rank-table" : {
width : "100%",
border_collapse : "collapse",
background : "#161b22",
border : "0.05em solid #30363d",
border_radius : "0.3em",
overflow : "hidden",
"th, td" : {
padding : "0.6em 0.8em",
border_bottom : "0.05em solid #30363d",
font_size : "0.8em"
},
"th" : {
background : "#0d1117",
color : "#8b949e",
text_align : "left"
},
"tbody tr" : {
cursor : "pointer",
"&:hover" : {
background : "#1c2128"
}
},
".jp-rank-badge-global" : {
font_weight : "bold",
color : "#f1e05a"
},
".jp-rank-badge-filter" : {
color : "#58a6ff"
},
".jp-rank-avg-score" : {
color : "#3fb950",
font_weight : "bold"
}
},
".jp-breakdown-row" : {
background : "#0d1117 !important",
cursor : "default !important",
"td" : {
padding : "0.8em 1.2em !important"
}
},
".jp-breakdown-grid" : {
display : "grid",
grid_template_columns : "repeat(auto-fill, minmax(10em, 1fr))",
gap : "0.5em",
margin_top : "0.4em"
},
".jp-breakdown-item" : {
background : "#161b22",
border : "0.05em solid #30363d",
padding : "0.4em 0.6em",
border_radius : "0.2em",
display : "flex",
justify_content : "space-between",
font_size : "0.75em",
".jp-crit-name" : { color : "#c9d1d9" },
".jp-crit-val" : { color : "#58a6ff", font_weight : "bold" }
},
".jp-matrix-table" : {
width : "100%",
border_collapse : "collapse",
margin_top : "0.8em",
"th, td" : {
border : "0.05em solid #30363d",
padding : "0.4em",
text_align : "center",
font_size : "0.75em"
},
"th" : { background : "#0d1117", color : "#c9d1d9" },
".jp-crit-cell" : { text_align : "left", font_weight : "bold", color : "#58a6ff" },
".jp-summary-cell" : { background : "#161b22", color : "#3fb950", font_weight : "bold" },
".jp-score-input" : {
width : "3.5em",
text_align : "center",
padding : "0.2em",
background : "#0d1117",
border : "0.05em solid #30363d",
color : "#e6edf3",
border_radius : "0.2em"
}
},
".jp-criterion-row" : {
display : "flex",
align_items : "center",
justify_content : "space-between",
padding : "0.2em 0em",
border_bottom : "0.05em solid #21262d",
".jp-crit-actions" : {
display : "flex",
gap : "0.3em"
}
},
".jp-weight-input" : {
width : "3.2em",
padding : "0.15em 0.3em",
background : "#0d1117",
border : "0.05em solid #30363d",
color : "#f1e05a",
font_size : "0.75em",
text_align : "center",
border_radius : "0.2em",
margin_left : "0.4em",
"&:disabled" : {
opacity : "0.3",
cursor : "not-allowed"
}
},
".jp-summary-subcell" : {
background : "#161b22",
font_weight : "bold",
font_size : "0.7em"
},
}
},
...(typeof custom_styles == "undefined" ? {} : custom_styles)
};

View File

@ -0,0 +1,373 @@
"use strict";
const ProjectsView = (function(){
const ProjectsView = function(judge_pole){
const self = this;
let container = null;
let cards_wrapper = null;
let filter_query = "";
this.go = () => {
judge_pole.content_area.innerHTML = "";
judge_pole.html(judge_pole.content_area, build());
};
const build = () => {
const is_admin = judge_pole.session.is_admin();
const topbar_actions = [
["input", {
"type" : "search",
"class" : "jp-search-input",
"data-i18n" : "search_projects_placeholder",
"data-i18n-without" : "true",
"placeholder" : judge_pole.i18n.get("search_projects_placeholder"),
"value" : filter_query,
"oninput" : (input) => {
filter_query = input.value.trim().toLowerCase();
render_cards();
}
}]
];
if(is_admin){
topbar_actions.push(["button", {
"type" : "button",
"class" : "jp-btn-primary",
"data-i18n" : "btn_new_project",
"onclick" : () => show_project_modal()
}, judge_pole.i18n.get("btn_new_project")]);
}
container = judge_pole.html(["div", { "class" : "jp-view jp-projects-view" }, [
["div", { "class" : "jp-view-topbar" }, [
["h2", { "data-i18n" : "projects_title" }, judge_pole.i18n.get("projects_title")],
["div", { "style" : "display:flex; gap:0.5em; align-items:center;" }, topbar_actions]
]],
cards_wrapper = judge_pole.html(["div", { "class" : "jp-cards-grid", "id" : "jp-projects-cards" }])[0]
]])[0];
render_cards();
return container;
};
const render_cards = () => {
if(!cards_wrapper) return;
cards_wrapper.innerHTML = "";
const db_data = judge_pole.db.get_data();
const projects = db_data.projects;
const users = db_data.users;
const is_admin = judge_pole.session.is_admin();
if(!projects.length){
cards_wrapper.appendChild(judge_pole.html(["p", { "class" : "jp-empty", "data-i18n" : "no_projects" }, judge_pole.i18n.get("no_projects")])[0]);
return;
}
const filtered_projects = projects.filter(project => {
if(!filter_query) return true;
const project_user_ids = project.user_ids || (project.user_id ? [project.user_id] : []);
const authors = users.filter(u => project_user_ids.includes(u.id));
const authors_str = authors.map(a => a.nick.toLowerCase()).join(" ");
const match_name = project.name.toLowerCase().includes(filter_query);
const match_author = authors_str.includes(filter_query);
const match_desc = project.description ? project.description.toLowerCase().includes(filter_query) : false;
const match_tags = project.tags ? project.tags.some(t => t.toLowerCase().includes(filter_query)) : false;
const match_tech = project.technologies ? project.technologies.some(t => t.toLowerCase().includes(filter_query)) : false;
const match_links = project.links ? project.links.some(l => l.name.toLowerCase().includes(filter_query)) : false;
return match_name || match_author || match_desc || match_tags || match_tech || match_links;
});
if(!filtered_projects.length){
cards_wrapper.appendChild(judge_pole.html(["p", { "class" : "jp-empty", "data-i18n" : "no_projects_found" }, judge_pole.i18n.get("no_projects_found")])[0]);
return;
}
filtered_projects.forEach(project => {
const project_user_ids = project.user_ids || (project.user_id ? [project.user_id] : []);
const authors = users.filter(u => project_user_ids.includes(u.id));
const authors_str = authors.length
? authors.map(a => `${a.nick} ${a.type === 'ai' ? '🤖' : '👤'}`).join(", ")
: "Unknown";
const tags_html = project.tags.map(t => ["span", { "class" : "jp-badge jp-badge-tag" }, "#" + t]);
const tech_html = project.technologies.map(t => ["span", { "class" : "jp-badge jp-badge-tech" }, t]);
const links_html = project.links.map(link => ["a", { "href" : link.url, "target" : "_blank", "class" : "jp-tag-link" }, link.name]);
const avatar_element = project.avatar
? ["img", { "src" : project.avatar, "class" : "jp-avatar", "alt" : project.name }]
: ["div", { "class" : "jp-avatar-placeholder" }, (project.name ? project.name.substring(0, 2).toUpperCase() : "</>")];
const card_elements = [];
// Botones sólo si es Admin
if(is_admin){
card_elements.push(["button", {
"type" : "button",
"class" : "jp-card-btn-delete",
"data-i18n" : "btn_delete",
"data-i18n-without" : "true",
"title" : judge_pole.i18n.get("btn_delete"),
"onclick" : () => {
const confirm_msg = judge_pole.i18n.get("confirm_delete_project", { name : project.name });
if(confirm(confirm_msg)){
judge_pole.db.delete_project(project.id);
render_cards();
}
}
}, "✕"]);
card_elements.push(["button", {
"type" : "button",
"class" : "jp-card-btn-edit",
"data-i18n" : "btn_edit",
"data-i18n-without" : "true",
"title" : judge_pole.i18n.get("btn_edit"),
"onclick" : () => show_project_modal(project.id)
}, "✏️"]);
}
card_elements.push(
avatar_element,
["h3", {}, project.name],
["h4", { "class" : "jp-author-tag" }, "By: " + authors_str],
["p", { "class" : "jp-desc" }, project.description],
["div", { "class" : "jp-tags-group" }, tags_html],
["div", { "class" : "jp-tags-group" }, tech_html],
["div", { "class" : "jp-tags-group" }, links_html],
["div", { "class" : "jp-meta" }, [
["small", {}, judge_pole.i18n.get("created_at") + " " + project.created_at.substring(0, 10)],
["small", {}, judge_pole.i18n.get("updated_at") + " " + project.updated_at.substring(0, 10)]
]]
);
const card = judge_pole.html(["div", { "class" : "jp-card" }, card_elements])[0];
cards_wrapper.appendChild(card);
});
};
const show_project_modal = (project_id = null) => {
const db_data = judge_pole.db.get_data();
const users = db_data.users;
const current_project = project_id
? db_data.projects.find(p => p.id === project_id)
: null;
let links_container = null;
const add_link_row = (name = "", url = "") => {
const row = judge_pole.html(["div", { "class" : "jp-link-row" }, [
["input", { "type" : "text", "placeholder" : "Nombre (Repo, Demo...)", "class" : "link-name", "value" : name }],
["input", { "type" : "url", "placeholder" : "https://...", "class" : "link-url", "value" : url }],
["button", {
"type" : "button",
"class" : "jp-btn-danger",
"onclick" : (btn) => btn.parentNode.remove()
}, "✕"]
]])[0];
links_container.appendChild(row);
};
const close_modal = () => {
if(modal_overlay && modal_overlay.parentNode)
modal_overlay.remove();
};
const modal_title = current_project
? judge_pole.i18n.get("modal_edit_project_title", { name : current_project.name })
: judge_pole.i18n.get("modal_new_project_title");
// 1. Overlay base
const modal_overlay = judge_pole.html(["div", {
"class" : "jp-modal-overlay",
"onclick" : (overlay, e) => {
if(e.target === overlay) close_modal();
}
}, [
["div", { "class" : "jp-modal-window" }, [
["div", { "class" : "jp-modal-header" }, [
["h3", {}, modal_title],
["button", { "type" : "button", "class" : "jp-btn-danger", "onclick" : () => close_modal() }, "✕"]
]],
["div", { "class" : "jp-modal-body" }]
]]
]])[0];
const modal_body = modal_overlay.querySelector(".jp-modal-body");
// 2. Construcción de la caja de autores (Picker)
const current_author_ids = current_project
? (current_project.user_ids || (current_project.user_id ? [current_project.user_id] : []))
: [];
const authors_picker = judge_pole.html(["div", { "class" : "jp-authors-picker" }, [
["input", {
"type" : "search",
"class" : "jp-authors-search-input",
"data-i18n" : "search_authors_placeholder",
"data-i18n-without" : "true",
"placeholder" : judge_pole.i18n.get("search_authors_placeholder"),
"oninput" : (input) => {
const query = input.value.trim().toLowerCase();
authors_list_box.querySelectorAll(".jp-check-label").forEach(label => {
const nick = label.getAttribute("data-nick") || "";
label.style.display = (!query || nick.includes(query)) ? "flex" : "none";
});
}
}],
["div", { "class" : "jp-authors-select-box" }]
]])[0];
const authors_list_box = authors_picker.querySelector(".jp-authors-select-box");
users.forEach(user => {
const is_checked = current_author_ids.includes(user.id);
const is_ai = user.type === "ai";
const user_label_text = (is_ai ? "🤖 " : "👤 ") + user.nick;
const row = judge_pole.html(["label", {
"class" : "jp-check-label",
"data-nick" : user.nick.toLowerCase()
}, [
["input", {
"type" : "checkbox",
"name" : "author_ids",
"value" : user.id,
...(is_checked ? { "checked" : "checked" } : {})
}],
user_label_text
]])[0];
authors_list_box.appendChild(row);
});
// 3. Formulario principal
const form = judge_pole.html(["form", {
"onsubmit" : (form_element, event) => {
event.preventDefault();
const selected_authors = [];
form_element.querySelectorAll("[name='author_ids']:checked").forEach(input => {
selected_authors.push(input.value);
});
if(!selected_authors.length){
alert(judge_pole.i18n.get("no_authors_selected"));
return;
}
const links = [];
links_container.querySelectorAll(".jp-link-row").forEach(row => {
const name = row.querySelector(".link-name").value.trim();
const url = row.querySelector(".link-url").value.trim();
if(name && url) links.push({ name : name, url : url });
});
const tags_raw = form_element.querySelector("[name='tags']").value;
const tech_raw = form_element.querySelector("[name='technologies']").value;
const project_payload = {
user_ids : selected_authors,
name : form_element.querySelector("[name='name']").value.trim(),
avatar : form_element.querySelector("[name='avatar']").value.trim(),
description : form_element.querySelector("[name='description']").value.trim(),
tags : tags_raw.split(",").map(t => t.trim()).filter(t => t),
technologies : tech_raw.split(",").map(t => t.trim()).filter(t => t),
links : links
};
if(current_project) {
judge_pole.db.update_project(current_project.id, project_payload);
} else {
judge_pole.db.add_project(project_payload);
}
close_modal();
render_cards();
}
}, [
["div", { "class" : "jp-field jp-field-authors" }, [
["label", {"data-i18n" : "project_authors"}, judge_pole.i18n.get("project_authors")]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "project_name"}, judge_pole.i18n.get("project_name")],
["input", {
"type" : "text",
"name" : "name",
"required" : "required",
"value" : current_project ? current_project.name : ""
}]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "project_avatar"}, judge_pole.i18n.get("project_avatar")],
["input", {
"type" : "text",
"name" : "avatar",
"placeholder" : "./data/projects/logo.png",
"value" : current_project ? current_project.avatar : ""
}]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "project_desc"}, judge_pole.i18n.get("project_desc")],
["textarea", { "name" : "description", "rows" : "3" }, current_project ? current_project.description : ""]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "project_tags"}, judge_pole.i18n.get("project_tags")],
["input", {
"type" : "text",
"name" : "tags",
"placeholder" : "retro, troll, shader",
"value" : current_project ? current_project.tags.join(", ") : ""
}]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "project_tech"}, judge_pole.i18n.get("project_tech")],
["input", {
"type" : "text",
"name" : "technologies",
"placeholder" : "C++, Raylib, WebGL",
"value" : current_project ? current_project.technologies.join(", ") : ""
}]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "project_links"}, judge_pole.i18n.get("project_links")],
links_container = judge_pole.html(["div", { "class" : "jp-links-list" }])[0],
["button", { "type" : "button", "class" : "jp-btn-secondary", "data-i18n" : "btn_add_link", "onclick" : () => add_link_row() }, judge_pole.i18n.get("btn_add_link")]
]],
["div", { "style" : "display:flex; justify-content:flex-end; gap:0.5em; margin-top:1em;" }, [
["button", { "type" : "button", "class" : "jp-btn", "data-i18n" : "btn_cancel_edit", "onclick" : () => close_modal() }, judge_pole.i18n.get("btn_cancel_edit")],
["button", {
"type" : "submit",
"class" : "jp-btn-primary",
"data-i18n" : current_project ? "btn_update_project" : "btn_save_project"
},
current_project ? judge_pole.i18n.get("btn_update_project") : judge_pole.i18n.get("btn_save_project")
]
]]
]])[0];
// 4. Inyección manual de authors_picker en su campo del form
const authors_field = form.querySelector(".jp-field-authors");
authors_field.appendChild(authors_picker);
// 5. Inyección del form en el modal body
modal_body.appendChild(form);
if(current_project && current_project.links && current_project.links.length) {
current_project.links.forEach(link => add_link_row(link.name, link.url));
}
judge_pole.item_self.appendChild(modal_overlay);
};
};
return ProjectsView;
})();

View File

@ -0,0 +1,289 @@
"use strict";
const UsersView = (function(){
const UsersView = function(judge_pole){
const self = this;
let container = null;
let cards_wrapper = null;
let filter_query = "";
this.go = () => {
judge_pole.content_area.innerHTML = "";
judge_pole.html(judge_pole.content_area, build());
};
const build = () => {
const is_admin = judge_pole.session.is_admin();
const topbar_actions = [
["input", {
"type" : "search",
"class" : "jp-search-input",
"data-i18n" : "search_users_placeholder",
"data-i18n-without" : "true",
"placeholder" : judge_pole.i18n.get("search_users_placeholder"),
"value" : filter_query,
"oninput" : (input) => {
filter_query = input.value.trim().toLowerCase();
render_cards();
}
}]
];
// Sólo añadimos el botón si es admin
if(is_admin){
topbar_actions.push(["button", {
"type" : "button",
"class" : "jp-btn-primary",
"data-i18n" : "btn_new_user",
"onclick" : () => show_user_modal()
}, judge_pole.i18n.get("btn_new_user")]);
}
container = judge_pole.html(["div", { "class" : "jp-view jp-users-view" }, [
["div", { "class" : "jp-view-topbar" }, [
["h2", { "data-i18n" : "users_title" }, judge_pole.i18n.get("users_title")],
["div", { "style" : "display:flex; gap:0.5em; align-items:center;" }, topbar_actions]
]],
cards_wrapper = judge_pole.html(["div", { "class" : "jp-cards-grid", "id" : "jp-users-cards" }])[0]
]])[0];
render_cards();
return container;
};
const render_cards = () => {
if(!cards_wrapper) return;
cards_wrapper.innerHTML = "";
const users = judge_pole.db.get_data().users;
const is_admin = judge_pole.session.is_admin();
if(!users.length){
cards_wrapper.appendChild(judge_pole.html(["p", { "class" : "jp-empty", "data-i18n" : "no_users" }, judge_pole.i18n.get("no_users")])[0]);
return;
}
const filtered_users = users.filter(user => {
if(!filter_query) return true;
const match_nick = user.nick.toLowerCase().includes(filter_query);
const match_desc = user.description ? user.description.toLowerCase().includes(filter_query) : false;
const match_links = user.links ? user.links.some(l => l.name.toLowerCase().includes(filter_query)) : false;
return match_nick || match_desc || match_links;
});
if(!filtered_users.length){
cards_wrapper.appendChild(judge_pole.html(["p", { "class" : "jp-empty", "data-i18n" : "no_users_found" }, judge_pole.i18n.get("no_users_found")])[0]);
return;
}
filtered_users.forEach(user => {
const links_html = user.links.map(link => ["a", { "href" : link.url, "target" : "_blank", "class" : "jp-tag-link" }, link.name]);
const is_ai = user.type === "ai";
const type_badge = ["span", {
"class" : "jp-badge " + (is_ai ? "jp-badge-type-ai" : "jp-badge-type-human")
}, is_ai ? "🤖 IA" : "👤 Humano"];
const avatar_element = user.avatar
? ["img", { "src" : user.avatar, "class" : "jp-avatar", "alt" : user.nick }]
: ["div", { "class" : "jp-avatar-placeholder" }, (user.nick ? user.nick[0].toUpperCase() : (is_ai ? "🤖" : "👤"))];
const card_elements = [];
// Botones sólo para Administradores
if(is_admin){
card_elements.push(["button", {
"type" : "button",
"class" : "jp-card-btn-delete",
"data-i18n" : "btn_delete",
"data-i18n-without" : "true",
"title" : judge_pole.i18n.get("btn_delete"),
"onclick" : () => {
const confirm_msg = judge_pole.i18n.get("confirm_delete_user", { nick : user.nick });
if(confirm(confirm_msg)){
judge_pole.db.delete_user(user.id);
render_cards();
}
}
}, "✕"]);
card_elements.push(["button", {
"type" : "button",
"class" : "jp-card-btn-edit",
"data-i18n" : "btn_edit",
"data-i18n-without" : "true",
"title" : judge_pole.i18n.get("btn_edit"),
"onclick" : () => show_user_modal(user.id)
}, "✏️"]);
}
card_elements.push(
avatar_element,
["div", { "style" : "display:flex; justify-content:space-between; align-items:center; margin-bottom:0.3em;" }, [
["h3", { "style" : "margin:0;" }, user.nick],
type_badge
]],
["p", { "class" : "jp-desc" }, user.description],
["div", { "class" : "jp-tags-group" }, links_html],
["div", { "class" : "jp-meta" }, [
["small", {}, judge_pole.i18n.get("created_at") + " " + user.created_at.substring(0, 10)],
["small", {}, judge_pole.i18n.get("updated_at") + " " + user.updated_at.substring(0, 10)]
]]
);
const card = judge_pole.html(["div", { "class" : "jp-card" }, card_elements])[0];
cards_wrapper.appendChild(card);
});
};
const show_user_modal = (user_id = null) => {
const current_user = user_id
? judge_pole.db.get_data().users.find(u => u.id === user_id)
: null;
let links_container = null;
const add_link_row = (name = "", url = "") => {
const row = judge_pole.html(["div", { "class" : "jp-link-row" }, [
["input", { "type" : "text", "placeholder" : "Nombre (ej: GitHub)", "class" : "link-name", "value" : name }],
["input", { "type" : "url", "placeholder" : "https://...", "class" : "link-url", "value" : url }],
["button", {
"type" : "button",
"class" : "jp-btn-danger",
"onclick" : (btn) => btn.parentNode.remove()
}, "✕"]
]])[0];
links_container.appendChild(row);
};
const close_modal = () => {
if(modal_overlay && modal_overlay.parentNode)
modal_overlay.remove();
};
const modal_title = current_user
? judge_pole.i18n.get("modal_edit_user_title", { nick : current_user.nick })
: judge_pole.i18n.get("modal_new_user_title");
const modal_overlay = judge_pole.html(["div", {
"class" : "jp-modal-overlay",
"onclick" : (overlay, e) => {
if(e.target === overlay) close_modal();
}
}, [
["div", { "class" : "jp-modal-window" }, [
["div", { "class" : "jp-modal-header" }, [
["h3", {}, modal_title],
["button", { "type" : "button", "class" : "jp-btn-danger", "onclick" : () => close_modal() }, "✕"]
]],
["div", { "class" : "jp-modal-body" }]
]]
]])[0];
const modal_body = modal_overlay.querySelector(".jp-modal-body");
const form = judge_pole.html(["form", {
"onsubmit" : (form_element, event) => {
event.preventDefault();
const links = [];
links_container.querySelectorAll(".jp-link-row").forEach(row => {
const name = row.querySelector(".link-name").value.trim();
const url = row.querySelector(".link-url").value.trim();
if(name && url) links.push({ name : name, url : url });
});
const select_type = form_element.querySelector("[name='type']");
const pass_input = form_element.querySelector("[name='password']"); // <--- Extraer password
const user_payload = {
type : select_type ? select_type.value : "human",
nick : form_element.querySelector("[name='nick']").value.trim(),
password : pass_input ? pass_input.value.trim() : "", // <--- Incluir en payload
description : form_element.querySelector("[name='description']").value.trim(),
avatar : form_element.querySelector("[name='avatar']").value.trim(),
links : links
};
if(current_user) {
judge_pole.db.update_user(current_user.id, user_payload);
} else {
judge_pole.db.add_user(user_payload);
}
close_modal();
judge_pole.render_header(); // <--- Forzar refresco del header por si cambió el estado de seguridad
render_cards();
}
}, [
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "user_type"}, judge_pole.i18n.get("user_type")],
["select", { "name" : "type" }, [
["option", { "value" : "human", "data-i18n" : "user_type_human", ...(current_user && current_user.type === "human" ? { "selected" : "selected" } : {}) }, judge_pole.i18n.get("user_type_human")],
["option", { "value" : "ai", "data-i18n" : "user_type_ai", ...(current_user && current_user.type === "ai" ? { "selected" : "selected" } : {}) }, judge_pole.i18n.get("user_type_ai")]
]]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "user_nick"}, judge_pole.i18n.get("user_nick")],
["input", {
"type" : "text",
"name" : "nick",
"required" : "required",
"value" : current_user ? current_user.nick : ""
}]
]],
["div", { "class" : "jp-field" }, [
["label", { "data-i18n" : "user_pass_label" }, judge_pole.i18n.get("user_pass_label")],
["input", {
"type" : "password",
"name" : "password",
"placeholder" : judge_pole.i18n.get("user_pass_placeholder"),
"value" : current_user ? (current_user.password || "") : ""
}]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "user_avatar"}, judge_pole.i18n.get("user_avatar")],
["input", {
"type" : "text",
"name" : "avatar",
"placeholder" : "./data/avatars/yo.png",
"value" : current_user ? current_user.avatar : ""
}]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "user_desc"}, judge_pole.i18n.get("user_desc")],
["textarea", { "name" : "description", "rows" : "3" }, current_user ? current_user.description : ""]
]],
["div", { "class" : "jp-field" }, [
["label", {"data-i18n" : "user_links"}, judge_pole.i18n.get("user_links")],
links_container = judge_pole.html(["div", { "class" : "jp-links-list" }])[0],
["button", { "type" : "button", "class" : "jp-btn-secondary", "data-i18n" : "btn_add_link", "onclick" : () => add_link_row() }, judge_pole.i18n.get("btn_add_link")]
]],
["div", { "style" : "display:flex; justify-content:flex-end; gap:0.5em; margin-top:1em;" }, [
["button", { "type" : "button", "class" : "jp-btn", "data-i18n" : "btn_cancel_edit", "onclick" : () => close_modal() }, judge_pole.i18n.get("btn_cancel_edit")],
["button", {
"type" : "submit",
"class" : "jp-btn-primary",
"data-i18n" : current_user ? "btn_update_user" : "btn_save_user"
},
current_user ? judge_pole.i18n.get("btn_update_user") : judge_pole.i18n.get("btn_save_user")
]
]]
]])[0];
modal_body.appendChild(form);
if(current_user && current_user.links && current_user.links.length) {
current_user.links.forEach(link => add_link_row(link.name, link.url));
}
judge_pole.item_self.appendChild(modal_overlay);
};
};
return UsersView;
})();

View File

@ -0,0 +1,785 @@
"use strict";
const VotesView = (function(){
const VotesView = function(judge_pole){
const self = this;
let container = null;
let rank_wrapper = null;
let filter_query = "";
let expanded_project_id = null;
this.go = () => {
judge_pole.content_area.innerHTML = "";
judge_pole.html(judge_pole.content_area, build());
};
// Extrae las puntuaciones y pesos normalizados (con retrocompatibilidad)
const normalize_project_data = (raw_data) => {
if(!raw_data) return null;
// Si el objeto fue guardado con la nueva estructura
if(raw_data.scores !== undefined){
return {
scores : raw_data.scores || {},
judge_weights : raw_data.judge_weights || {},
criteria_weights : raw_data.criteria_weights || {}
};
}
// Compatibilidad hacia atrás
return {
scores : raw_data,
judge_weights : {},
criteria_weights : {}
};
};
// Calcula métricas naturales y ponderadas completas
const calculate_project_metrics = (project_id) => {
const raw_entry = judge_pole.db.get_data().votes[project_id];
const pack = normalize_project_data(raw_entry);
if(!pack) return null;
const { scores, judge_weights, criteria_weights } = pack;
const user_ids = Object.keys(scores);
if(!user_ids.length) return null;
const criteria_stats = {};
let natural_sum = 0, natural_count = 0;
let weighted_sum = 0, total_weight_mass = 0;
user_ids.forEach(u_id => {
const u_weight = parseInt(judge_weights[u_id]) || 20;
const u_scores = scores[u_id];
Object.keys(u_scores).forEach(crit => {
const val = u_scores[crit];
const c_weight = parseInt(criteria_weights[crit]) || 20;
if(!criteria_stats[crit]) {
criteria_stats[crit] = {
nat_sum : 0,
nat_count : 0,
pond_sum : 0,
pond_weights : 0
};
}
// Natural por criterio
criteria_stats[crit].nat_sum += val;
criteria_stats[crit].nat_count++;
// Ponderado de criterio según peso de juez
criteria_stats[crit].pond_sum += (val * u_weight);
criteria_stats[crit].pond_weights += u_weight;
// Globales
natural_sum += val;
natural_count++;
const joint_weight = u_weight * c_weight;
weighted_sum += (val * joint_weight);
total_weight_mass += joint_weight;
});
});
const criteria_averages = {};
Object.keys(criteria_stats).forEach(crit => {
const stat = criteria_stats[crit];
criteria_averages[crit] = {
natural : stat.nat_count > 0 ? (stat.nat_sum / stat.nat_count).toFixed(2) : "0.00",
weighted : stat.pond_weights > 0 ? (stat.pond_sum / stat.pond_weights).toFixed(2) : "0.00"
};
});
return {
natural_avg : natural_count > 0 ? (natural_sum / natural_count).toFixed(2) : "0.00",
weighted_avg : total_weight_mass > 0 ? (weighted_sum / total_weight_mass).toFixed(2) : "0.00",
criteria : criteria_averages
};
};
const build = () => {
const is_admin = judge_pole.session.is_admin();
const topbar_actions = [
["input", {
"type" : "search",
"class" : "jp-search-input",
"data-i18n" : "search_rankings_placeholder",
"data-i18n-without" : "true",
"placeholder" : judge_pole.i18n.get("search_rankings_placeholder"),
"value" : filter_query,
"oninput" : (input) => {
filter_query = input.value.trim().toLowerCase();
render_rankings();
}
}]
];
if(is_admin){
topbar_actions.push(["button", {
"type" : "button",
"class" : "jp-btn-primary",
"data-i18n" : "btn_new_vote",
"onclick" : () => show_vote_modal()
}, judge_pole.i18n.get("btn_new_vote")]);
}
container = judge_pole.html(["div", { "class" : "jp-view jp-votes-view" }, [
["div", { "class" : "jp-view-topbar" }, [
["h2", { "data-i18n" : "votes_title" }, judge_pole.i18n.get("votes_title")],
["div", { "style" : "display:flex; gap:0.5em; align-items:center;" }, topbar_actions]
]],
rank_wrapper = judge_pole.html(["div", { "class" : "jp-rank-table-wrapper" }])[0]
]])[0];
render_rankings();
return container;
};
const render_rankings = () => {
if(!rank_wrapper) return;
rank_wrapper.innerHTML = "";
const db_data = judge_pole.db.get_data();
const projects = db_data.projects;
const is_admin = judge_pole.session.is_admin();
const scored_projects = [];
projects.forEach(project => {
const metrics = calculate_project_metrics(project.id);
if(metrics){
scored_projects.push({
project : project,
natural_avg : metrics.natural_avg,
weighted_avg : metrics.weighted_avg,
weighted_num : parseFloat(metrics.weighted_avg),
criteria : metrics.criteria
});
}
});
scored_projects.sort((a, b) => b.weighted_num - a.weighted_num);
scored_projects.forEach((item, index) => {
item.global_rank = index + 1;
});
if(!scored_projects.length){
rank_wrapper.appendChild(judge_pole.html(["p", { "class" : "jp-empty", "data-i18n" : "no_votes_registered" }, judge_pole.i18n.get("no_votes_registered")])[0]);
return;
}
const filtered_list = scored_projects.filter(item => {
if(!filter_query) return true;
return item.project.name.toLowerCase().includes(filter_query);
});
// Cabeceras de tabla: la columna acciones sólo se muestra si es admin
const thead_cols = [
["th", { "data-i18n" : "rank_global" }, judge_pole.i18n.get("rank_global")],
["th", { "data-i18n" : "rank_filtered" }, judge_pole.i18n.get("rank_filtered")],
["th", { "data-i18n" : "rank_project" }, judge_pole.i18n.get("rank_project")],
["th", { "data-i18n" : "rank_average" }, judge_pole.i18n.get("rank_average")]
];
if(is_admin){
thead_cols.push(["th", { "data-i18n" : "rank_actions", "style" : "text-align:right;" }, judge_pole.i18n.get("rank_actions")]);
}
const table = judge_pole.html(["table", { "class" : "jp-rank-table" }, [
["thead", {}, [
["tr", {}, thead_cols]
]],
["tbody", {}]
]])[0];
const tbody = table.querySelector("tbody");
filtered_list.forEach((item, filter_idx) => {
const is_expanded = expanded_project_id === item.project.id;
const row_cols = [
["td", { "class" : "jp-rank-badge-global" }, "#" + item.global_rank],
["td", { "class" : "jp-rank-badge-filter" }, "#" + (filter_idx + 1)],
["td", { "style" : "font-weight:bold;" }, item.project.name],
["td", {}, [
["span", { "class" : "jp-rank-avg-score" }, item.weighted_avg + " pts "],
["small", { "style" : "color:#8b949e;" }, "(Nat: " + item.natural_avg + ")"]
]]
];
// Celda de acciones sólo si es admin
if(is_admin){
row_cols.push(["td", { "style" : "text-align:right;" }, [
["button", {
"type" : "button",
"class" : "jp-btn",
"style" : "margin-right:0.4em; padding:0.2em 0.5em;",
"data-i18n" : "btn_edit",
"data-i18n-without" : "true",
"title" : judge_pole.i18n.get("btn_edit"),
"onclick" : () => show_vote_modal(item.project.id)
}, "✏️"],
["button", {
"type" : "button",
"class" : "jp-btn-danger",
"style" : "padding:0.2em 0.5em;",
"data-i18n" : "btn_delete",
"data-i18n-without" : "true",
"title" : judge_pole.i18n.get("btn_delete"),
"onclick" : () => {
const msg = judge_pole.i18n.get("confirm_delete_vote", { project : item.project.name });
if(confirm(msg)){
delete db_data.votes[item.project.id];
if(expanded_project_id === item.project.id) expanded_project_id = null;
render_rankings();
}
}
}, "✕"]
]]);
}
const tr = judge_pole.html(["tr", {
"onclick" : (row, event) => {
if(event.target.closest("button")) return;
expanded_project_id = is_expanded ? null : item.project.id;
render_rankings();
}
}, row_cols])[0];
tbody.appendChild(tr);
// Desglose expandido (visible para todos en solo lectura)
if(is_expanded){
const breakdown_items = Object.keys(item.criteria).map(crit => ["div", { "class" : "jp-breakdown-item" }, [
["span", { "class" : "jp-crit-name" }, crit],
["div", { "style" : "display:flex; gap:0.4em;" }, [
["span", { "style" : "color:#8b949e;" }, "N: " + item.criteria[crit].natural],
["span", { "class" : "jp-crit-val" }, "P: " + item.criteria[crit].weighted]
]]
]]);
const raw_pack = normalize_project_data(db_data.votes[item.project.id]) || { scores:{}, judge_weights:{}, criteria_weights:{} };
const participating_users = db_data.users.filter(u => Object.keys(raw_pack.scores).includes(u.id));
const active_criteria_keys = Object.keys(item.criteria);
const static_table = judge_pole.html(["table", { "class" : "jp-matrix-table", "style" : "margin-top:0.8em;" }, [
["thead", {}, [
["tr", {}, [
["th", { "style" : "text-align:left;" }, "Criterio"]
].concat(participating_users.map(u => [
"th", {}, [
["div", {}, (u.type === 'ai' ? '🤖 ' : '👤 ') + u.nick],
["small", { "style" : "color:#f1e05a;" }, "(W: " + (raw_pack.judge_weights[u.id] || 20) + ")"]
]
])).concat([
["th", { "class" : "jp-summary-cell" }, "Media Nat."],
["th", { "class" : "jp-summary-cell", "style" : "color:#f1e05a;" }, "Media Pond."]
])]
]],
["tbody", {}]
]])[0];
const static_tbody = static_table.querySelector("tbody");
active_criteria_keys.forEach(crit => {
const crit_w = raw_pack.criteria_weights[crit] || 20;
const cells = [
["td", { "class" : "jp-crit-cell" }, [
["span", {}, crit + " "],
["small", { "style" : "color:#f1e05a;" }, "(W: " + crit_w + ")"]
]]
];
participating_users.forEach(u => {
const score = (raw_pack.scores[u.id] && raw_pack.scores[u.id][crit] !== undefined)
? raw_pack.scores[u.id][crit]
: "-";
cells.push(["td", { "style" : "font-weight:bold;" }, "" + score]);
});
cells.push(["td", { "class" : "jp-summary-subcell" }, item.criteria[crit].natural]);
cells.push(["td", { "class" : "jp-summary-subcell", "style" : "color:#f1e05a;" }, item.criteria[crit].weighted]);
static_tbody.appendChild(judge_pole.html(["tr", {}, cells])[0]);
});
const col_count = is_admin ? "5" : "4";
const tr_detail = judge_pole.html(["tr", { "class" : "jp-breakdown-row" }, [
["td", { "colspan" : col_count }, [
["div", { "style" : "font-size:0.75em; color:#8b949e;", "data-i18n" : "rank_criteria_breakdown" }, judge_pole.i18n.get("rank_criteria_breakdown")],
["div", { "class" : "jp-breakdown-grid" }, breakdown_items],
static_table
]]
]])[0];
tbody.appendChild(tr_detail);
}
});
rank_wrapper.appendChild(table);
};
const show_vote_modal = (target_project_id = null) => {
const db_data = judge_pole.db.get_data();
let selected_p_id = target_project_id || (db_data.projects[0] ? db_data.projects[0].id : null);
const existing_pack = normalize_project_data(db_data.votes[selected_p_id]) || {
scores : {},
judge_weights : {},
criteria_weights : {}
};
let chosen_judges = Object.keys(existing_pack.scores).length
? Object.keys(existing_pack.scores)
: db_data.users.map(u => u.id);
let chosen_criteria = [...db_data.criteria];
// Pesos en memoria
const active_judge_weights = { ...existing_pack.judge_weights };
const active_criteria_weights = { ...existing_pack.criteria_weights };
const close_modal = () => {
if(modal_overlay && modal_overlay.parentNode)
modal_overlay.remove();
};
const modal_overlay = judge_pole.html(["div", {
"class" : "jp-modal-overlay",
"onclick" : (overlay, e) => {
if(e.target === overlay) close_modal();
}
}, [
["div", { "class" : "jp-modal-window", "style" : "max-width:50em;" }, [
["div", { "class" : "jp-modal-header" }, [
["h3", {}, target_project_id ? judge_pole.i18n.get("modal_edit_vote_title", { project : db_data.projects.find(p => p.id === target_project_id).name }) : judge_pole.i18n.get("modal_new_vote_title")],
["button", { "type" : "button", "class" : "jp-btn-danger", "onclick" : () => close_modal() }, "✕"]
]],
["div", { "class" : "jp-modal-body" }]
]]
]])[0];
const modal_body = modal_overlay.querySelector(".jp-modal-body");
// --- 1. Selector de Proyecto ---
const project_options = db_data.projects.map(p => ["option", {
"value" : p.id,
...(p.id === selected_p_id ? { "selected" : "selected" } : {})
}, p.name]);
const project_select = judge_pole.html(["div", { "class" : "jp-field" }, [
["label", { "data-i18n" : "select_project" }, judge_pole.i18n.get("select_project")],
["select", {
"onchange" : (sel) => {
selected_p_id = sel.value;
rebuild_matrix();
}
}, project_options]
]])[0];
// --- 2. Picker de Jueces con Pesos Numéricos ---
const judges_picker = judge_pole.html(["div", { "class" : "jp-field" }, [
["label", { "data-i18n" : "participating_judges" }, judge_pole.i18n.get("participating_judges")],
["div", { "class" : "jp-authors-picker" }, [
["input", {
"type" : "search",
"class" : "jp-authors-search-input",
"data-i18n" : "search_authors_placeholder",
"data-i18n-without" : "true",
"placeholder" : judge_pole.i18n.get("search_authors_placeholder"),
"oninput" : (input) => {
const q = input.value.trim().toLowerCase();
judges_list_box.querySelectorAll(".jp-criterion-row").forEach(lbl => {
const nick = lbl.getAttribute("data-name") || "";
lbl.style.display = (!q || nick.includes(q)) ? "flex" : "none";
});
}
}],
["div", { "class" : "jp-authors-select-box" }]
]]
]])[0];
const judges_list_box = judges_picker.querySelector(".jp-authors-select-box");
db_data.users.forEach(user => {
const is_checked = chosen_judges.includes(user.id);
const current_w = active_judge_weights[user.id] !== undefined ? active_judge_weights[user.id] : 20;
active_judge_weights[user.id] = current_w;
const row = judge_pole.html(["div", {
"class" : "jp-criterion-row",
"data-name" : user.nick.toLowerCase()
}, [
["label", { "class" : "jp-check-label", "style" : "flex:1; cursor:pointer;" }, [
["input", {
"type" : "checkbox",
"value" : user.id,
...(is_checked ? { "checked" : "checked" } : {}),
"onchange" : (chk) => {
// Buscamos el input de peso relativo a esta fila de forma segura
const row_parent = chk.closest(".jp-criterion-row");
const input_target = row_parent ? row_parent.querySelector(".jp-weight-input") : null;
if(input_target) input_target.disabled = !chk.checked;
if(chk.checked) chosen_judges.push(user.id);
else chosen_judges = chosen_judges.filter(id => id !== user.id);
rebuild_matrix();
}
}],
(user.type === "ai" ? "🤖 " : "👤 ") + user.nick
]],
["div", { "style" : "display:flex; align-items:center;" }, [
["small", { "style" : "color:#8b949e; font-size:0.7em;", "data-i18n" : "weight_label" }, judge_pole.i18n.get("weight_label")],
["input", {
"type" : "number",
"min" : "1",
"step" : "1",
"class" : "jp-weight-input",
"value" : current_w,
...(!is_checked ? { "disabled" : "disabled" } : {}),
"oninput" : (inp) => {
const val = parseInt(inp.value) || 1;
active_judge_weights[user.id] = Math.max(1, Math.floor(val));
rebuild_matrix();
}
}]
]]
]])[0];
judges_list_box.appendChild(row);
});
// --- 3. Picker de Criterios con Pesos Numéricos ---
const criteria_picker = judge_pole.html(["div", { "class" : "jp-field" }, [
["div", { "style" : "display:flex; justify-content:space-between; align-items:center;" }, [
["label", { "data-i18n" : "evaluation_criteria" }, judge_pole.i18n.get("evaluation_criteria")],
["button", {
"type" : "button",
"class" : "jp-btn-secondary",
"style" : "padding:0.2em 0.5em; font-size:0.7em;",
"data-i18n" : "btn_new_criterion",
"onclick" : () => {
const new_c = prompt(judge_pole.i18n.get("new_criterion_prompt"));
if(new_c && judge_pole.db.add_criterion(new_c)){
chosen_criteria.push(new_c.trim());
active_criteria_weights[new_c.trim()] = 20;
render_criteria_rows();
rebuild_matrix();
}
}
}, judge_pole.i18n.get("btn_new_criterion")]
]],
["div", { "class" : "jp-authors-picker" }, [
["input", {
"type" : "search",
"class" : "jp-authors-search-input",
"data-i18n" : "search_criteria_placeholder",
"data-i18n-without" : "true",
"placeholder" : judge_pole.i18n.get("search_criteria_placeholder"),
"oninput" : (input) => {
const q = input.value.trim().toLowerCase();
criteria_list_box.querySelectorAll(".jp-criterion-row").forEach(lbl => {
const name = lbl.getAttribute("data-name") || "";
lbl.style.display = (!q || name.includes(q)) ? "flex" : "none";
});
}
}],
["div", { "class" : "jp-authors-select-box" }]
]]
]])[0];
const criteria_list_box = criteria_picker.querySelector(".jp-authors-select-box");
const render_criteria_rows = () => {
criteria_list_box.innerHTML = "";
db_data.criteria.forEach(crit => {
const is_checked = chosen_criteria.includes(crit);
const current_w = active_criteria_weights[crit] !== undefined ? active_criteria_weights[crit] : 20;
active_criteria_weights[crit] = current_w;
const row = judge_pole.html(["div", {
"class" : "jp-criterion-row",
"data-name" : crit.toLowerCase()
}, [
["label", { "class" : "jp-check-label", "style" : "flex:1; cursor:pointer;" }, [
["input", {
"type" : "checkbox",
"value" : crit,
...(is_checked ? { "checked" : "checked" } : {}),
"onchange" : (chk) => {
const row_parent = chk.closest(".jp-criterion-row");
const input_target = row_parent ? row_parent.querySelector(".jp-weight-input") : null;
if(input_target) input_target.disabled = !chk.checked;
if(chk.checked) chosen_criteria.push(crit);
else chosen_criteria = chosen_criteria.filter(c => c !== crit);
rebuild_matrix();
}
}],
crit
]],
["div", { "class" : "jp-crit-actions", "style" : "display:flex; align-items:center;" }, [
["small", { "style" : "color:#8b949e; font-size:0.7em;", "data-i18n" : "weight_label" }, judge_pole.i18n.get("weight_label")],
["input", {
"type" : "number",
"min" : "1",
"step" : "1",
"class" : "jp-weight-input",
"value" : current_w,
...(!is_checked ? { "disabled" : "disabled" } : {}),
"oninput" : (inp) => {
const val = parseInt(inp.value) || 1;
active_criteria_weights[crit] = Math.max(1, Math.floor(val));
rebuild_matrix();
}
}],
["button", {
"type" : "button",
"class" : "jp-btn",
"style" : "padding:0.1em 0.3em; font-size:0.65em; margin-left:0.4em;",
"title" : judge_pole.i18n.get("btn_edit"),
"onclick" : () => {
const updated_name = prompt(judge_pole.i18n.get("edit_criterion_prompt"), crit);
if(updated_name && judge_pole.db.update_criterion(crit, updated_name)){
const idx = chosen_criteria.indexOf(crit);
if(idx !== -1) chosen_criteria[idx] = updated_name.trim();
active_criteria_weights[updated_name.trim()] = active_criteria_weights[crit];
delete active_criteria_weights[crit];
render_criteria_rows();
rebuild_matrix();
}
}
}, "✏️"],
["button", {
"type" : "button",
"class" : "jp-btn-danger",
"style" : "padding:0.1em 0.3em; font-size:0.65em; margin-left:0.2em;",
"title" : judge_pole.i18n.get("btn_delete"),
"onclick" : () => {
const confirm_msg = judge_pole.i18n.get("confirm_delete_criterion", { criterion : crit });
if(confirm(confirm_msg)){
judge_pole.db.delete_criterion(crit);
chosen_criteria = chosen_criteria.filter(c => c !== crit);
delete active_criteria_weights[crit];
render_criteria_rows();
rebuild_matrix();
}
}
}, "✕"]
]]
]])[0];
criteria_list_box.appendChild(row);
});
};
render_criteria_rows();
// --- 4. Matriz Transpuesta con Doble Medias ---
const matrix_container = judge_pole.html(["div", { "style" : "overflow-x:auto; margin-top:1em;" }])[0];
const rebuild_matrix = () => {
matrix_container.innerHTML = "";
if(!selected_p_id || !chosen_judges.length || !chosen_criteria.length) return;
const current_scores = existing_pack.scores || {};
const participating_users = db_data.users.filter(u => chosen_judges.includes(u.id));
const table = judge_pole.html(["table", { "class" : "jp-matrix-table" }, [
["thead", {}, [
["tr", {}, [
["th", { "style" : "text-align:left;" }, "Criterio (Peso)"]
].concat(participating_users.map(u => [
"th", {}, [
["div", {}, (u.type === 'ai' ? '🤖 ' : '👤 ') + u.nick],
["small", { "style" : "color:#f1e05a;" }, "(W: " + (active_judge_weights[u.id] || 20) + ")"]
]
])).concat([
["th", { "class" : "jp-summary-cell", "data-i18n" : "crit_avg_natural" }, judge_pole.i18n.get("crit_avg_natural")],
["th", { "class" : "jp-summary-cell", "style" : "color:#f1e05a;", "data-i18n" : "crit_avg_weighted" }, judge_pole.i18n.get("crit_avg_weighted")]
])]
]],
["tbody", {}],
["tfoot", {}, [
// Tupla 1: Media Natural de Jueces
["tr", { "class" : "jp-summary-row" }, [
["td", { "class" : "jp-crit-cell", "data-i18n" : "judge_avg_natural" }, judge_pole.i18n.get("judge_avg_natural")]
].concat(participating_users.map(u => ["td", {
"class" : "jp-summary-cell",
"data-judge-nat" : u.id
}, "0.00"])).concat([
["td", { "class" : "jp-summary-cell", "data-total-nat" : "true" }, "0.00"],
["td", { "class" : "jp-summary-cell", "style" : "background:#0d1117;" }, "-"]
])],
// Tupla 2: Media Ponderada de Jueces (según peso de criterios)
["tr", { "class" : "jp-summary-row" }, [
["td", { "class" : "jp-crit-cell", "style" : "color:#f1e05a;", "data-i18n" : "judge_avg_weighted" }, judge_pole.i18n.get("judge_avg_weighted")]
].concat(participating_users.map(u => ["td", {
"class" : "jp-summary-cell",
"style" : "color:#f1e05a;",
"data-judge-pond" : u.id
}, "0.00"])).concat([
["td", { "class" : "jp-summary-cell", "style" : "background:#0d1117;" }, "-"],
["td", { "class" : "jp-summary-cell", "style" : "color:#f1e05a;", "data-total-pond" : "true" }, "0.00"]
])]
]]
]])[0];
const tbody = table.querySelector("tbody");
chosen_criteria.forEach(crit => {
const c_w = active_criteria_weights[crit] || 20;
const row_cells = [
["td", { "class" : "jp-crit-cell" }, [
["span", {}, crit + " "],
["small", { "style" : "color:#f1e05a;" }, "(W: " + c_w + ")"]
]]
];
participating_users.forEach(user => {
const saved_score = (current_scores[user.id] && current_scores[user.id][crit] !== undefined)
? current_scores[user.id][crit]
: 5;
row_cells.push(["td", {}, [
["input", {
"type" : "number",
"min" : "0",
"max" : "10",
"step" : "0.5",
"value" : saved_score,
"class" : "jp-score-input",
"data-judge" : user.id,
"data-crit" : crit,
"oninput" : () => calculate_live_averages(table)
}]
]]);
});
// 2 Columnas de media por criterio: Natural y Ponderada (por peso de juez)
row_cells.push(["td", { "class" : "jp-summary-subcell", "data-crit-nat" : crit }, "0.00"]);
row_cells.push(["td", { "class" : "jp-summary-subcell", "style" : "color:#f1e05a;", "data-crit-pond" : crit }, "0.00"]);
tbody.appendChild(judge_pole.html(["tr", {}, row_cells])[0]);
});
matrix_container.appendChild(table);
calculate_live_averages(table);
};
const calculate_live_averages = (table_element) => {
const inputs = table_element.querySelectorAll(".jp-score-input");
const crit_data = {};
const judge_data = {};
let overall_nat_sum = 0, overall_nat_count = 0;
let overall_pond_sum = 0, overall_pond_mass = 0;
inputs.forEach(input => {
const judge = input.getAttribute("data-judge");
const crit = input.getAttribute("data-crit");
const val = parseFloat(input.value) || 0;
const j_w = active_judge_weights[judge] || 20;
const c_w = active_criteria_weights[crit] || 20;
// Acumuladores de Criterio
if(!crit_data[crit]) crit_data[crit] = { nat_sum : 0, count : 0, pond_sum : 0, j_weight_sum : 0 };
crit_data[crit].nat_sum += val;
crit_data[crit].count++;
crit_data[crit].pond_sum += (val * j_w);
crit_data[crit].j_weight_sum += j_w;
// Acumuladores de Juez
if(!judge_data[judge]) judge_data[judge] = { nat_sum : 0, count : 0, pond_sum : 0, c_weight_sum : 0 };
judge_data[judge].nat_sum += val;
judge_data[judge].count++;
judge_data[judge].pond_sum += (val * c_w);
judge_data[judge].c_weight_sum += c_w;
// Totales globales
overall_nat_sum += val;
overall_nat_count++;
const joint_weight = j_w * c_w;
overall_pond_sum += (val * joint_weight);
overall_pond_mass += joint_weight;
});
// Actualizar columnas por criterio
Object.keys(crit_data).forEach(crit => {
const d = crit_data[crit];
const nat_cell = table_element.querySelector(`[data-crit-nat="${crit}"]`);
const pond_cell = table_element.querySelector(`[data-crit-pond="${crit}"]`);
if(nat_cell) nat_cell.textContent = (d.nat_sum / (d.count || 1)).toFixed(2);
if(pond_cell) pond_cell.textContent = (d.pond_sum / (d.j_weight_sum || 1)).toFixed(2);
});
// Actualizar tuplas por juez
Object.keys(judge_data).forEach(judge => {
const d = judge_data[judge];
const nat_cell = table_element.querySelector(`[data-judge-nat="${judge}"]`);
const pond_cell = table_element.querySelector(`[data-judge-pond="${judge}"]`);
if(nat_cell) nat_cell.textContent = (d.nat_sum / (d.count || 1)).toFixed(2);
if(pond_cell) pond_cell.textContent = (d.pond_sum / (d.c_weight_sum || 1)).toFixed(2);
});
// Esquinas globales
const total_nat_cell = table_element.querySelector(`[data-total-nat="true"]`);
const total_pond_cell = table_element.querySelector(`[data-total-pond="true"]`);
if(total_nat_cell) total_nat_cell.textContent = (overall_nat_sum / (overall_nat_count || 1)).toFixed(2);
if(total_pond_cell) total_pond_cell.textContent = (overall_pond_sum / (overall_pond_mass || 1)).toFixed(2);
};
const actions = judge_pole.html(["div", { "style" : "display:flex; justify-content:flex-end; gap:0.5em; margin-top:1em;" }, [
["button", { "type" : "button", "class" : "jp-btn", "onclick" : () => close_modal() }, judge_pole.i18n.get("btn_cancel_edit")],
["button", {
"type" : "button",
"class" : "jp-btn-primary",
"data-i18n" : "btn_save_scores",
"onclick" : () => {
const scores = {};
matrix_container.querySelectorAll(".jp-score-input").forEach(input => {
const j_id = input.getAttribute("data-judge");
const c_name = input.getAttribute("data-crit");
const val = parseFloat(input.value) || 0;
if(!scores[j_id]) scores[j_id] = {};
scores[j_id][c_name] = Math.max(0, Math.min(10, val));
});
const payload = {
scores : scores,
judge_weights : active_judge_weights,
criteria_weights : active_criteria_weights
};
judge_pole.db.save_votes(selected_p_id, payload);
close_modal();
render_rankings();
}
}, judge_pole.i18n.get("btn_save_scores")]
]])[0];
modal_body.appendChild(project_select);
modal_body.appendChild(judges_picker);
modal_body.appendChild(criteria_picker);
modal_body.appendChild(matrix_container);
modal_body.appendChild(actions);
rebuild_matrix();
judge_pole.item_self.appendChild(modal_overlay);
};
};
return VotesView;
})();

47
Public/index.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="es">
<head>
<title data-i18n="judge_pole">JudgePole</title>
<meta http-equiv="content-type" content="text/html;charset=utf-8" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="xdoc:link" content="https://judgepole.k3y.pw/" />
<meta name="xdoc:git" content="https://git.k3y.pw/KyMAN/JudgePole" />
<meta name="xdoc:authors" content="KyMAN" />
<meta name="xdoc:ai-authors" content="Gemini" />
<meta name="xdoc:since" content="20260919" />
<meta name="xdoc:version" content="20260920" />
<meta name="xdoc:license" content=" Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International" />
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./data/database.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./data/i18n.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./data/settings.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./data/styles.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Utils/Styles.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Utils/Common.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Utils/I18N.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Utils/Settings.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Managers/DatabaseManager.ecma.js" charset="utf-8"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Managers/SettingsManager.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Managers/I18NManager.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script src="./ecma/Managers/SessionManager.ecma.js"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Views/UsersView.ecma.js" charset="utf-8"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Views/ProjectsView.ecma.js" charset="utf-8"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Views/VotesView.ecma.js" charset="utf-8"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" src="./ecma/Application/JudgePole.ecma.js" charset="utf-8" data-crossorigin="anonymous"></script>
<script data-type="text/javascript;charset=utf-8" data-language="ECMAScript 2015" charset="utf-8">
"use strict";
const judge_pole = new JudgePole();
</script>
</head>
<body></body>
</html>

13
Public/md/ai-dev-rules.md Normal file
View File

@ -0,0 +1,13 @@
El proyecto tiene un raíz privado con los archivos `LICENSE`, `README.md` y `.gitignore`; luego tiene un lado público el cual es `/Public`. Dentro de éste un `index.html` y los directorios `data` y `ecma`. En el directorio `data` se meterá todo lo que no se meta en el repositorio de Git, como configuraciones personalizadas, la base de datos, etc. Mientras que los archivos de Script se meten dentro de `ecma` el cual tiene la estructura `Application` para los archivos que funcionen sobre el proyecto de forma diferente o independiente; `Utils` para archivos totalmente autónomos; `Managers` para gestores como `SettingsManager` o `I18NManager`; `Views` para las vistas; y `Models` para los modelos si hicieren falta.
Las reglas de desarrollo son:
- La sangría es de 4 espacios.
- Las variables y nombres de funciones, métodos y atributos son Snake Lower Case; el nombre de las clases es en Pascal Case; las constantes de clase o constantes sueltas estarán en Snake Upper Case.
- El GUI HTML se basa en un sistema que simula DPIs a partir de la unidad de medida `em`, el cual divide el lado más estrecho del GUI en 40 celdas por defecto. `1em` es equivalente a una celda. Cada celda es cuadrada.
- Todas las clases que se generen han de ser Funciones Constructoras. Los métodos y atributos estáticos tienen que ir vinculados al nombre sin `prototype`.
- Todo valor susceptible de ser de configuración ha de estar integrado en el archivo `/Public/ecma/Utils/Settings.ecma.js`.
- En HTML, todos los elementos que contengan textos internacionalizados han de llevar el atributo `data-i18n` con la clave del texto, y en caso de ser campos sin contenido como `input`, `textarea`, etc. Han de llevar a mayores el atributo `data-i18n-without="true"` para indicar que no se añada la traducción a su contenido, sólo a los atributos, si los tuviere, como `title`, `placeholder`, etc.
- Las vistas estará en el directorio `/Public/ecma/Views` y tendrán como nombre `NombreDeVistaView.ecma.js` y nombre de clase función constructora `NombreDeVistaView` y se implementarán en el objeto `judge_pole.views` con el nombre `nombre_de_vista`. Cada vista ha de tener un método `go` para construir y visualizar dicha vista cada vez que se realiza la acción de ir a dicha vista. Cada texto que se muestre tiene que recogerse del método `judge_pole.i18n.get` y sus claves y valores en el archivo `/Public/ecma/Utils/I18N.ecma.js` en cada uno de los idiomas deseados.
- Cualquier HTML que se haga, salvo que no se pueda realizar por dicho método, ha de estar hecho con el método `judge_pole.html`.
- Los estilos han de agregarse por bloques que se deseen al archivo `/Public/ecma/Utils/Styles.ecma.js` en formato del método `Common.SCSS`.

145
README.md
View File

@ -1,3 +1,144 @@
# JudgePole # ⚖️ Judge Pole - Manual de Usuario y Arquitectura
**Judge Pole** es una aplicación web *serverless*, ligera y portátil diseñada para arbitrar y evaluar proyectos durante "vibe coding" jams, hackathons y competiciones de desarrollo.
---
## 🚀 1. Filosofía y Arquitectura Técnica
* **Cero Infraestructura**: No requiere backend, bases de datos SQL/NoSQL ni servicios en la nube.
* **Persistencia Portátil (Base64)**: Todo el estado de la aplicación reside en `/Public/data/database.ecma.js` codificado en una cadena Base64 que contiene un JSON plano.
* **Construcción DOM Nativa**: Construida íntegramente con Vanilla JS mediante el helper `judge_pole.html()`, evitando frameworks pesados y virtual DOMs.
* **Sistema de Virtual DPI (Grid de 40 celdas)**: El diseño escala de manera uniforme adaptando la unidad base `em` al tamaño de la pantalla mediante un cálculo dinámico de `fontSize`.
* **Internacionalización Dinámica**: Soporte reactivo multilingüe basado en atributos `data-i18n` y `data-i18n-without="true"`.
---
## 📁 2. Estructura del Proyecto
```text
judge-pole/
├── Dockerfile # Entorno de compilación SASS
├── build.sh / build.bat # Scripts de compilación de estilos
└── Public/
├── index.html # Punto de entrada HTML
├── data/
│ └── database.ecma.js # Archivo físico de la BDD en Base64
└── ecma/
├── Application/
│ └── JudgePole.ecma.js # Orquestador del ciclo de vida y UI
├── Managers/
│ ├── DatabaseManager.ecma.js # Operaciones CRUD y serialización
│ ├── I18NManager.ecma.js # Motor de traducción
│ ├── SessionManager.ecma.js # Control de acceso y roles
│ └── SettingsManager.ecma.js # Ajustes de visualización y FPS
├── Models/ # Definiciones de datos
├── Utils/
│ ├── Common.ecma.js # Inyección de estilos y helpers
│ ├── I18N.ecma.js # Diccionarios de idiomas
│ └── Styles.ecma.js # Estilos SCSS en JavaScript
└── Views/
├── UsersView.ecma.js # Gestión y filtrado de jueces/creadores
├── ProjectsView.ecma.js # Gestión y autoría de proyectos
└── VotesView.ecma.js # Matriz de notas ponderada y ranking
```
---
## 👥 3. Gestión de Participantes (Usuarios)
La vista de **Usuarios** permite registrar tanto jueces como participantes:
* **Tipos de Usuario**:
* **Humano (`👤`)**: Por defecto.
* **Inteligencia Artificial (`🤖`)**: Modelos LLM o agentes participantes.
* **Avatar Adaptativo**: Si no se define una URL o ruta local, se genera automáticamente un placeholder centrado con las iniciales o el icono de tipo.
* **Buscador Reactivo**: Filtra en tiempo real por nick, biografía o plataformas vinculadas.
* **Seguridad / Contraseña**:
* Puedes definir una contraseña opcional en cualquier participante.
* En cuanto al menos un usuario tenga contraseña, la web bloqueará la edición pública.
---
## 📦 4. Gestión de Proyectos
La vista de **Proyectos** permite administrar los trabajos presentados a la Jam:
* **Coautoría Múltiple**: Los proyectos pueden pertenecer a uno o varios creadores simultáneamente (equipos humanos, equipos de IAs o formatos híbridos).
* **Selector con Scroll y Filtro**: Al crear o editar un proyecto, la lista de autores cuenta con un buscador en vivo y altura restringida para no desbordar el formulario.
* **Metadatos y Enlaces**: Soporta etiquetas (#tags), tecnologías utilizadas y enlaces externos (repositorios, demos, webs).
---
## ⚖️ 5. Tribunal de Evaluación y Ranking
La vista de **Votaciones** es el núcleo de la aplicación:
### 5.1. Ranking en Vivo
* **Posición Doble**: Muestra la posición **Global** (absoluta en la Jam) y la posición en el **Filtro** (según la búsqueda tecleada).
* **Criterio de Orden**: Ordenado oficialmente por la **Media Ponderada**.
* **Acordeón Desplegable**: Al hacer clic sobre cualquier fila del ranking, se despliegan:
* El resumen de medias naturales y ponderadas de cada criterio.
* La **Matriz Completa de Solo Lectura**, visible para cualquier visitante.
### 5.2. Matriz de Puntuaciones Transpuesta
Al pulsar en **"+ Registrar Votación"** o al **Editar (✏️)**:
* **Estructura Transpuesta**:
* **Filas (Tuplas)**: Criterios de evaluación.
* **Columnas**: Jueces participantes.
* **Columnas Finales**: Media Natural y Media Ponderada por Criterio.
* **Filas Inferiores (`tfoot`)**: Media Natural y Media Ponderada por Juez.
* **Esquina Inferior Derecha**: Media Global Doblemente Ponderada.
* **Cálculo de Pesos (Weights)**:
* Tanto jueces como criterios admiten un **Peso entero** (por defecto `20`, mínimo `1`).
* Las medias se recalculan automáticamente en el cliente a medida que se teclean notas o se modifican los pesos.
* **Gestión de Criterios en Caliente**:
* Es posible añadir nuevos criterios con `+ Nuevo Criterio`.
* Los criterios existentes pueden renombrarse (✏️) o eliminarse (✕), propagándose el cambio en cascada sobre todas las notas históricas guardadas.
---
## 🔐 6. Control de Acceso (Modo Admin vs. Público)
El sistema opera bajo dos estados dinámicos gestionados por `SessionManager`:
1. **Modo Libre / Desarrollo**:
* Si ningún usuario de la base de datos tiene contraseña asignada, la web asume que estás en local y muestra todas las opciones administrativas (`+ Añadir`, `✏️ Editar`, `✕ Borrar`, exportar Base64).
2. **Modo Protegido (Visitante)**:
* Se activa en cuanto un usuario recibe una contraseña.
* La interfaz oculta todos los botones de creación, modificación y borrado.
* La cabecera muestra el botón **"Acceder 🔐"**.
* Al iniciar sesión con nick y clave, la sesión pasa a ser administradora y se restauran todos los botones de control. Al pulsar **"Salir 🚪"**, vuelve al modo protegido.
---
## 💾 7. Flujo de Guardado y Persistencia
Al ser una aplicación sin servidor backend, los cambios realizados en el navegador se almacenan en la memoria local activa. Para hacerlos permanentes:
1. Inicia sesión como administrador.
2. Pulsa en el botón **"Base64 DB"** en la barra superior.
3. Copia el contenido generado (`const database = "...";`).
4. Abre el archivo `/Public/data/database.ecma.js` en tu editor y sobreescribe su contenido.
5. Haz commit o despliega los cambios en tu servidor estático (GitHub Pages, Vercel, Netlify o servidor local).
Jam project for Jams valorations.