From f4c821a2c9419d731bce72e05cfeffdc9efb2c4d Mon Sep 17 00:00:00 2001 From: Harm Manders Date: Wed, 4 Mar 2026 11:35:52 +0100 Subject: [PATCH 1/6] Add NPC groups feature for organizing NPCs - Add NPC groups Firebase service and Vuex store module - Add group CRUD management dialog (NpcGroupManager) - Add group assignment in NPC editor via multi-select - Display group membership as chips in NPC table - Add group filter dropdown to NPC list - Cascade group cleanup when deleting a group - Sync groups field to search_npcs for client-side filtering --- src/components/npcs/NpcGroupManager.vue | 266 +++++++++++++++++++++ src/services/npc_groups.js | 73 ++++++ src/store/index.js | 2 + src/store/modules/userContent/npcGroups.js | 151 ++++++++++++ src/store/modules/userContent/npcs.js | 41 +++- src/views/UserContent/Npcs/EditNpc.vue | 60 +++++ src/views/UserContent/Npcs/Npcs.vue | 142 +++++++++-- 7 files changed, 716 insertions(+), 19 deletions(-) create mode 100644 src/components/npcs/NpcGroupManager.vue create mode 100644 src/services/npc_groups.js create mode 100644 src/store/modules/userContent/npcGroups.js diff --git a/src/components/npcs/NpcGroupManager.vue b/src/components/npcs/NpcGroupManager.vue new file mode 100644 index 000000000..46d78d60c --- /dev/null +++ b/src/components/npcs/NpcGroupManager.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/src/services/npc_groups.js b/src/services/npc_groups.js new file mode 100644 index 000000000..d5f5e29d3 --- /dev/null +++ b/src/services/npc_groups.js @@ -0,0 +1,73 @@ +import { firebase, db } from "src/firebase"; + +const NPC_GROUPS_REF = db.ref("npc_groups"); + +/** + * NPC Groups Firebase Service + * CRUD interface for NPC group management + */ +export class npcGroupServices { + /** + * Get all NPC groups for a user + * + * @param {String} uid ID of active user + * @returns All NPC groups for the user + */ + async getGroups(uid) { + try { + const groups = await NPC_GROUPS_REF.child(uid).once("value"); + return groups.val(); + } catch (error) { + throw error; + } + } + + /** + * Add a new NPC group + * + * @param {String} uid ID of active user + * @param {Object} group Group object { name } + * @returns Key of the newly added group + */ + async addGroup(uid, group) { + try { + group.name = group.name.toLowerCase(); + group.created = firebase.database.ServerValue.TIMESTAMP; + + const newGroup = await NPC_GROUPS_REF.child(uid).push(group); + return newGroup.key; + } catch (error) { + throw error; + } + } + + /** + * Update an existing NPC group + * + * @param {String} uid ID of active user + * @param {String} id Group ID + * @param {Object} group Updated group object + */ + async updateGroup(uid, id, group) { + try { + group.name = group.name.toLowerCase(); + await NPC_GROUPS_REF.child(`${uid}/${id}`).update(group); + } catch (error) { + throw error; + } + } + + /** + * Delete an NPC group + * + * @param {String} uid ID of active user + * @param {String} id Group ID + */ + async deleteGroup(uid, id) { + try { + await NPC_GROUPS_REF.child(`${uid}/${id}`).remove(); + } catch (error) { + throw error; + } + } +} diff --git a/src/store/index.js b/src/store/index.js index 44c4ddb27..d72d1bee6 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -11,6 +11,7 @@ import api_items from "./modules/content/items.js"; import api_conditions from "./modules/content/conditions.js"; import campaigns from "./modules/userContent/campaigns.js"; import npcs from "./modules/userContent/npcs.js"; +import npcGroups from "./modules/userContent/npcGroups.js"; import items from "./modules/userContent/items.js"; import spells from "./modules/userContent/spells.js"; import reminders from "./modules/userContent/reminders.js"; @@ -44,6 +45,7 @@ export default function () { api_items: api_items, api_conditions: api_conditions, npcs: npcs, + npcGroups: npcGroups, items: items, spells: spells, reminders: reminders, diff --git a/src/store/modules/userContent/npcGroups.js b/src/store/modules/userContent/npcGroups.js new file mode 100644 index 000000000..5897e20b0 --- /dev/null +++ b/src/store/modules/userContent/npcGroups.js @@ -0,0 +1,151 @@ +import Vue from "vue"; +import { npcGroupServices } from "src/services/npc_groups"; +import _ from "lodash"; + +const npc_group_state = () => ({ + npc_group_services: null, + npc_groups: undefined, +}); + +const npc_group_getters = { + npc_groups: (state) => { + // Convert object to sorted array + return _.chain(state.npc_groups) + .filter((group, key) => { + group.key = key; + return group; + }) + .orderBy("name", "asc") + .value(); + }, + npc_group_services: (state) => { + return state.npc_group_services; + }, +}; + +const npc_group_actions = { + async get_npc_group_services({ getters, commit }) { + if (getters.npc_group_services === null || !Object.keys(getters.npc_group_services).length) { + commit("SET_NPC_GROUP_SERVICES", new npcGroupServices()); + } + return getters.npc_group_services; + }, + + /** + * Fetches all NPC groups for a user + */ + async get_npc_groups({ state, rootGetters, dispatch, commit }) { + const uid = rootGetters.user ? rootGetters.user.uid : undefined; + let groups = state.npc_groups ? state.npc_groups : undefined; + + if (!groups && uid) { + const services = await dispatch("get_npc_group_services"); + try { + groups = await services.getGroups(uid); + commit("SET_NPC_GROUPS", groups || {}); + } catch (error) { + throw error; + } + } + return groups; + }, + + /** + * Add a new NPC group + * + * @param {Object} group { name } + * @returns {String} the id of the newly added group + */ + async add_npc_group({ rootGetters, commit, dispatch }, group) { + const uid = rootGetters.user ? rootGetters.user.uid : undefined; + + if (uid) { + const services = await dispatch("get_npc_group_services"); + try { + const id = await services.addGroup(uid, group); + commit("SET_NPC_GROUP", { id, group }); + return id; + } catch (error) { + throw error; + } + } + }, + + /** + * Update an existing NPC group + * + * @param {String} id + * @param {Object} group + */ + async edit_npc_group({ rootGetters, commit, dispatch }, { id, group }) { + const uid = rootGetters.user ? rootGetters.user.uid : undefined; + + if (uid) { + const services = await dispatch("get_npc_group_services"); + try { + await services.updateGroup(uid, id, group); + commit("SET_NPC_GROUP", { id, group }); + return; + } catch (error) { + throw error; + } + } + }, + + /** + * Delete an NPC group and remove it from all NPCs + * + * @param {String} id + */ + async delete_npc_group({ rootGetters, commit, dispatch }, id) { + const uid = rootGetters.user ? rootGetters.user.uid : undefined; + + if (uid) { + const services = await dispatch("get_npc_group_services"); + try { + await services.deleteGroup(uid, id); + commit("REMOVE_NPC_GROUP", id); + + // Remove group from all NPCs that reference it + await dispatch("npcs/remove_group_from_all_npcs", id, { root: true }); + return; + } catch (error) { + throw error; + } + } + }, + + clear_npc_group_store({ commit }) { + commit("CLEAR_STORE"); + }, +}; + +const npc_group_mutations = { + SET_NPC_GROUP_SERVICES(state, payload) { + Vue.set(state, "npc_group_services", payload); + }, + SET_NPC_GROUPS(state, value) { + Vue.set(state, "npc_groups", value); + }, + SET_NPC_GROUP(state, { id, group }) { + if (state.npc_groups) { + Vue.set(state.npc_groups, id, group); + } else { + Vue.set(state, "npc_groups", { [id]: group }); + } + }, + REMOVE_NPC_GROUP(state, id) { + Vue.delete(state.npc_groups, id); + }, + CLEAR_STORE(state) { + Vue.set(state, "npc_groups", undefined); + }, +}; + +export default { + namespaced: true, + state: npc_group_state, + getters: npc_group_getters, + actions: npc_group_actions, + mutations: npc_group_mutations, +}; diff --git a/src/store/modules/userContent/npcs.js b/src/store/modules/userContent/npcs.js index 4dfbcbf95..fca98f6f6 100644 --- a/src/store/modules/userContent/npcs.js +++ b/src/store/modules/userContent/npcs.js @@ -4,7 +4,7 @@ import _ from "lodash"; // Converts a full npc to a search_npc const convert_npc = (npc) => { - const properties = ["name", "challenge_rating", "avatar", "storage_avatar", "type"]; + const properties = ["name", "challenge_rating", "avatar", "storage_avatar", "type", "groups"]; const returnNpc = {}; for (const prop of properties) { @@ -359,6 +359,45 @@ const npc_actions = { } }, + /** + * Updates the groups for an NPC + * + * @param {string} id NPC ID + * @param {object} groups Groups object { groupId: true, ... } + */ + async update_npc_groups({ rootGetters, commit, dispatch }, { id, groups }) { + const uid = rootGetters.user ? rootGetters.user.uid : undefined; + if (uid) { + const services = await dispatch("get_npc_services"); + try { + await services.updateNpc(uid, id, "", { groups }, true); + commit("SET_NPC_PROP", { uid, id, property: "groups", value: groups, update_search: true }); + return; + } catch (error) { + throw error; + } + } + }, + + /** + * Removes a group from all NPCs that reference it + * Called when a group is deleted + * + * @param {string} groupId + */ + async remove_group_from_all_npcs({ state, rootGetters, dispatch }, groupId) { + const uid = rootGetters.user ? rootGetters.user.uid : undefined; + if (uid && state.npcs) { + const services = await dispatch("get_npc_services"); + for (const [npcId, npc] of Object.entries(state.npcs)) { + if (npc.groups && npc.groups[groupId]) { + await services.updateNpc(uid, npcId, "/groups", { [groupId]: null }, true); + Vue.delete(state.npcs[npcId].groups, groupId); + } + } + } + }, + cache_generated_npc({ commit }, npc) { commit("CACHE_GENERATED_NPC", npc); }, diff --git a/src/views/UserContent/Npcs/EditNpc.vue b/src/views/UserContent/Npcs/EditNpc.vue index 9ab5add4d..0c01b73a4 100644 --- a/src/views/UserContent/Npcs/EditNpc.vue +++ b/src/views/UserContent/Npcs/EditNpc.vue @@ -41,6 +41,26 @@
+ + +
+ +
+
+ @@ -239,6 +259,10 @@ export default { }; }, async mounted() { + if (this.userId) { + this.get_npc_groups(); + this.get_campaigns(); + } if (this.npcId) { this.loading = true; await this.get_npc({ uid: this.userId, id: this.npcId }).then((npc) => { @@ -255,6 +279,40 @@ export default { computed: { ...mapGetters(["user", "tier", "overencumbered"]), ...mapGetters("npcs", ["npc_count"]), + ...mapGetters("npcGroups", { npc_groups: "npc_groups" }), + ...mapGetters("campaigns", { all_campaigns: "campaigns" }), + selectedGroups: { + get() { + return this.npc.groups ? Object.keys(this.npc.groups) : []; + }, + set(val) { + const groups = {}; + for (const id of val) { + groups[id] = true; + } + this.$set(this.npc, "groups", Object.keys(groups).length ? groups : undefined); + }, + }, + groupOptions() { + const options = []; + if (this.npc_groups) { + for (const group of this.npc_groups) { + options.push({ + label: group.name ? group.name.capitalizeEach() : group.key, + value: group.key, + }); + } + } + if (this.all_campaigns) { + for (const campaign of this.all_campaigns) { + options.push({ + label: `[Campaign] ${campaign.name ? campaign.name.capitalizeEach() : campaign.key}`, + value: `campaign__${campaign.key}`, + }); + } + } + return options; + }, }, watch: { npc: { @@ -275,6 +333,8 @@ export default { ...mapActions(["setDrawer"]), ...mapActions("api_monsters", ["fetch_monsters", "fetch_monster"]), ...mapActions("npcs", ["add_npc", "edit_npc", "get_npc"]), + ...mapActions("npcGroups", ["get_npc_groups"]), + ...mapActions("campaigns", ["get_campaigns"]), isOwner() { return this.$route.name !== "Edit Companion"; }, diff --git a/src/views/UserContent/Npcs/Npcs.vue b/src/views/UserContent/Npcs/Npcs.vue index 349cfb5ea..edc2ddfb6 100644 --- a/src/views/UserContent/Npcs/Npcs.vue +++ b/src/views/UserContent/Npcs/Npcs.vue @@ -10,6 +10,9 @@ > Export +