From af8b02071e777ec5d8153953856030b5c1be5a7d Mon Sep 17 00:00:00 2001 From: cosmo Date: Sat, 30 May 2026 23:03:18 +0200 Subject: [PATCH] initial commit --- README.md | 32 +++ module.json | 29 +++ scripts/dh-attribution-sources.mjs | 233 +++++++++++++++++++ styles/dh-attribution-sources.css | 347 +++++++++++++++++++++++++++++ templates/footer.hbs | 8 + templates/settings.hbs | 66 ++++++ 6 files changed, 715 insertions(+) create mode 100644 README.md create mode 100644 module.json create mode 100644 scripts/dh-attribution-sources.mjs create mode 100644 styles/dh-attribution-sources.css create mode 100644 templates/footer.hbs create mode 100644 templates/settings.hbs diff --git a/README.md b/README.md new file mode 100644 index 0000000..49f98c9 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Daggerheart Custom Attribution Sources + +A Foundry VTT (Version 14) module for the **Daggerheart** system that allows Game Masters to add, update, and remove custom item attribution sources directly from the module settings. + +## Features + +- **No Overwrites**: Safely registers custom sources to the system `CONFIG.DH.GENERAL.attributionSources` configuration without altering or affecting system defaults or attributions from other modules. +- **Modern UI/UX**: Built using Foundry VTT v14's modern `ApplicationV2` interface with a dark layout matching Daggerheart's crimson and gold design aesthetics. +- **Dynamic Configuration**: Add source groups (e.g. `Forevermore`) and nest multiple values inside them (e.g. `Campaign - Forevermore`). +- **Autocompleter Integration**: Custom attributions automatically populate autocomplete lists when editing any item's attribution details. + +## File Structure + +```text +dh-attribution-sources/ +├── module.json # Module registration and compatibility manifest +├── scripts/ +│ └── dh-attribution-sources.mjs # Module entrypoint & ApplicationV2 form logic +├── styles/ +│ └── dh-attribution-sources.css # Crimson & Gold CSS styles for the settings dialog +└── templates/ + ├── footer.hbs # Save and Reset buttons template + └── settings.hbs # Layout template for editing groups and nested values +``` + +## How to Install & Use + +1. Ensure the `dh-attribution-sources` directory is placed inside your Foundry VTT `Data/modules` folder. +2. Launch Foundry VTT, open your game world, go to the **Manage Modules** tab in the sidebar, and check **Daggerheart Custom Attribution Sources**. +3. In the sidebar under **Configure Game Settings** -> **Daggerheart Custom Attribution Sources**, click the **Manage Custom Sources** menu. +4. Add source groups and values, configure their labels, and click **Save Settings**. +5. Edit any item sheet, click the **Attribution** icon in the header, and begin typing in the source field to see your custom sources appear as autocomplete recommendations. diff --git a/module.json b/module.json new file mode 100644 index 0000000..cbe68d9 --- /dev/null +++ b/module.json @@ -0,0 +1,29 @@ +{ + "id": "dh-attribution-sources", + "title": "Daggerheart Custom Attribution Sources", + "description": "Allows GMs to add, update, and remove custom attribution sources for items without modifying default system or other modules' attribution sources.", + "version": "1.0.0", + "compatibility": { + "minimum": "12", + "verified": "14" + }, + "authors": [ + { + "name": "cptn_cosmo" + } + ], + "relationships": { + "systems": [ + { + "id": "daggerheart", + "type": "system" + } + ] + }, + "esmodules": [ + "scripts/dh-attribution-sources.mjs" + ], + "styles": [ + "styles/dh-attribution-sources.css" + ] +} diff --git a/scripts/dh-attribution-sources.mjs b/scripts/dh-attribution-sources.mjs new file mode 100644 index 0000000..731b6a4 --- /dev/null +++ b/scripts/dh-attribution-sources.mjs @@ -0,0 +1,233 @@ +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; + +let registeredKeys = new Set(); + +/** + * Apply the custom attribution sources to the system CONFIG object. + */ +export function applyAttributionSources() { + if (!CONFIG.DH?.GENERAL?.attributionSources) return; + + // Remove previously registered custom sources + for (const key of registeredKeys) { + delete CONFIG.DH.GENERAL.attributionSources[key]; + } + registeredKeys.clear(); + + // Get current custom sources from setting + const customSources = game.settings.get("dh-attribution-sources", "customSources") || []; + + // Inject custom sources + for (const source of customSources) { + if (!source.id) continue; + const key = `custom_${source.id}`; + + CONFIG.DH.GENERAL.attributionSources[key] = { + label: source.label || source.id, + values: (source.values || []).map(val => ({ + label: val.label || "", + hint: val.hint || "" + })) + }; + registeredKeys.add(key); + } +} + +/** + * Custom application sheet to add, update, and remove custom attribution sources. + */ +export class CustomAttributionSourcesForm extends HandlebarsApplicationMixin(ApplicationV2) { + constructor(options = {}) { + super(options); + const saved = game.settings.get("dh-attribution-sources", "customSources") || []; + this.sources = foundry.utils.deepClone(saved); + } + + static DEFAULT_OPTIONS = { + tag: 'form', + id: 'dh-attribution-sources-settings', + classes: ['dh-attribution-sources', 'dialog', 'settings-app'], + position: { width: 620, height: 650 }, + window: { + title: "Custom Attribution Sources", + icon: 'fa-solid fa-signature', + resizable: true + }, + actions: { + addSource: CustomAttributionSourcesForm.#onAddSource, + removeSource: CustomAttributionSourcesForm.#onRemoveSource, + addValue: CustomAttributionSourcesForm.#onAddValue, + removeValue: CustomAttributionSourcesForm.#onRemoveValue, + reset: CustomAttributionSourcesForm.#onReset, + save: CustomAttributionSourcesForm.#onSave + }, + form: { + handler: CustomAttributionSourcesForm.#onSubmit, + submitOnChange: false, + closeOnSubmit: false + } + }; + + static PARTS = { + main: { + template: 'modules/dh-attribution-sources/templates/settings.hbs', + scrollable: ['.sources-list'] + }, + footer: { + template: 'modules/dh-attribution-sources/templates/footer.hbs' + } + }; + + async _prepareContext(options) { + const context = await super._prepareContext(options); + context.sources = this.sources; + return context; + } + + static #serializeForm(target) { + const form = target.closest('form'); + const formData = new foundry.applications.ux.FormDataExtended(form); + const data = foundry.utils.expandObject(formData.object); + + // Convert dot-notation structures back to nested arrays + const sources = Object.values(data.sources || {}).map(src => { + const values = Object.values(src.values || {}).map(val => ({ + label: val.label || "", + hint: val.hint || "" + })); + return { + id: src.id || "", + label: src.label || "", + values: values + }; + }); + + return sources; + } + + static async #onAddSource(event, target) { + const sources = CustomAttributionSourcesForm.#serializeForm(target); + sources.push({ + id: `group-${foundry.utils.randomID(4)}`, + label: "New Source Group", + values: [{ label: "", hint: "" }] + }); + this.sources = sources; + this.render(); + } + + static async #onRemoveSource(event, target) { + const sources = CustomAttributionSourcesForm.#serializeForm(target); + const index = parseInt(target.dataset.sourceIndex); + if (!isNaN(index)) { + sources.splice(index, 1); + } + this.sources = sources; + this.render(); + } + + static async #onAddValue(event, target) { + const sources = CustomAttributionSourcesForm.#serializeForm(target); + const index = parseInt(target.dataset.sourceIndex); + if (!isNaN(index)) { + sources[index].values.push({ label: "", hint: "" }); + } + this.sources = sources; + this.render(); + } + + static async #onRemoveValue(event, target) { + const sources = CustomAttributionSourcesForm.#serializeForm(target); + const sourceIndex = parseInt(target.dataset.sourceIndex); + const valueIndex = parseInt(target.dataset.valueIndex); + if (!isNaN(sourceIndex) && !isNaN(valueIndex)) { + sources[sourceIndex].values.splice(valueIndex, 1); + } + this.sources = sources; + this.render(); + } + + static async #onReset(event, target) { + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { + title: "Reset Custom Attribution Sources" + }, + content: "Are you sure you want to discard your unsaved changes and reload settings?" + }); + if (!confirmed) return; + + const saved = game.settings.get("dh-attribution-sources", "customSources") || []; + this.sources = foundry.utils.deepClone(saved); + this.render(); + } + + static async #onSave(event, target) { + const sources = CustomAttributionSourcesForm.#serializeForm(target); + + // Validation + const ids = new Set(); + for (const src of sources) { + const cleanId = src.id.trim(); + if (!cleanId) { + ui.notifications.error("All attribution source groups must have a non-empty ID."); + return; + } + if (ids.has(cleanId)) { + ui.notifications.error(`Duplicate ID found: "${cleanId}". Each attribution source group must have a unique ID.`); + return; + } + ids.add(cleanId); + + src.id = cleanId; + src.label = src.label.trim() || cleanId; + + if (src.values.length === 0) { + ui.notifications.error(`Group "${src.label}" must contain at least one attribution value.`); + return; + } + + for (const val of src.values) { + val.label = val.label.trim(); + val.hint = val.hint.trim(); + if (!val.label) { + ui.notifications.error(`Group "${src.label}" has an empty attribution label.`); + return; + } + } + } + + await game.settings.set("dh-attribution-sources", "customSources", sources); + applyAttributionSources(); + ui.notifications.info("Custom attribution sources saved successfully."); + this.close(); + } + + static async #onSubmit(event, form, formData) { + // Form submit falls through; handled by onSave button click + } +} + +// Hook into initialization to register the settings and configuration panel +Hooks.once('init', () => { + game.settings.register("dh-attribution-sources", "customSources", { + name: "Custom Attribution Sources List", + scope: "world", + config: false, + type: Array, + default: [] + }); + + game.settings.registerMenu("dh-attribution-sources", "manageSources", { + name: "Custom Attribution Sources", + label: "Manage Custom Sources", + hint: "Configure your custom item attribution sources.", + icon: "fas fa-signature", + type: CustomAttributionSourcesForm, + restricted: true + }); +}); + +// Setup hook to run once the system has registered its configuration +Hooks.on('setup', () => { + applyAttributionSources(); +}); diff --git a/styles/dh-attribution-sources.css b/styles/dh-attribution-sources.css new file mode 100644 index 0000000..1411c30 --- /dev/null +++ b/styles/dh-attribution-sources.css @@ -0,0 +1,347 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap'); + +/* Main application window styling */ +.dh-attribution-sources.settings-app { + --dh-font-family: 'Outfit', 'Signika', 'Helvetica Neue', Arial, sans-serif; + --dh-color-bg-dark: #121214; + --dh-color-bg-card: rgba(30, 30, 35, 0.65); + --dh-color-bg-input: rgba(10, 10, 12, 0.7); + --dh-color-border: rgba(255, 255, 255, 0.1); + --dh-color-border-hover: rgba(197, 160, 89, 0.4); + --dh-color-primary: #9e1b1b; + --dh-color-primary-hover: #c02222; + --dh-color-gold: #c5a059; + --dh-color-gold-hover: #dfb96c; + --dh-color-text-bright: #f0edf4; + --dh-color-text-muted: #a6a3ad; + --dh-color-error: #e74c3c; + --dh-color-error-hover: #ff6b6b; + --dh-color-success: #2ecc71; + --dh-color-success-hover: #2ee07e; + + font-family: var(--dh-font-family); + background: var(--dh-color-bg-dark) !important; + border: 1px solid rgba(197, 160, 89, 0.25) !important; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6) !important; +} + +/* Container */ +.dh-attribution-sources-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 8px; + box-sizing: border-box; + color: var(--dh-color-text-bright); +} + +.dh-attribution-sources-container .description { + font-size: 0.95rem; + line-height: 1.4; + color: var(--dh-color-text-muted); + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--dh-color-border); +} + +/* Scrollable source list */ +.dh-attribution-sources-container .sources-list { + flex: 1; + overflow-y: auto; + padding-right: 4px; + margin-bottom: 16px; + min-height: 250px; +} + +/* Source Group Card */ +.dh-attribution-sources-container .source-group-card { + background: var(--dh-color-bg-card); + border: 1px solid var(--dh-color-border); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + position: relative; + transition: border-color 0.25s ease, box-shadow 0.25s ease; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(4px); +} + +.dh-attribution-sources-container .source-group-card:hover { + border-color: var(--dh-color-border-hover); + box-shadow: 0 4px 15px rgba(197, 160, 89, 0.15); +} + +/* Group Header layout */ +.dh-attribution-sources-container .group-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px dashed var(--dh-color-border); +} + +.dh-attribution-sources-container .group-title-row { + display: flex; + gap: 16px; + flex: 1; +} + +.dh-attribution-sources-container .group-title-row .form-group { + margin: 0; + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.dh-attribution-sources-container label.compact-label { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--dh-color-gold); +} + +/* Custom inputs */ +.dh-attribution-sources-container input[type="text"] { + background: var(--dh-color-bg-input) !important; + border: 1px solid rgba(255, 255, 255, 0.15) !important; + color: var(--dh-color-text-bright) !important; + padding: 6px 10px !important; + border-radius: 4px !important; + width: 100%; + height: 32px !important; + transition: border-color 0.2s, box-shadow 0.2s; + font-family: var(--dh-font-family); + box-sizing: border-box; +} + +.dh-attribution-sources-container input[type="text"]:focus { + border-color: var(--dh-color-gold) !important; + box-shadow: 0 0 6px rgba(197, 160, 89, 0.4) !important; + outline: none; +} + +/* Delete group button styling */ +.dh-attribution-sources-container .remove-group-btn { + background: transparent !important; + border: 1px solid rgba(231, 76, 60, 0.3) !important; + color: var(--dh-color-error) !important; + padding: 8px 10px !important; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + align-self: flex-end; + margin-bottom: 2px; + width: 36px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.dh-attribution-sources-container .remove-group-btn:hover { + background: rgba(231, 76, 60, 0.15) !important; + border-color: var(--dh-color-error-hover) !important; + color: var(--dh-color-error-hover) !important; + box-shadow: 0 0 8px rgba(231, 76, 60, 0.3); +} + +/* Values section */ +.dh-attribution-sources-container .values-section { + padding-left: 8px; +} + +.dh-attribution-sources-container .values-section h6 { + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--dh-color-text-bright); + margin: 0 0 10px 0; + border-left: 2px solid var(--dh-color-gold); + padding-left: 8px; +} + +.dh-attribution-sources-container .values-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +/* Single value row styling */ +.dh-attribution-sources-container .value-row { + display: flex; + gap: 10px; + align-items: center; +} + +.dh-attribution-sources-container .value-row .form-group.val-input { + margin: 0; + flex: 1; +} + +/* Remove single value button styling */ +.dh-attribution-sources-container .remove-value-btn { + background: transparent !important; + border: 1px solid rgba(231, 76, 60, 0.2) !important; + color: var(--dh-color-error) !important; + width: 28px; + height: 28px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + padding: 0 !important; +} + +.dh-attribution-sources-container .remove-value-btn:hover { + background: rgba(231, 76, 60, 0.1) !important; + border-color: var(--dh-color-error-hover) !important; + color: var(--dh-color-error-hover) !important; +} + +/* Buttons */ +.dh-attribution-sources-container .add-value-btn { + background: rgba(197, 160, 89, 0.1) !important; + border: 1px dashed var(--dh-color-gold) !important; + color: var(--dh-color-gold) !important; + font-size: 0.8rem; + font-weight: 500; + padding: 5px 12px !important; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.dh-attribution-sources-container .add-value-btn:hover { + background: rgba(197, 160, 89, 0.2) !important; + border-style: solid !important; + color: var(--dh-color-text-bright) !important; + box-shadow: 0 0 6px rgba(197, 160, 89, 0.2); +} + +.dh-attribution-sources-container .add-group-btn { + background: var(--dh-color-primary) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + color: white !important; + font-size: 0.9rem; + font-weight: 600; + padding: 8px 16px !important; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.dh-attribution-sources-container .add-group-btn:hover { + background: var(--dh-color-primary-hover) !important; + transform: translateY(-1px); + box-shadow: 0 6px 12px rgba(158, 27, 27, 0.4); +} + +.dh-attribution-sources-container .form-actions-bar { + display: flex; + justify-content: center; + padding-top: 8px; + border-top: 1px solid var(--dh-color-border); +} + +/* Empty State */ +.dh-attribution-sources-container .no-sources-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + background: var(--dh-color-bg-card); + border: 1px dashed var(--dh-color-border); + border-radius: 8px; + color: var(--dh-color-text-muted); +} + +.dh-attribution-sources-container .no-sources-message i { + color: var(--dh-color-gold); + margin-bottom: 12px; + opacity: 0.7; +} + +/* Form Footer styling */ +#dh-attribution-sources-settings footer.form-footer { + display: flex; + gap: 12px; + padding: 12px 16px; + background: rgba(15, 15, 18, 0.95); + border-top: 1px solid rgba(197, 160, 89, 0.25); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; +} + +#dh-attribution-sources-settings footer.form-footer button { + flex: 1; + font-family: var(--dh-font-family); + font-size: 0.9rem; + font-weight: 600; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +#dh-attribution-sources-settings footer.form-footer button[data-action="reset"] { + background: transparent !important; + border: 1px solid var(--dh-color-border) !important; + color: var(--dh-color-text-muted) !important; +} + +#dh-attribution-sources-settings footer.form-footer button[data-action="reset"]:hover { + background: rgba(255, 255, 255, 0.05) !important; + color: var(--dh-color-text-bright) !important; + border-color: rgba(255, 255, 255, 0.2) !important; +} + +#dh-attribution-sources-settings footer.form-footer button.save-btn { + background: var(--dh-color-gold) !important; + border: 1px solid rgba(0, 0, 0, 0.15) !important; + color: #121214 !important; +} + +#dh-attribution-sources-settings footer.form-footer button.save-btn:hover { + background: var(--dh-color-gold-hover) !important; + box-shadow: 0 0 10px rgba(197, 160, 89, 0.4); +} + +/* Custom Scrollbar for modern aesthetics */ +.dh-attribution-sources-container .sources-list::-webkit-scrollbar { + width: 6px; +} + +.dh-attribution-sources-container .sources-list::-webkit-scrollbar-track { + background: transparent; +} + +.dh-attribution-sources-container .sources-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} + +.dh-attribution-sources-container .sources-list::-webkit-scrollbar-thumb:hover { + background: var(--dh-color-gold); +} diff --git a/templates/footer.hbs b/templates/footer.hbs new file mode 100644 index 0000000..b256936 --- /dev/null +++ b/templates/footer.hbs @@ -0,0 +1,8 @@ + diff --git a/templates/settings.hbs b/templates/settings.hbs new file mode 100644 index 0000000..f1c3032 --- /dev/null +++ b/templates/settings.hbs @@ -0,0 +1,66 @@ +
+

+ Manage custom attribution sources. These groups and values will populate the autocomplete suggestions when editing an item's attribution. +

+ +
+ {{#each sources as |source sourceIndex|}} +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+
Attribution Values
+
+ +
+ {{#each source.values as |val valIndex|}} +
+
+ +
+
+ +
+ +
+ {{/each}} +
+ +
+ +
+
+
+ {{else}} +
+

+

No custom attribution sources configured. Click below to add your first one!

+
+ {{/each}} +
+ +
+ +
+