From b7222a1f8f64866fc7ffa2174e85a0775edf23af Mon Sep 17 00:00:00 2001 From: Andyyy7666 <86536434+Andyyy7666@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:04:08 +0100 Subject: [PATCH] feat: admin group panel --- client/groupadmin.lua | 32 +++ client/main.lua | 2 +- database/group_ranks.sql | 10 + database/groups.sql | 6 + server/groupadmin.lua | 60 ++++++ server/groups.lua | 226 +++++++++++++++++++ server/main.lua | 10 +- server/player.lua | 21 +- ui/index.html | 13 ++ ui/main.js | 283 ++++++++++++++++++++++++ ui/modules/close.js | 46 ++++ ui/modules/fetch.js | 9 + ui/modules/listener.js | 15 ++ ui/style.css | 455 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 1177 insertions(+), 11 deletions(-) create mode 100644 client/groupadmin.lua create mode 100644 database/group_ranks.sql create mode 100644 database/groups.sql create mode 100644 server/groupadmin.lua create mode 100644 server/groups.lua create mode 100644 ui/index.html create mode 100644 ui/main.js create mode 100644 ui/modules/close.js create mode 100644 ui/modules/fetch.js create mode 100644 ui/modules/listener.js create mode 100644 ui/style.css diff --git a/client/groupadmin.lua b/client/groupadmin.lua new file mode 100644 index 0000000..260e4c3 --- /dev/null +++ b/client/groupadmin.lua @@ -0,0 +1,32 @@ +local isOpen = false + +RegisterNUICallback("hide", function(_, cb) + isOpen = false + SetNuiFocus(false, false) + cb("ok") +end) + +RegisterNUICallback("groups:create", function(data, cb) + local result = lib.callback.await("ND_Core:groups:create", false, data) + cb(result or {}) +end) + +RegisterNUICallback("groups:edit", function(data, cb) + local result = lib.callback.await("ND_Core:groups:edit", false, data) + cb(result or {}) +end) + +RegisterNUICallback("groups:delete", function(data, cb) + local result = lib.callback.await("ND_Core:groups:delete", false, data) + cb(result or {}) +end) + +RegisterNetEvent("ND:openGroupAdmin", function(groups) + if isOpen then return end + isOpen = true + SetNuiFocus(true, true) + SendNUIMessage({ + type = "groups:open", + groups = groups + }) +end) diff --git a/client/main.lua b/client/main.lua index d7e8069..7a69553 100644 --- a/client/main.lua +++ b/client/main.lua @@ -15,7 +15,7 @@ Config = { randomUnlockedVehicleChance = GetConvarInt("core:randomUnlockedVehicleChance", 30), requireKeys = GetConvarInt("core:requireKeys", 1) == 1, useInventoryForKeys = GetConvarInt("core:useInventoryForKeys", 1) == 1, - groups = json.decode(GetConvar("core:groups", "[]")), + groups = {}, compatibility = json.decode(GetConvar("core:compatibility", "[]")), lockpickTries = GetConvarInt("core:lockpickTries", 3) } diff --git a/database/group_ranks.sql b/database/group_ranks.sql new file mode 100644 index 0000000..a2fb37d --- /dev/null +++ b/database/group_ranks.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `nd_group_ranks` ( + `id` INT NOT NULL AUTO_INCREMENT, + `group_name` VARCHAR(50) NOT NULL, + `label` VARCHAR(100) NOT NULL, + `weight` INT NOT NULL DEFAULT 1, + `isBoss` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_group_name` (`group_name`), + CONSTRAINT `fk_group_ranks_group` FOREIGN KEY (`group_name`) REFERENCES `nd_groups`(`name`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/database/groups.sql b/database/groups.sql new file mode 100644 index 0000000..b6aef05 --- /dev/null +++ b/database/groups.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS `nd_groups` ( + `name` VARCHAR(50) NOT NULL, + `label` VARCHAR(100) NOT NULL, + `isJob` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/groupadmin.lua b/server/groupadmin.lua new file mode 100644 index 0000000..213c83a --- /dev/null +++ b/server/groupadmin.lua @@ -0,0 +1,60 @@ +lib.addCommand("groupadmin", { + help = "Open the group management admin panel.", + restricted = "group.admin" +}, function(source, args, raw) + if not source or source == 0 then return end + local groups = NDCore.getAllGroups() + TriggerClientEvent("ND:openGroupAdmin", source, groups) +end) + +lib.callback.register("ND_Core:groups:create", function(source, data) + local player = NDCore.getPlayer(source) + if not player or not player.groups["admin"] then return { success = false } end + + if not data or not data.name or not data.label then + return { success = false } + end + + local success, err = NDCore.createNewGroup(data.name, data.label, data.isJob, data.ranks) + if not success then + return { success = false, error = err } + end + + return { success = true, groups = NDCore.getAllGroups() } +end) + +lib.callback.register("ND_Core:groups:edit", function(source, data) + local player = NDCore.getPlayer(source) + if not player or not player.groups["admin"] then return { success = false } end + + if not data or not data.name then + return { success = false } + end + + local success, err = NDCore.editGroupData(data.name, { + label = data.label, + isJob = data.isJob, + ranks = data.ranks + }) + if not success then + return { success = false, error = err } + end + + return { success = true, groups = NDCore.getAllGroups() } +end) + +lib.callback.register("ND_Core:groups:delete", function(source, data) + local player = NDCore.getPlayer(source) + if not player or not player.groups["admin"] then return { success = false } end + + if not data or not data.name then + return { success = false } + end + + local success = NDCore.deleteGroup(data.name) + if not success then + return { success = false } + end + + return { success = true, groups = NDCore.getAllGroups() } +end) diff --git a/server/groups.lua b/server/groups.lua new file mode 100644 index 0000000..eb6b5fd --- /dev/null +++ b/server/groups.lua @@ -0,0 +1,226 @@ +-- Cache of all groups loaded from DB: Config.groups[name] = { label, isJob, ranks = { [weight] = { id, label, weight, isBoss } } } + +local function loadGroupsFromDB() + local groups = {} + local rows = MySQL.query.await("SELECT * FROM nd_groups") + if not rows then return groups end + + for _, row in ipairs(rows) do + groups[row.name] = { + label = row.label, + isJob = row.isJob == 1, + ranks = {} + } + end + + local ranks = MySQL.query.await("SELECT * FROM nd_group_ranks ORDER BY weight ASC") + if ranks then + for _, rank in ipairs(ranks) do + local g = groups[rank.group_name] + if g then + g.ranks[rank.weight] = { + id = rank.id, + label = rank.label, + weight = rank.weight, + isBoss = rank.isBoss == 1 + } + end + end + end + + return groups +end + +--- Check if a group exists in the database/cache. +---@param name string +---@return boolean +function NDCore.doesGroupExist(name) + return Config.groups[name] ~= nil +end + +--- Get group data from cache. +---@param name string +---@return table|nil +function NDCore.getGroupData(name) + return Config.groups[name] +end + +--- Create a new group in the database and cache. +---@param name string Unique key +---@param label string Display name +---@param isJob boolean +---@param ranks table Array of { label = string, weight = number, isBoss = boolean } +---@return boolean success +---@return string|nil error +function NDCore.createNewGroup(name, label, isJob, ranks) + if not name or name == "" then return false, "name is required" end + if Config.groups[name] then return false, "group already exists" end + + MySQL.insert.await("INSERT INTO nd_groups (`name`, `label`, `isJob`) VALUES (?, ?, ?)", { + name, label or name, isJob and 1 or 0 + }) + + Config.groups[name] = { + label = label or name, + isJob = isJob or false, + ranks = {} + } + + if ranks and #ranks > 0 then + for _, rank in ipairs(ranks) do + local id = MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", { + name, rank.label, rank.weight, rank.isBoss and 1 or 0 + }) + Config.groups[name].ranks[rank.weight] = { + id = id, + label = rank.label, + weight = rank.weight, + isBoss = rank.isBoss or false + } + end + end + + NDCore.syncGroupsToClients() + return true +end + +--- Edit an existing group in the database and cache. +---@param name string Group key +---@param data table { label?, isJob?, ranks? } +---@return boolean success +---@return string|nil error +function NDCore.editGroupData(name, data) + if not name or not Config.groups[name] then return false, "group does not exist" end + + if data.label ~= nil or data.isJob ~= nil then + local label = data.label or Config.groups[name].label + local isJob = data.isJob ~= nil and data.isJob or Config.groups[name].isJob + MySQL.update.await("UPDATE nd_groups SET `label` = ?, `isJob` = ? WHERE `name` = ?", { + label, isJob and 1 or 0, name + }) + Config.groups[name].label = label + Config.groups[name].isJob = isJob + end + + if data.ranks then + -- Delete all old ranks and replace with new set + MySQL.update.await("DELETE FROM nd_group_ranks WHERE `group_name` = ?", { name }) + Config.groups[name].ranks = {} + + for _, rank in ipairs(data.ranks) do + local id = MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", { + name, rank.label, rank.weight, rank.isBoss and 1 or 0 + }) + Config.groups[name].ranks[rank.weight] = { + id = id, + label = rank.label, + weight = rank.weight, + isBoss = rank.isBoss or false + } + end + end + + NDCore.syncGroupsToClients() + return true +end + +--- Delete a group from the database and cache. +---@param name string +---@return boolean +function NDCore.deleteGroup(name) + if not name or not Config.groups[name] then return false end + MySQL.update.await("DELETE FROM nd_groups WHERE `name` = ?", { name }) + Config.groups[name] = nil + NDCore.syncGroupsToClients() + return true +end + +--- Get all groups formatted for UI or external use. +---@return table +function NDCore.getAllGroups() + local result = {} + for name, group in pairs(Config.groups) do + local ranks = {} + for weight, rank in pairs(group.ranks) do + ranks[#ranks+1] = { + id = rank.id, + label = rank.label, + weight = rank.weight, + isBoss = rank.isBoss + } + end + table.sort(ranks, function(a, b) return a.weight < b.weight end) + result[#result+1] = { + name = name, + label = group.label, + isJob = group.isJob, + ranks = ranks + } + end + table.sort(result, function(a, b) return a.name < b.name end) + return result +end + +--- Sync groups convar to all clients after changes. +function NDCore.syncGroupsToClients() + -- Build a simplified map for the convar (backwards compatible format) + local simplified = {} + for name, group in pairs(Config.groups) do + local rankLabels = {} + local sortedRanks = {} + for weight, rank in pairs(group.ranks) do + sortedRanks[#sortedRanks+1] = rank + end + table.sort(sortedRanks, function(a, b) return a.weight < b.weight end) + for _, rank in ipairs(sortedRanks) do + rankLabels[#rankLabels+1] = rank.label + end + simplified[name] = { + label = group.label, + isJob = group.isJob, + ranks = rankLabels + } + end + SetConvarReplicated("core:groups", json.encode(simplified)) +end + +--- Load groups from DB into Config.groups. Called at startup. +function NDCore.loadGroups() + Config.groups = loadGroupsFromDB() + NDCore.syncGroupsToClients() +end + +--- Seed the database from the old JSON config if tables are empty. +function NDCore.seedGroupsFromJson() + local count = MySQL.scalar.await("SELECT COUNT(*) FROM nd_groups") + if count and count > 0 then return false end + + local groupsJson = lib.loadJson("_config.groups") or json.decode(GetConvar("core:groups", "[]")) + if not groupsJson then return false end + + for name, group in pairs(groupsJson) do + MySQL.insert.await("INSERT INTO nd_groups (`name`, `label`, `isJob`) VALUES (?, ?, ?)", { + name, group.label or name, (group.isJob and 1 or 0) + }) + + if group.ranks then + for i, rankLabel in ipairs(group.ranks) do + local isBoss = false + if group.minimumBossRank and i >= group.minimumBossRank then + isBoss = true + end + MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", { + name, rankLabel, i, isBoss and 1 or 0 + }) + end + end + end + + return true +end + +for name, func in pairs(NDCore) do + if type(func) == "function" then + exports(name, func) + end +end diff --git a/server/main.lua b/server/main.lua index 8784f9d..209355b 100644 --- a/server/main.lua +++ b/server/main.lua @@ -4,6 +4,7 @@ NDCore.players = {} PlayersInfo = {} local resourceName = GetCurrentResourceName() local tempPlayersInfo = {} +-- Groups are now loaded from the database in MySQL.ready via NDCore.loadGroups() Config = { serverName = GetConvar("core:serverName", "Unconfigured ND-Core Server"), @@ -23,7 +24,7 @@ Config = { randomUnlockedVehicleChance = GetConvarInt("core:randomUnlockedVehicleChance", 30), disableVehicleAirControl = GetConvarInt("core:disableVehicleAirControl", 1) == 1, useInventoryForKeys = GetConvarInt("core:useInventoryForKeys", 1) == 1, - groups = json.decode(GetConvar("core:groups", "[]")), + groups = {}, admins = json.decode(GetConvar("core:admins", "[]")), adminDiscordRoles = json.decode(GetConvar("core:adminDiscordRoles", "[]")), groupRoles = json.decode(GetConvar("core:groupRoles", "[]")), @@ -253,8 +254,13 @@ MySQL.ready(function() "database/users.sql", "database/characters.sql", "database/vehicles.sql", - "database/moneylogs.sql" + "database/moneylogs.sql", + "database/groups.sql", + "database/group_ranks.sql" }, resourceName) + Wait(200) + NDCore.seedGroupsFromJson() + NDCore.loadGroups() end) -- Hourly cron, purge soft-deleted characters older than 30 days. diff --git a/server/player.lua b/server/player.lua index 4a215d6..b22e214 100644 --- a/server/player.lua +++ b/server/player.lua @@ -404,11 +404,17 @@ local function createCharacterTable(info) ---@return boolean function self.addGroup(name, rank, customGroup, isJob) local groupRank = tonumber(rank) or 1 - local groupInfo = lib.table.deepclone(Config.groups?[name] or {}) - local bossRank = groupInfo?.minimumBossRank + local groupInfo = Config.groups?[name] or {} + local rankData = groupInfo?.ranks?[groupRank] - for k, v in pairs(customGroup or {}) do - groupInfo[k] = v + local groupLabel = groupInfo?.label or name + local rankName = rankData?.label or groupRank + local isBoss = rankData?.isBoss or false + + if customGroup then + if customGroup.label then groupLabel = customGroup.label end + if customGroup.rankName then rankName = customGroup.rankName end + if customGroup.isBoss ~= nil then isBoss = customGroup.isBoss end end if isJob then @@ -419,12 +425,11 @@ local function createCharacterTable(info) self.groups[name] = { name = name, - label = groupInfo?.label or name, - rankName = groupInfo?.ranks?[groupRank] or groupRank, + label = groupLabel, + rankName = rankName, rank = groupRank, isJob = isJob, - isBoss = bossRank and groupRank >= bossRank, - metadata = groupInfo.metadata or {} + isBoss = isBoss } if not isJob then diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..707edee --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + + + Group Management + + + + diff --git a/ui/main.js b/ui/main.js new file mode 100644 index 0000000..9b02940 --- /dev/null +++ b/ui/main.js @@ -0,0 +1,283 @@ +import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.5.3.min.js"; +import { nuiMessage } from "./modules/listener.js"; +import { fetchCallback } from "./modules/fetch.js"; +import { closeUI, setOpen } from "./modules/close.js"; + +const { div, h1, h2, h3, p, button, input, label, span, i: icon } = van.tags; + +let groups = []; +let container = null; +let editPanel = null; +let currentEdit = null; + +function createGroupCard(group) { + const card = div({ class: "group-card", onclick: () => openEditPanel(group) }, + div({ class: "group-card-header" }, + h3(group.label), + span({ class: "group-name" }, group.name) + ), + div({ class: "group-card-tags" }, + group.isJob ? span({ class: "tag tag-job" }, "Job") : null, + span({ class: "tag tag-ranks" }, `${group.ranks.length} rank${group.ranks.length !== 1 ? "s" : ""}`) + ) + ); + return card; +} + +function renderGroupList() { + if (!container) return; + container.innerHTML = ""; + + const header = div({ class: "list-header" }, + h1("Group Management"), + button({ class: "btn btn-create", onclick: () => openCreatePanel() }, + icon({ class: "fa-solid fa-plus" }), + " New Group" + ) + ); + container.appendChild(header); + + const searchRow = div({ class: "search-row" }, + input({ class: "search-input", type: "text", placeholder: "Search groups...", oninput: (e) => filterGroups(e.target.value) }) + ); + container.appendChild(searchRow); + + const grid = div({ class: "group-grid", id: "group-grid" }); + groups.forEach(g => grid.appendChild(createGroupCard(g))); + container.appendChild(grid); +} + +function filterGroups(query) { + const grid = document.getElementById("group-grid"); + if (!grid) return; + grid.innerHTML = ""; + const lower = query.toLowerCase(); + const filtered = groups.filter(g => g.name.toLowerCase().includes(lower) || g.label.toLowerCase().includes(lower)); + filtered.forEach(g => grid.appendChild(createGroupCard(g))); +} + +function createRankRow(rank, index, ranksList) { + const row = div({ class: "rank-row", "data-index": index }, + div({ class: "rank-order" }, + button({ class: "btn-icon", title: "Move up", onclick: () => moveRank(ranksList, index, -1) }, + icon({ class: "fa-solid fa-chevron-up" }) + ), + span({ class: "rank-weight" }, String(rank.weight)), + button({ class: "btn-icon", title: "Move down", onclick: () => moveRank(ranksList, index, 1) }, + icon({ class: "fa-solid fa-chevron-down" }) + ) + ), + input({ class: "rank-label-input", type: "text", value: rank.label, placeholder: "Rank label", oninput: (e) => { + ranksList[index].label = e.target.value; + }}), + div({ class: "rank-boss-toggle" }, + label({ class: "checkbox-label" }, + input({ type: "checkbox", checked: rank.isBoss, onchange: (e) => { + ranksList[index].isBoss = e.target.checked; + }}), + " Boss" + ) + ), + button({ class: "btn-icon btn-danger", title: "Delete rank", onclick: () => { + ranksList.splice(index, 1); + recalculateWeights(ranksList); + rerenderRanks(ranksList); + }}, + icon({ class: "fa-solid fa-trash" }) + ) + ); + return row; +} + +function moveRank(ranksList, index, direction) { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= ranksList.length) return; + const temp = ranksList[index]; + ranksList[index] = ranksList[newIndex]; + ranksList[newIndex] = temp; + recalculateWeights(ranksList); + rerenderRanks(ranksList); +} + +function recalculateWeights(ranksList) { + for (let i = 0; i < ranksList.length; i++) { + ranksList[i].weight = i + 1; + } +} + +function rerenderRanks(ranksList) { + const ranksContainer = document.getElementById("ranks-container"); + if (!ranksContainer) return; + ranksContainer.innerHTML = ""; + ranksList.forEach((rank, idx) => { + ranksContainer.appendChild(createRankRow(rank, idx, ranksList)); + }); +} + +function openEditPanel(group) { + currentEdit = JSON.parse(JSON.stringify(group)); + showEditPanel(false); +} + +function openCreatePanel() { + currentEdit = { name: "", label: "", isJob: false, ranks: [] }; + showEditPanel(true); +} + +function showEditPanel(isNew) { + if (!editPanel) return; + editPanel.innerHTML = ""; + editPanel.classList.add("open"); + + const ranksList = currentEdit.ranks.map(r => ({ ...r })); + + const panelContent = div({ class: "edit-content" }, + div({ class: "edit-header" }, + h2(isNew ? "Create Group" : "Edit Group"), + button({ class: "btn-icon", onclick: () => closeEditPanel() }, + icon({ class: "fa-solid fa-xmark" }) + ) + ), + div({ class: "edit-form" }, + div({ class: "form-group" }, + label("Name (unique key)"), + input({ + class: "form-input", + type: "text", + value: currentEdit.name, + disabled: !isNew, + placeholder: "e.g. polis", + oninput: (e) => { currentEdit.name = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""); e.target.value = currentEdit.name; } + }) + ), + div({ class: "form-group" }, + label("Label"), + input({ + class: "form-input", + type: "text", + value: currentEdit.label, + placeholder: "e.g. Polis", + oninput: (e) => { currentEdit.label = e.target.value; } + }) + ), + div({ class: "form-group form-row" }, + label({ class: "checkbox-label" }, + input({ + type: "checkbox", + checked: currentEdit.isJob, + onchange: (e) => { currentEdit.isJob = e.target.checked; } + }), + " Is Job" + ) + ), + div({ class: "form-group" }, + div({ class: "ranks-header" }, + h3("Ranks"), + button({ class: "btn btn-small", onclick: () => { + ranksList.push({ label: "", weight: ranksList.length + 1, isBoss: false }); + rerenderRanks(ranksList); + }}, + icon({ class: "fa-solid fa-plus" }), + " Add Rank" + ) + ), + div({ class: "ranks-info" }, + p("Rank 1 is the lowest. Higher weight = higher rank. Boss ranks grant boss permissions.") + ), + div({ id: "ranks-container", class: "ranks-container" }) + ) + ), + div({ class: "edit-actions" }, + !isNew ? button({ class: "btn btn-danger", onclick: () => deleteGroup(currentEdit.name) }, + icon({ class: "fa-solid fa-trash" }), + " Delete Group" + ) : null, + div({ class: "edit-actions-right" }, + button({ class: "btn btn-secondary", onclick: () => closeEditPanel() }, "Cancel"), + button({ class: "btn btn-primary", onclick: () => saveGroup(isNew, ranksList) }, + icon({ class: "fa-solid fa-floppy-disk" }), + " Save" + ) + ) + ) + ); + + editPanel.appendChild(panelContent); + rerenderRanks(ranksList); +} + +function closeEditPanel() { + if (!editPanel) return; + editPanel.classList.remove("open"); + editPanel.innerHTML = ""; + currentEdit = null; +} + +function saveGroup(isNew, ranksList) { + if (!currentEdit.name || !currentEdit.label) return; + + const ranks = ranksList.map((r, idx) => ({ + label: r.label, + weight: idx + 1, + isBoss: r.isBoss + })); + + if (isNew) { + fetchCallback("groups:create", { + name: currentEdit.name, + label: currentEdit.label, + isJob: currentEdit.isJob, + ranks: ranks + }, (result) => { + if (result && result.success) { + groups = result.groups || groups; + renderGroupList(); + closeEditPanel(); + } + }); + } else { + fetchCallback("groups:edit", { + name: currentEdit.name, + label: currentEdit.label, + isJob: currentEdit.isJob, + ranks: ranks + }, (result) => { + if (result && result.success) { + groups = result.groups || groups; + renderGroupList(); + closeEditPanel(); + } + }); + } +} + +function deleteGroup(name) { + if (!name) return; + fetchCallback("groups:delete", { name: name }, (result) => { + if (result && result.success) { + groups = result.groups || groups; + renderGroupList(); + closeEditPanel(); + } + }); +} + +function init() { + container = div({ class: "main-page" }); + editPanel = div({ class: "edit-panel" }); + document.body.appendChild(container); + document.body.appendChild(editPanel); +} + +nuiMessage("groups:open", (info) => { + setOpen(true); + document.body.style.opacity = "1"; + groups = info.groups || []; + renderGroupList(); +}); + +nuiMessage("groups:close", () => { + closeUI(); +}); + +init(); diff --git a/ui/modules/close.js b/ui/modules/close.js new file mode 100644 index 0000000..07ccc92 --- /dev/null +++ b/ui/modules/close.js @@ -0,0 +1,46 @@ +import { fetchCallback } from "./fetch.js"; + +let isOpen = false; + +export function closeUI() { + if (!isOpen) return; + isOpen = false; + document.body.style.opacity = "0"; + setTimeout(() => { + fetchCallback("hide"); + }, 400); +} + +export function setOpen(state) { + isOpen = state; +} + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeUI(); +}); + +document.addEventListener("click", (event) => { + const mainPage = document.querySelector(".main-page"); + const editPanel = document.querySelector(".edit-panel"); + if (!mainPage || !editPanel) return; + + const mainRect = mainPage.getBoundingClientRect(); + const editRect = editPanel.getBoundingClientRect(); + + const insideMainPage = + event.clientX >= mainRect.left && + event.clientX <= mainRect.right && + event.clientY >= mainRect.top && + event.clientY <= mainRect.bottom; + + const insideEditPanel = + event.clientX >= editRect.left && + event.clientX <= editRect.right && + event.clientY >= editRect.top && + event.clientY <= editRect.bottom; + + if (!insideMainPage && !insideEditPanel) { + closeUI(); + } +}); + diff --git a/ui/modules/fetch.js b/ui/modules/fetch.js new file mode 100644 index 0000000..bfe7515 --- /dev/null +++ b/ui/modules/fetch.js @@ -0,0 +1,9 @@ +export function fetchCallback(name, data, cb) { + fetch(`https://${GetParentResourceName()}/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=UTF-8" }, + body: JSON.stringify(data) + }).then(resp => resp.json()).then(resp => { + if (cb) cb(resp); + }); +} diff --git a/ui/modules/listener.js b/ui/modules/listener.js new file mode 100644 index 0000000..09ca5bb --- /dev/null +++ b/ui/modules/listener.js @@ -0,0 +1,15 @@ +const nuiMessages = {}; + +window.addEventListener("message", function(event) { + const item = event.data; + const listener = nuiMessages[item.type]; + if (!listener) return; + for (let i = 0; i < listener.length; i++) { + listener[i](item); + } +}); + +export function nuiMessage(name, cb) { + if (!nuiMessages[name]) nuiMessages[name] = []; + nuiMessages[name].push(cb); +} diff --git a/ui/style.css b/ui/style.css new file mode 100644 index 0000000..0c190e0 --- /dev/null +++ b/ui/style.css @@ -0,0 +1,455 @@ +@import url('https://fonts.googleapis.com/css2?family=Abel&display=swap'); + +:root { + --font: "Abel", sans-serif; + --bg-primary: #1a1a1bfd; + --bg-secondary: #202020; + --bg-tertiary: #262627; + --bg-hover: #2e2e2f; + --text-primary: #d4d4d3; + --text-secondary: #999; + --accent: #0063b1; + --accent-hover: #0078d4; + --danger: #c42b1c; + --danger-hover: #e03e2d; + --border: #333; + --success: #107c10; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + user-select: none; + opacity: 0; + transition: opacity 0.4s; + font-family: var(--font); + color: var(--text-primary); + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; +} + +.main-page { + position: relative; + background-color: var(--bg-primary); + border-radius: 12px; + width: 60rem; + max-height: 45rem; + padding: 1.5rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.list-header h1 { + font-size: 1.6rem; + font-weight: 400; +} + +.search-row { + margin-bottom: 1rem; +} + +.search-input { + width: 100%; + padding: 0.6rem 1rem; + border-radius: 8px; + border: 1px solid var(--border); + background-color: var(--bg-secondary); + color: var(--text-primary); + font-family: var(--font); + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} + +.search-input:focus { + border-color: var(--accent); +} + +.group-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 0.75rem; + overflow-y: auto; + padding-right: 0.25rem; + max-height: 35rem; +} + +.group-grid::-webkit-scrollbar { + width: 5px; +} + +.group-grid::-webkit-scrollbar-track { + background: transparent; +} + +.group-grid::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.group-card { + background-color: var(--bg-secondary); + border-radius: 10px; + padding: 1rem; + cursor: pointer; + transition: background-color 0.2s, transform 0.15s; + border: 1px solid transparent; +} + +.group-card:hover { + background-color: var(--bg-tertiary); + border-color: var(--border); + transform: translateY(-1px); +} + +.group-card-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.5rem; +} + +.group-card-header h3 { + font-size: 1.15rem; + font-weight: 400; +} + +.group-name { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.group-card-tags { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.tag { + font-size: 0.8rem; + padding: 0.15rem 0.5rem; + border-radius: 4px; + background-color: var(--bg-tertiary); + color: var(--text-secondary); +} + +.tag-job { + background-color: rgba(0, 99, 177, 0.25); + color: var(--accent-hover); +} + +.tag-ranks { + background-color: rgba(16, 124, 16, 0.2); + color: #4caf50; +} + +/* Edit Panel */ +.edit-panel { + position: fixed; + top: 0; + right: -35rem; + width: 35rem; + height: 100vh; + background-color: var(--bg-primary); + border-left: 1px solid var(--border); + transition: right 0.3s ease; + z-index: 100; + display: flex; + flex-direction: column; +} + +.edit-panel.open { + right: 0; +} + +.edit-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 1.5rem; +} + +.edit-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.edit-header h2 { + font-size: 1.4rem; + font-weight: 400; +} + +.edit-form { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1rem; + padding-right: 0.25rem; +} + +.edit-form::-webkit-scrollbar { + width: 5px; +} + +.edit-form::-webkit-scrollbar-track { + background: transparent; +} + +.edit-form::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.form-group > label { + font-size: 0.9rem; + color: var(--text-secondary); +} + +.form-row { + flex-direction: row; + align-items: center; +} + +.form-input { + padding: 0.5rem 0.75rem; + border-radius: 6px; + border: 1px solid var(--border); + background-color: var(--bg-secondary); + color: var(--text-primary); + font-family: var(--font); + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} + +.form-input:focus { + border-color: var(--accent); +} + +.form-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + font-size: 0.95rem; +} + +.checkbox-label input[type="checkbox"] { + width: 1.1rem; + height: 1.1rem; + accent-color: var(--accent); + cursor: pointer; +} + +/* Ranks */ +.ranks-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ranks-header h3 { + font-size: 1.1rem; + font-weight: 400; +} + +.ranks-info { + margin-bottom: 0.5rem; +} + +.ranks-info p { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.ranks-container { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.rank-row { + display: flex; + align-items: center; + gap: 0.5rem; + background-color: var(--bg-secondary); + padding: 0.5rem 0.6rem; + border-radius: 8px; + transition: background-color 0.15s; +} + +.rank-row:hover { + background-color: var(--bg-tertiary); +} + +.rank-order { + display: flex; + align-items: center; + gap: 0.15rem; + min-width: 4rem; + justify-content: center; +} + +.rank-weight { + font-size: 0.85rem; + color: var(--text-secondary); + min-width: 1.5rem; + text-align: center; +} + +.rank-label-input { + flex: 1; + padding: 0.35rem 0.6rem; + border-radius: 5px; + border: 1px solid var(--border); + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font); + font-size: 0.95rem; + outline: none; + transition: border-color 0.2s; +} + +.rank-label-input:focus { + border-color: var(--accent); +} + +.rank-boss-toggle { + min-width: 3.5rem; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.5rem 1rem; + border: none; + border-radius: 7px; + cursor: pointer; + font-family: var(--font); + font-size: 0.95rem; + transition: background-color 0.2s, opacity 0.2s; +} + +.btn:hover { + opacity: 0.9; +} + +.btn-primary { + background-color: var(--accent); + color: var(--text-primary); +} + +.btn-primary:hover { + background-color: var(--accent-hover); +} + +.btn-secondary { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-secondary:hover { + background-color: var(--bg-hover); +} + +.btn-create { + background-color: var(--accent); + color: var(--text-primary); +} + +.btn-create:hover { + background-color: var(--accent-hover); +} + +.btn-danger { + background-color: var(--danger); + color: var(--text-primary); +} + +.btn-danger:hover { + background-color: var(--danger-hover); +} + +.btn-small { + padding: 0.3rem 0.7rem; + font-size: 0.85rem; + background-color: var(--accent); + color: var(--text-primary); +} + +.btn-icon { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.2rem; + font-size: 0.85rem; + border-radius: 4px; + transition: color 0.2s, background-color 0.15s; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; +} + +.btn-icon:hover { + color: var(--text-primary); + background-color: var(--bg-hover); +} + +.btn-icon.btn-danger { + background: none; + color: var(--text-secondary); +} + +.btn-icon.btn-danger:hover { + color: var(--danger-hover); + background-color: rgba(196, 43, 28, 0.15); +} + +/* Edit Actions */ +.edit-actions { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1rem; + border-top: 1px solid var(--border); + margin-top: 1rem; +} + +.edit-actions-right { + display: flex; + gap: 0.5rem; +}