diff --git a/nx/public/plugins/tags.html b/nx/public/plugins/tags.html
new file mode 100644
index 00000000..a61521e9
--- /dev/null
+++ b/nx/public/plugins/tags.html
@@ -0,0 +1,20 @@
+
+
+
+ DA App SDK Sample
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/nx/public/plugins/tags/tag-browser.css b/nx/public/plugins/tags/tag-browser.css
new file mode 100644
index 00000000..55f93713
--- /dev/null
+++ b/nx/public/plugins/tags/tag-browser.css
@@ -0,0 +1,112 @@
+:host {
+ font-family: 'Adobe Clean', adobe-clean, sans-serif;
+ }
+
+ .tag-browser {
+ display: block;
+ position: relative;
+ overflow: hidden;
+ width: 100%;
+ height: 100vh;
+ }
+
+ .tag-browser .search-details {
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ line-height: 36px;
+ font-size: 16px;
+ padding: 0 12px;
+ }
+
+ .tag-browser .tag-search input[type="text"] {
+ flex: 1;
+ height: 32px;
+ padding: 0 8px;
+ font-size: 16px;
+ font-family: 'Adobe Clean', adobe-clean, sans-serif;
+ border: 1px solid #d1d1d1;
+ border-radius: 2px;
+ box-sizing: border-box;
+ }
+
+ .tag-browser .tag-groups {
+ display: flex;
+ overflow: auto;
+ position: absolute;
+ width: 100%;
+ height: calc(100% - 36px);
+ }
+
+ .tag-browser ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ .tag-browser .tag-group-column {
+ flex-shrink: 0;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ max-width: 280px;
+ padding: 0 12px;
+ box-sizing: border-box;
+ }
+
+ .tag-browser .tag-group-column:first-child {
+ padding: 0 12px;
+ }
+
+ .tag-browser .tag-group .tag-details {
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ line-height: 36px;
+ }
+
+ .tag-browser .tag-group .tag-title {
+ flex: 1;
+ padding: 0 6px;
+ font-size: 16px;
+ font-family: 'Adobe Clean', adobe-clean, sans-serif;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ background: none;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ }
+
+ .tag-browser .tag-search button,
+ .tag-browser .tag-details button {
+ cursor: pointer;
+ height: 32px;
+ width: 32px;
+ margin: 2px;
+ padding: 0;
+ border: none;
+ display: block;
+ border-radius: 2px;
+ background-color: #efefef;
+ }
+
+ .tag-browser .tag-group .tag-title:hover {
+ background-color: #efefef;
+ }
+
+ .tag-browser .tag-search button:hover,
+ .tag-browser .tag-group button:hover,
+ .tag-browser .tag-group .tag-title.active {
+ background-color: #e9e9e9;
+ }
+
+ .tag-browser .tag-search::after,
+ .tag-browser .tag-group::after {
+ content: "";
+ display: block;
+ height: 1px;
+ background: #d1d1d1;
+ }
\ No newline at end of file
diff --git a/nx/public/plugins/tags/tag-browser.js b/nx/public/plugins/tags/tag-browser.js
new file mode 100644
index 00000000..6a6de953
--- /dev/null
+++ b/nx/public/plugins/tags/tag-browser.js
@@ -0,0 +1,164 @@
+/* eslint-disable no-underscore-dangle, import/no-unresolved */
+import { LitElement, html, nothing } from 'https://da.live/nx/deps/lit/lit-core.min.js';
+import getStyle from 'https://da.live/nx/utils/styles.js';
+
+const style = await getStyle(import.meta.url);
+
+class DaTagBrowser extends LitElement {
+ static properties = {
+ rootTags: { type: Array },
+ actions: { type: Object },
+ getTags: { type: Function },
+ tagValue: { type: String },
+ _tags: { state: true },
+ _activeTag: { state: true },
+ _searchQuery: { state: true },
+ _secondaryTags: { state: true },
+ };
+
+ constructor() {
+ super();
+ this._tags = [];
+ this._activeTag = {};
+ this._searchQuery = '';
+ this._secondaryTags = false;
+ }
+
+ getTagSegments() {
+ return (this._activeTag.activeTag ? this._activeTag.activeTag.split('/') : []).concat(this._activeTag.name);
+ }
+
+ getTagValue() {
+ if (this.tagValue === 'title') return this._activeTag.title;
+ const tagSegments = this.getTagSegments();
+ return tagSegments.join(tagSegments.length > 2 ? '/' : ':').replace('/', ':');
+ }
+
+ handleBlur() {
+ this._secondaryTags = false;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ this.addEventListener('blur', this.handleBlur, true);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener('blur', this.handleBlur, true);
+ super.disconnectedCallback();
+ }
+
+ updated(changedProperties) {
+ if (changedProperties.has('rootTags')) {
+ this._tags = [this.rootTags];
+ this._activeTag = {};
+ }
+
+ if (changedProperties.has('_tags')) {
+ setTimeout(() => {
+ const groups = this.renderRoot.querySelector('.tag-groups');
+ if (!groups) return;
+ const firstTag = groups.lastElementChild?.querySelector('.tag-title');
+ firstTag?.focus();
+ groups.scrollTo({ left: groups.scrollWidth, behavior: 'smooth' });
+ }, 100);
+ }
+ }
+
+ async handleTagClick(tag, idx) {
+ this._activeTag = tag;
+ if (!this.getTags) return;
+ const newTags = await this.getTags(tag);
+ if (!newTags || newTags.length === 0) return;
+ this._tags = [...this._tags.toSpliced(idx + 1), newTags];
+ }
+
+ handleTagInsert(tag) {
+ this._activeTag = tag;
+ const tagValue = this._secondaryTags ? `, ${this.getTagValue()}` : this.getTagValue();
+ this.actions.sendText(tagValue);
+ this._secondaryTags = true;
+ }
+
+ handleBackClick() {
+ if (this._tags.length === 0) return;
+ this._tags = this._tags.slice(0, -1);
+ this._activeTag = this._tags[this._tags.length - 1]
+ .find((tag) => this._activeTag.activeTag.includes(tag.name)) || {};
+ }
+
+ handleSearchInput(event) {
+ this._searchQuery = event.target.value.toLowerCase();
+ }
+
+ filterTags(tags) {
+ if (!this._searchQuery) return tags;
+ return tags.filter((tag) => tag.title.toLowerCase().includes(this._searchQuery));
+ }
+
+ renderSearchBar() {
+ return html`
+
+ `;
+ }
+
+ renderTag(tag, idx) {
+ const active = this.getTagSegments()[idx] === tag.name;
+ return html`
+
+
+
+
+
+
+ `;
+ }
+
+ renderTagGroup(group, idx) {
+ const filteredGroup = this.filterTags(group);
+ return html`
+
+ ${filteredGroup.map((tag) => this.renderTag(tag, idx))}
+
+ `;
+ }
+
+ render() {
+ if (this._tags.length === 0) return nothing;
+ return html`
+
+ ${this.renderSearchBar()}
+
+ ${this._tags.map((group, idx) => html`
+ -
+ ${this.renderTagGroup(group, idx)}
+
+ `)}
+
+
+ `;
+ }
+}
+
+customElements.define('da-tag-browser', DaTagBrowser);
diff --git a/nx/public/plugins/tags/tags.js b/nx/public/plugins/tags/tags.js
new file mode 100644
index 00000000..97dccf2a
--- /dev/null
+++ b/nx/public/plugins/tags/tags.js
@@ -0,0 +1,130 @@
+/* eslint-disable import/no-unresolved */
+import DA_SDK from 'https://da.live/nx/utils/sdk.js';
+import { DA_ORIGIN } from 'https://da.live/nx/public/utils/constants.js';
+import './tag-browser.js';
+
+const ROOT_TAG_PATH = '/content/cq:tags';
+const UI_TAG_PATH = '/ui#/aem/aem/tags';
+const TAG_EXT = '.1.json';
+
+async function getAemRepo(project, opts) {
+ const configUrl = `${DA_ORIGIN}/config/${project.org}/${project.repo}`;
+ const resp = await fetch(configUrl, opts);
+ if (!resp.ok) return null;
+ const json = await resp.json();
+
+ // Get all config values that might affect behavior
+ const configs = json.data.data.reduce((acc, entry) => {
+ acc[entry.key] = entry.value;
+ return acc;
+ }, {});
+
+ // If configs aren't set at all, use defaults
+ const requiresAuth = configs['aem.tags.requiresAuth'] === undefined ? true : configs['aem.tags.requiresAuth'] !== 'false';
+ const tagExt = configs['aem.tags.extension'] || TAG_EXT;
+
+ return {
+ aemRepo: configs['aem.repositoryId'],
+ namespaces: configs['aem.tags.namespaces'],
+ requiresAuth,
+ tagExt,
+ };
+}
+
+async function getTags(path, opts, aemConfig) {
+ const activeTag = path.split('cq:tags').pop().replace(aemConfig.tagExt, '').slice(1);
+ const resp = await fetch(path, opts);
+ if (!resp.ok) return null;
+ const json = await resp.json();
+ const tags = Object.keys(json).reduce((acc, key) => {
+ if (json[key]['jcr:primaryType'] === 'cq:Tag') {
+ acc.push({
+ path: `${path.replace(aemConfig.tagExt, '')}/${key}${aemConfig.tagExt}`,
+ activeTag,
+ name: key,
+ title: json[key]['jcr:title'] || key,
+ details: json[key],
+ });
+ }
+ return acc;
+ }, []);
+
+ return tags;
+}
+
+const getRootTags = async (namespaces, aemConfig, opts) => {
+ const createTagUrl = (namespace = '') => `https://${aemConfig.aemRepo}${ROOT_TAG_PATH}${namespace ? `/${namespace}` : ''}${aemConfig.tagExt}`;
+
+ if (namespaces.length === 0) {
+ return getTags(createTagUrl(), opts, aemConfig).catch(() => null);
+ }
+
+ if (namespaces.length === 1) {
+ const namespace = namespaces[0].toLowerCase().replaceAll(' ', '-');
+ return getTags(createTagUrl(namespace), opts, aemConfig).catch(() => null);
+ }
+
+ return namespaces.map((title) => {
+ const namespace = title.toLowerCase().replaceAll(' ', '-');
+ return {
+ path: createTagUrl(namespace),
+ name: namespace,
+ title,
+ activeTag: '',
+ details: {},
+ };
+ });
+};
+
+function showError(message, link = null) {
+ const mainElement = document.body.querySelector('main');
+ const errorMessage = document.createElement('p');
+ errorMessage.textContent = message;
+
+ if (link) {
+ const linkEl = document.createElement('a');
+ linkEl.textContent = 'View Here';
+ linkEl.href = link;
+ linkEl.target = '_blank';
+ errorMessage.append(linkEl);
+ }
+
+ const reloadButton = document.createElement('button');
+ reloadButton.textContent = 'Reload';
+ reloadButton.addEventListener('click', () => window.location.reload());
+
+ mainElement.append(errorMessage, reloadButton);
+}
+
+(async function init() {
+ const { context, actions, token } = await DA_SDK.catch(() => null);
+ if (!context || !actions || !token) {
+ showError('Please log in to view tags.');
+ return;
+ }
+
+ const opts = { headers: { Authorization: `Bearer ${token}` } };
+ const aemConfig = await getAemRepo(context, opts).catch(() => null);
+ if (!aemConfig || !aemConfig.aemRepo) {
+ showError('Failed to retrieve config. ', `https://da.live/config#/${context.org}/${context.repo}/`);
+ return;
+ }
+
+ // Only use auth for tags if requiresAuth is true
+ const tagOpts = aemConfig.requiresAuth ? opts : {};
+ const namespaces = aemConfig?.namespaces.split(',').map((namespace) => namespace.trim()) || [];
+ const rootTags = await getRootTags(namespaces, aemConfig, tagOpts);
+
+ if (!rootTags || rootTags.length === 0) {
+ showError('Could not load tags. ', `https://${aemConfig.aemRepo}${UI_TAG_PATH}`);
+ return;
+ }
+
+ const daTagBrowser = document.createElement('da-tag-browser');
+ daTagBrowser.tabIndex = 0;
+ daTagBrowser.rootTags = rootTags;
+ daTagBrowser.getTags = async (tag) => getTags(tag.path, tagOpts, aemConfig);
+ daTagBrowser.tagValue = aemConfig.namespaces ? 'title' : 'path';
+ daTagBrowser.actions = actions;
+ document.body.querySelector('main').append(daTagBrowser);
+}());