diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4e87b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/Public/data \ No newline at end of file diff --git a/Public/ecma/Application/JudgePole.ecma.js b/Public/ecma/Application/JudgePole.ecma.js new file mode 100644 index 0000000..2e4aa3e --- /dev/null +++ b/Public/ecma/Application/JudgePole.ecma.js @@ -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.)} inputs + * @returns {Array.} + * @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; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/DatabaseManager.ecma.js b/Public/ecma/Managers/DatabaseManager.ecma.js new file mode 100644 index 0000000..169da16 --- /dev/null +++ b/Public/ecma/Managers/DatabaseManager.ecma.js @@ -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; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/I18NManager.ecma.js b/Public/ecma/Managers/I18NManager.ecma.js new file mode 100644 index 0000000..484753b --- /dev/null +++ b/Public/ecma/Managers/I18NManager.ecma.js @@ -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; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/SessionManager.ecma.js b/Public/ecma/Managers/SessionManager.ecma.js new file mode 100644 index 0000000..bf7d4d3 --- /dev/null +++ b/Public/ecma/Managers/SessionManager.ecma.js @@ -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; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/SettingsManager.ecma.js b/Public/ecma/Managers/SettingsManager.ecma.js new file mode 100644 index 0000000..76e809a --- /dev/null +++ b/Public/ecma/Managers/SettingsManager.ecma.js @@ -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; +})(); \ No newline at end of file diff --git a/Public/ecma/Utils/Common.ecma.js b/Public/ecma/Utils/Common.ecma.js new file mode 100644 index 0000000..adaec88 --- /dev/null +++ b/Public/ecma/Utils/Common.ecma.js @@ -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.} */ + 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} */ + 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; +})(); \ No newline at end of file diff --git a/Public/ecma/Utils/I18N.ecma.js b/Public/ecma/Utils/I18N.ecma.js new file mode 100644 index 0000000..44a9ad8 --- /dev/null +++ b/Public/ecma/Utils/I18N.ecma.js @@ -0,0 +1,187 @@ +"use strict"; + +/** @type {Dictionary.>} */ +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!" + } +}; \ No newline at end of file diff --git a/Public/ecma/Utils/Settings.ecma.js b/Public/ecma/Utils/Settings.ecma.js new file mode 100644 index 0000000..d093d3b --- /dev/null +++ b/Public/ecma/Utils/Settings.ecma.js @@ -0,0 +1,20 @@ +"use strict"; + +/** @type {Dictionary.} */ +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) +}; \ No newline at end of file diff --git a/Public/ecma/Utils/Styles.ecma.js b/Public/ecma/Utils/Styles.ecma.js new file mode 100644 index 0000000..0078012 --- /dev/null +++ b/Public/ecma/Utils/Styles.ecma.js @@ -0,0 +1,567 @@ +"use strict"; + +/** @type {Dictionary.} */ +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) +}; \ No newline at end of file diff --git a/Public/ecma/Views/ProjectsView.ecma.js b/Public/ecma/Views/ProjectsView.ecma.js new file mode 100644 index 0000000..be39c6c --- /dev/null +++ b/Public/ecma/Views/ProjectsView.ecma.js @@ -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; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/UsersView.ecma.js b/Public/ecma/Views/UsersView.ecma.js new file mode 100644 index 0000000..99e7d54 --- /dev/null +++ b/Public/ecma/Views/UsersView.ecma.js @@ -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; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/VotesView.ecma.js b/Public/ecma/Views/VotesView.ecma.js new file mode 100644 index 0000000..8f67571 --- /dev/null +++ b/Public/ecma/Views/VotesView.ecma.js @@ -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; +})(); \ No newline at end of file diff --git a/Public/index.html b/Public/index.html new file mode 100644 index 0000000..195a9b4 --- /dev/null +++ b/Public/index.html @@ -0,0 +1,47 @@ + + + + JudgePole + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Public/md/ai-dev-rules.md b/Public/md/ai-dev-rules.md new file mode 100644 index 0000000..d5ce29a --- /dev/null +++ b/Public/md/ai-dev-rules.md @@ -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`. \ No newline at end of file diff --git a/README.md b/README.md index fbba0bc..2c7e608 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file