diff --git a/.eslintrc.js b/.eslintrc.js
index 690fc606a..69b8d1e1c 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -8,6 +8,7 @@ module.exports = {
 		'jsdoc/require-param-type': ['off'],
 		'jsdoc/check-param-names': ['off'],
 		'jsdoc/no-undefined-types': ['off'],
-		'jsdoc/require-property-description' : ['off']
+		'jsdoc/require-property-description': ['off'],
+		'import/no-named-as-default-member': ['off']
 	},
 }
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 53eb60258..edbd2b3a2 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -36,7 +36,6 @@
 use OCA\Deck\Db\AssignmentMapper;
 use OCA\Deck\Db\BoardMapper;
 use OCA\Deck\Db\CardMapper;
-use OCA\Deck\Db\User;
 use OCA\Deck\Event\AclCreatedEvent;
 use OCA\Deck\Event\AclDeletedEvent;
 use OCA\Deck\Event\AclUpdatedEvent;
@@ -48,6 +47,7 @@
 use OCA\Deck\Middleware\DefaultBoardMiddleware;
 use OCA\Deck\Middleware\ExceptionMiddleware;
 use OCA\Deck\Notification\Notifier;
+use OCA\Deck\Reference\CardReferenceProvider;
 use OCA\Deck\Search\CardCommentProvider;
 use OCA\Deck\Search\DeckProvider;
 use OCA\Deck\Service\PermissionService;
@@ -58,6 +58,7 @@
 use OCP\AppFramework\Bootstrap\IBootstrap;
 use OCP\AppFramework\Bootstrap\IRegistrationContext;
 use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
+use OCP\Collaboration\Reference\RenderReferenceEvent;
 use OCP\Collaboration\Resources\IProviderManager;
 use OCP\Comments\CommentsEntityEvent;
 use OCP\Comments\ICommentsManager;
@@ -83,6 +84,14 @@ class Application extends App implements IBootstrap {
 
 	public function __construct(array $urlParams = []) {
 		parent::__construct(self::APP_ID, $urlParams);
+
+		// TODO move this back to ::register after fixing the autoload issue
+		// (and use a listener class)
+		$container = $this->getContainer();
+		$eventDispatcher = $container->get(IEventDispatcher::class);
+		$eventDispatcher->addListener(RenderReferenceEvent::class, function () {
+			Util::addScript(self::APP_ID, self::APP_ID . '-card-reference');
+		});
 	}
 
 	public function boot(IBootContext $context): void {
@@ -121,8 +130,12 @@ public function register(IRegistrationContext $context): void {
 		$context->registerSearchProvider(CardCommentProvider::class);
 		$context->registerDashboardWidget(DeckWidget::class);
 
+		// reference widget
+		$context->registerReferenceProvider(CardReferenceProvider::class);
+		// $context->registerEventListener(RenderReferenceEvent::class, CardReferenceListener::class);
+
 		$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
-		
+
 		// Event listening for full text search indexing
 		$context->registerEventListener(CardCreatedEvent::class, FullTextSearchEventListener::class);
 		$context->registerEventListener(CardUpdatedEvent::class, FullTextSearchEventListener::class);
diff --git a/lib/Reference/CardReferenceProvider.php b/lib/Reference/CardReferenceProvider.php
new file mode 100644
index 000000000..a4e4a9c8b
--- /dev/null
+++ b/lib/Reference/CardReferenceProvider.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * @copyright Copyright (c) 2022 Julien Veyssier <eneiluj@posteo.net>
+ *
+ * @author Julien Veyssier <eneiluj@posteo.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace OCA\Deck\Reference;
+
+use OC\Collaboration\Reference\Reference;
+use OCA\Deck\AppInfo\Application;
+use OCA\Deck\Service\BoardService;
+use OCA\Deck\Service\CardService;
+use OCA\Deck\Service\StackService;
+use OCP\Collaboration\Reference\IReference;
+use OCP\Collaboration\Reference\IReferenceProvider;
+use OCP\IURLGenerator;
+
+class CardReferenceProvider implements IReferenceProvider {
+	private CardService $cardService;
+	private IURLGenerator $urlGenerator;
+	private BoardService $boardService;
+	private StackService $stackService;
+
+	public function __construct(CardService $cardService,
+								BoardService $boardService,
+								StackService $stackService,
+								IURLGenerator $urlGenerator,
+								?string $userId) {
+		$this->cardService = $cardService;
+		$this->urlGenerator = $urlGenerator;
+		$this->boardService = $boardService;
+		$this->stackService = $stackService;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	public function matchReference(string $referenceText): bool {
+		$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_ID);
+		$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID);
+
+		// link example: https://nextcloud.local/index.php/apps/deck/#/board/2/card/11
+		$noIndexMatch = preg_match('/^' . preg_quote($start, '/') . '\/#\/board\/[0-9]+\/card\/[0-9]+$/', $referenceText) === 1;
+		$indexMatch = preg_match('/^' . preg_quote($startIndex, '/') . '\/#\/board\/[0-9]+\/card\/[0-9]+$/', $referenceText) === 1;
+
+		return $noIndexMatch || $indexMatch;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	public function resolveReference(string $referenceText): ?IReference {
+		if ($this->matchReference($referenceText)) {
+			$cardIds = $this->getBoardCardId($referenceText);
+			if ($cardIds !== null) {
+				[$boardId, $cardId] = $cardIds;
+				$card = $this->cardService->find((int) $cardId);
+				$board = $this->boardService->find((int) $boardId);
+				$stack = $this->stackService->find((int) $card->jsonSerialize()['stackId']);
+				$reference = new Reference($referenceText);
+				$reference->setRichObject(Application::APP_ID . '-card', [
+					'card' => $card,
+					'board' => $board,
+					'stack' => $stack,
+				]);
+				return $reference;
+			}
+		}
+
+		return null;
+	}
+
+	private function getBoardCardId(string $url): ?array {
+		$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_ID);
+		$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID);
+
+		preg_match('/^' . preg_quote($start, '/') . '\/#\/board\/([0-9]+)\/card\/([0-9]+)$/', $url, $matches);
+		if ($matches && count($matches) > 2) {
+			return [$matches[1], $matches[2]];
+		}
+
+		preg_match('/^' . preg_quote($startIndex, '/') . '\/#\/board\/([0-9]+)\/card\/([0-9]+)$/', $url, $matches2);
+		if ($matches2 && count($matches2) > 2) {
+			return [$matches2[1], $matches2[2]];
+		}
+
+		return null;
+	}
+
+	public function getCachePrefix(string $referenceId): string {
+		return $referenceId;
+	}
+
+	public function getCacheKey(string $referenceId): ?string {
+		return null;
+	}
+}
diff --git a/package-lock.json b/package-lock.json
index dfa2caf66..fe9550132 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
         "@nextcloud/router": "^2.0.0",
         "@nextcloud/vue": "^6.0.0-beta.4",
         "@nextcloud/vue-dashboard": "^2.0.1",
+        "@nextcloud/vue-richtext": "^2.0.0",
         "blueimp-md5": "^2.19.0",
         "dompurify": "^2.4.0",
         "lodash": "^4.17.21",
@@ -53,8 +54,10 @@
         "@relative-ci/agent": "^4.1.0",
         "@vue/test-utils": "^1.3.0",
         "cypress": "^10.7.0",
+        "eslint-webpack-plugin": "^3.2.0",
         "jest": "^29.0.1",
         "jest-serializer-vue": "^2.0.2",
+        "stylelint-webpack-plugin": "^3.3.0",
         "vue-jest": "^3.0.7",
         "vue-template-compiler": "^2.7.9"
       },
@@ -3497,6 +3500,24 @@
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
     },
+    "node_modules/@nextcloud/vue-richtext": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@nextcloud/vue-richtext/-/vue-richtext-2.0.0.tgz",
+      "integrity": "sha512-Z/AbweVmIB8shWZVLI0kUPrJnkCBlU5xIkkfv+RPFepLY7eZ+ttm5HRhLGqgFyXFNf4RIM7yGt/l6K35XcEX2A==",
+      "dependencies": {
+        "@nextcloud/axios": "^2.0.0",
+        "@nextcloud/router": "^2.0.0",
+        "clone": "^2.1.2",
+        "vue": "^2.7.8"
+      },
+      "engines": {
+        "node": ">=14.0.0",
+        "npm": ">=7.0.0"
+      },
+      "peerDependencies": {
+        "vue": "^2.7.8"
+      }
+    },
     "node_modules/@nextcloud/vue/node_modules/ansi-regex": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@@ -3600,7 +3621,6 @@
       "version": "2.1.4",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@nodelib/fs.stat": "2.0.4",
         "run-parallel": "^1.1.9"
@@ -3613,7 +3633,6 @@
       "version": "2.0.4",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">= 8"
       }
@@ -3622,7 +3641,6 @@
       "version": "1.2.6",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@nodelib/fs.scandir": "2.1.4",
         "fastq": "^1.6.0"
@@ -3798,10 +3816,9 @@
       }
     },
     "node_modules/@types/eslint": {
-      "version": "7.28.2",
-      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.2.tgz",
-      "integrity": "sha512-KubbADPkfoU75KgKeKLsFHXnU4ipH7wYg0TRT33NK3N3yiu7jlFAAoygIWBV+KbuHx/G+AvuGX6DllnK35gfJA==",
-      "peer": true,
+      "version": "8.4.6",
+      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz",
+      "integrity": "sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==",
       "dependencies": {
         "@types/estree": "*",
         "@types/json-schema": "*"
@@ -4463,7 +4480,6 @@
       "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
       "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "ajv": "^8.0.0"
       },
@@ -4481,7 +4497,6 @@
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz",
       "integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "fast-deep-equal": "^3.1.1",
         "json-schema-traverse": "^1.0.0",
@@ -4497,8 +4512,7 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "node_modules/ajv-keywords": {
       "version": "3.5.2",
@@ -4636,7 +4650,6 @@
       "version": "2.1.0",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=8"
       }
@@ -7438,7 +7451,6 @@
       "version": "3.0.1",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "path-type": "^4.0.0"
       },
@@ -7450,7 +7462,6 @@
       "version": "4.0.0",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=8"
       }
@@ -8450,6 +8461,121 @@
         "node": ">=10"
       }
     },
+    "node_modules/eslint-webpack-plugin": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz",
+      "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==",
+      "dev": true,
+      "dependencies": {
+        "@types/eslint": "^7.29.0 || ^8.4.1",
+        "jest-worker": "^28.0.2",
+        "micromatch": "^4.0.5",
+        "normalize-path": "^3.0.0",
+        "schema-utils": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 12.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "eslint": "^7.0.0 || ^8.0.0",
+        "webpack": "^5.0.0"
+      }
+    },
+    "node_modules/eslint-webpack-plugin/node_modules/ajv": {
+      "version": "8.11.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+      "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+      "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3"
+      },
+      "peerDependencies": {
+        "ajv": "^8.8.2"
+      }
+    },
+    "node_modules/eslint-webpack-plugin/node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/eslint-webpack-plugin/node_modules/jest-worker": {
+      "version": "28.1.3",
+      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz",
+      "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "merge-stream": "^2.0.0",
+        "supports-color": "^8.0.0"
+      },
+      "engines": {
+        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
+      }
+    },
+    "node_modules/eslint-webpack-plugin/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true
+    },
+    "node_modules/eslint-webpack-plugin/node_modules/schema-utils": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz",
+      "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==",
+      "dev": true,
+      "dependencies": {
+        "@types/json-schema": "^7.0.9",
+        "ajv": "^8.8.0",
+        "ajv-formats": "^2.1.1",
+        "ajv-keywords": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 12.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
+    "node_modules/eslint-webpack-plugin/node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
     "node_modules/eslint/node_modules/ansi-regex": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -9068,7 +9194,6 @@
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
       "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@nodelib/fs.stat": "^2.0.2",
         "@nodelib/fs.walk": "^1.2.3",
@@ -9105,7 +9230,6 @@
       "version": "1.11.0",
       "dev": true,
       "license": "ISC",
-      "peer": true,
       "dependencies": {
         "reusify": "^1.0.4"
       }
@@ -9625,7 +9749,6 @@
       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
       "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "is-glob": "^4.0.1"
       },
@@ -9711,7 +9834,6 @@
       "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
       "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "array-union": "^2.1.0",
         "dir-glob": "^3.0.1",
@@ -10223,7 +10345,6 @@
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
       "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
       "dev": true,
-      "peer": true,
       "engines": {
         "node": ">= 4"
       }
@@ -10533,7 +10654,6 @@
       "version": "2.1.1",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -10590,7 +10710,6 @@
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
       "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "is-extglob": "^2.1.1"
       },
@@ -13815,7 +13934,6 @@
       "version": "1.4.1",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">= 8"
       }
@@ -15452,8 +15570,7 @@
           "url": "https://feross.org/support"
         }
       ],
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/quick-lru": {
       "version": "4.0.1",
@@ -15866,7 +15983,6 @@
       "version": "2.0.2",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -15964,7 +16080,6 @@
       "version": "1.0.4",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "iojs": ">=1.0.0",
         "node": ">=0.10.0"
@@ -16020,7 +16135,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "queue-microtask": "^1.2.2"
       }
@@ -17112,6 +17226,121 @@
         "stylelint": "^14.5.1"
       }
     },
+    "node_modules/stylelint-webpack-plugin": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/stylelint-webpack-plugin/-/stylelint-webpack-plugin-3.3.0.tgz",
+      "integrity": "sha512-F53bapIZ9zI16ero8IWm6TrUE6SSibZBphJE9b5rR2FxtvmGmm1YmS+a5xjQzn63+cv71GVSCu4byX66fBLpEw==",
+      "dev": true,
+      "dependencies": {
+        "globby": "^11.1.0",
+        "jest-worker": "^28.1.0",
+        "micromatch": "^4.0.5",
+        "normalize-path": "^3.0.0",
+        "schema-utils": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 12.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "stylelint": "^13.0.0 || ^14.0.0",
+        "webpack": "^5.0.0"
+      }
+    },
+    "node_modules/stylelint-webpack-plugin/node_modules/ajv": {
+      "version": "8.11.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+      "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/stylelint-webpack-plugin/node_modules/ajv-keywords": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+      "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3"
+      },
+      "peerDependencies": {
+        "ajv": "^8.8.2"
+      }
+    },
+    "node_modules/stylelint-webpack-plugin/node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/stylelint-webpack-plugin/node_modules/jest-worker": {
+      "version": "28.1.3",
+      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz",
+      "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "merge-stream": "^2.0.0",
+        "supports-color": "^8.0.0"
+      },
+      "engines": {
+        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
+      }
+    },
+    "node_modules/stylelint-webpack-plugin/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true
+    },
+    "node_modules/stylelint-webpack-plugin/node_modules/schema-utils": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz",
+      "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==",
+      "dev": true,
+      "dependencies": {
+        "@types/json-schema": "^7.0.9",
+        "ajv": "^8.8.0",
+        "ajv-formats": "^2.1.1",
+        "ajv-keywords": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 12.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
+    "node_modules/stylelint-webpack-plugin/node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
     "node_modules/stylelint/node_modules/@csstools/selector-specificity": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz",
@@ -21677,6 +21906,17 @@
         }
       }
     },
+    "@nextcloud/vue-richtext": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@nextcloud/vue-richtext/-/vue-richtext-2.0.0.tgz",
+      "integrity": "sha512-Z/AbweVmIB8shWZVLI0kUPrJnkCBlU5xIkkfv+RPFepLY7eZ+ttm5HRhLGqgFyXFNf4RIM7yGt/l6K35XcEX2A==",
+      "requires": {
+        "@nextcloud/axios": "^2.0.0",
+        "@nextcloud/router": "^2.0.0",
+        "clone": "^2.1.2",
+        "vue": "^2.7.8"
+      }
+    },
     "@nextcloud/webpack-vue-config": {
       "version": "5.3.0",
       "resolved": "https://registry.npmjs.org/@nextcloud/webpack-vue-config/-/webpack-vue-config-5.3.0.tgz",
@@ -21687,7 +21927,6 @@
     "@nodelib/fs.scandir": {
       "version": "2.1.4",
       "dev": true,
-      "peer": true,
       "requires": {
         "@nodelib/fs.stat": "2.0.4",
         "run-parallel": "^1.1.9"
@@ -21695,13 +21934,11 @@
     },
     "@nodelib/fs.stat": {
       "version": "2.0.4",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "@nodelib/fs.walk": {
       "version": "1.2.6",
       "dev": true,
-      "peer": true,
       "requires": {
         "@nodelib/fs.scandir": "2.1.4",
         "fastq": "^1.6.0"
@@ -21857,10 +22094,9 @@
       }
     },
     "@types/eslint": {
-      "version": "7.28.2",
-      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.2.tgz",
-      "integrity": "sha512-KubbADPkfoU75KgKeKLsFHXnU4ipH7wYg0TRT33NK3N3yiu7jlFAAoygIWBV+KbuHx/G+AvuGX6DllnK35gfJA==",
-      "peer": true,
+      "version": "8.4.6",
+      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz",
+      "integrity": "sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==",
       "requires": {
         "@types/estree": "*",
         "@types/json-schema": "*"
@@ -22455,7 +22691,6 @@
       "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
       "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
       "dev": true,
-      "peer": true,
       "requires": {
         "ajv": "^8.0.0"
       },
@@ -22465,7 +22700,6 @@
           "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz",
           "integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==",
           "dev": true,
-          "peer": true,
           "requires": {
             "fast-deep-equal": "^3.1.1",
             "json-schema-traverse": "^1.0.0",
@@ -22477,8 +22711,7 @@
           "version": "1.0.0",
           "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
           "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
-          "dev": true,
-          "peer": true
+          "dev": true
         }
       }
     },
@@ -22567,8 +22800,7 @@
     },
     "array-union": {
       "version": "2.1.0",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "array.prototype.flat": {
       "version": "1.3.0",
@@ -24732,15 +24964,13 @@
     "dir-glob": {
       "version": "3.0.1",
       "dev": true,
-      "peer": true,
       "requires": {
         "path-type": "^4.0.0"
       },
       "dependencies": {
         "path-type": {
           "version": "4.0.0",
-          "dev": true,
-          "peer": true
+          "dev": true
         }
       }
     },
@@ -25681,6 +25911,86 @@
       "dev": true,
       "peer": true
     },
+    "eslint-webpack-plugin": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz",
+      "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==",
+      "dev": true,
+      "requires": {
+        "@types/eslint": "^7.29.0 || ^8.4.1",
+        "jest-worker": "^28.0.2",
+        "micromatch": "^4.0.5",
+        "normalize-path": "^3.0.0",
+        "schema-utils": "^4.0.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "8.11.0",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+          "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "ajv-keywords": {
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+          "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.3"
+          }
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "jest-worker": {
+          "version": "28.1.3",
+          "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz",
+          "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==",
+          "dev": true,
+          "requires": {
+            "@types/node": "*",
+            "merge-stream": "^2.0.0",
+            "supports-color": "^8.0.0"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true
+        },
+        "schema-utils": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz",
+          "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==",
+          "dev": true,
+          "requires": {
+            "@types/json-schema": "^7.0.9",
+            "ajv": "^8.8.0",
+            "ajv-formats": "^2.1.1",
+            "ajv-keywords": "^5.0.0"
+          }
+        },
+        "supports-color": {
+          "version": "8.1.1",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+          "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
     "espree": {
       "version": "9.3.1",
       "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz",
@@ -25947,7 +26257,6 @@
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
       "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
       "dev": true,
-      "peer": true,
       "requires": {
         "@nodelib/fs.stat": "^2.0.2",
         "@nodelib/fs.walk": "^1.2.3",
@@ -25976,7 +26285,6 @@
     "fastq": {
       "version": "1.11.0",
       "dev": true,
-      "peer": true,
       "requires": {
         "reusify": "^1.0.4"
       }
@@ -26358,7 +26666,6 @@
       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
       "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
       "dev": true,
-      "peer": true,
       "requires": {
         "is-glob": "^4.0.1"
       }
@@ -26420,7 +26727,6 @@
       "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
       "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
       "dev": true,
-      "peer": true,
       "requires": {
         "array-union": "^2.1.0",
         "dir-glob": "^3.0.1",
@@ -26787,8 +27093,7 @@
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
       "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "immutable": {
       "version": "4.1.0",
@@ -26990,8 +27295,7 @@
     },
     "is-extglob": {
       "version": "2.1.1",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "is-finite": {
       "version": "1.1.0",
@@ -27027,7 +27331,6 @@
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
       "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
       "dev": true,
-      "peer": true,
       "requires": {
         "is-extglob": "^2.1.1"
       }
@@ -29365,8 +29668,7 @@
     },
     "merge2": {
       "version": "1.4.1",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "methods": {
       "version": "1.1.2",
@@ -30559,8 +30861,7 @@
     },
     "queue-microtask": {
       "version": "1.2.3",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "quick-lru": {
       "version": "4.0.1",
@@ -30869,8 +31170,7 @@
     },
     "require-from-string": {
       "version": "2.0.2",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "requireindex": {
       "version": "1.2.0",
@@ -30935,8 +31235,7 @@
     },
     "reusify": {
       "version": "1.0.4",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "rfdc": {
       "version": "1.3.0",
@@ -30967,7 +31266,6 @@
     "run-parallel": {
       "version": "1.2.0",
       "dev": true,
-      "peer": true,
       "requires": {
         "queue-microtask": "^1.2.2"
       }
@@ -31870,6 +32168,86 @@
         "postcss-value-parser": "^4.1.0"
       }
     },
+    "stylelint-webpack-plugin": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/stylelint-webpack-plugin/-/stylelint-webpack-plugin-3.3.0.tgz",
+      "integrity": "sha512-F53bapIZ9zI16ero8IWm6TrUE6SSibZBphJE9b5rR2FxtvmGmm1YmS+a5xjQzn63+cv71GVSCu4byX66fBLpEw==",
+      "dev": true,
+      "requires": {
+        "globby": "^11.1.0",
+        "jest-worker": "^28.1.0",
+        "micromatch": "^4.0.5",
+        "normalize-path": "^3.0.0",
+        "schema-utils": "^4.0.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "8.11.0",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+          "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "ajv-keywords": {
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+          "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.3"
+          }
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "jest-worker": {
+          "version": "28.1.3",
+          "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz",
+          "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==",
+          "dev": true,
+          "requires": {
+            "@types/node": "*",
+            "merge-stream": "^2.0.0",
+            "supports-color": "^8.0.0"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true
+        },
+        "schema-utils": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz",
+          "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==",
+          "dev": true,
+          "requires": {
+            "@types/json-schema": "^7.0.9",
+            "ajv": "^8.8.0",
+            "ajv-formats": "^2.1.1",
+            "ajv-keywords": "^5.0.0"
+          }
+        },
+        "supports-color": {
+          "version": "8.1.1",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+          "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
     "superstruct": {
       "version": "0.16.0",
       "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.16.0.tgz",
diff --git a/package.json b/package.json
index 7c825036c..13b879b0c 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
     "@nextcloud/router": "^2.0.0",
     "@nextcloud/vue": "^6.0.0-beta.4",
     "@nextcloud/vue-dashboard": "^2.0.1",
+    "@nextcloud/vue-richtext": "^2.0.0",
     "blueimp-md5": "^2.19.0",
     "dompurify": "^2.4.0",
     "lodash": "^4.17.21",
@@ -79,8 +80,10 @@
     "@relative-ci/agent": "^4.1.0",
     "@vue/test-utils": "^1.3.0",
     "cypress": "^10.7.0",
+    "eslint-webpack-plugin": "^3.2.0",
     "jest": "^29.0.1",
     "jest-serializer-vue": "^2.0.2",
+    "stylelint-webpack-plugin": "^3.3.0",
     "vue-jest": "^3.0.7",
     "vue-template-compiler": "^2.7.9"
   },
diff --git a/src/App.vue b/src/App.vue
index 1f2e6e90f..cffef1b84 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -43,9 +43,9 @@
 
 <script>
 import { mapState } from 'vuex'
-import AppNavigation from './components/navigation/AppNavigation'
+import AppNavigation from './components/navigation/AppNavigation.vue'
 import { NcModal, NcContent, NcAppContent } from '@nextcloud/vue'
-import { BoardApi } from './services/BoardApi'
+import { BoardApi } from './services/BoardApi.js'
 import { emit, subscribe } from '@nextcloud/event-bus'
 
 const boardApi = new BoardApi()
diff --git a/src/CardCreateDialog.vue b/src/CardCreateDialog.vue
index ab7b5cc8e..357fa9497 100644
--- a/src/CardCreateDialog.vue
+++ b/src/CardCreateDialog.vue
@@ -92,11 +92,11 @@
 
 <script>
 import { generateUrl } from '@nextcloud/router'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal'
-import NcMultiselect from '@nextcloud/vue/dist/Components/NcMultiselect'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent'
+import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
+import NcMultiselect from '@nextcloud/vue/dist/Components/NcMultiselect.js'
+import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
 import axios from '@nextcloud/axios'
-import { CardApi } from './services/CardApi'
+import { CardApi } from './services/CardApi.js'
 
 const cardApi = new CardApi()
 
diff --git a/src/components/ActivityEntry.vue b/src/components/ActivityEntry.vue
index b6f289b63..c65823ee8 100644
--- a/src/components/ActivityEntry.vue
+++ b/src/components/ActivityEntry.vue
@@ -39,7 +39,7 @@ import RichText from '@juliushaertl/vue-richtext'
 import { NcUserBubble } from '@nextcloud/vue'
 import moment from '@nextcloud/moment'
 import DOMPurify from 'dompurify'
-import relativeDate from '../mixins/relativeDate'
+import relativeDate from '../mixins/relativeDate.js'
 
 const InternalLink = {
 	name: 'InternalLink',
diff --git a/src/components/ActivityList.vue b/src/components/ActivityList.vue
index 597058527..fa1eb7df2 100644
--- a/src/components/ActivityList.vue
+++ b/src/components/ActivityList.vue
@@ -37,7 +37,7 @@
 <script>
 import axios from '@nextcloud/axios'
 import { generateOcsUrl } from '@nextcloud/router'
-import ActivityEntry from './ActivityEntry'
+import ActivityEntry from './ActivityEntry.vue'
 import InfiniteLoading from 'vue-infinite-loading'
 
 const ACTIVITY_FETCH_LIMIT = 50
diff --git a/src/components/AttachmentDragAndDrop.vue b/src/components/AttachmentDragAndDrop.vue
index d5487e285..05ba964b8 100644
--- a/src/components/AttachmentDragAndDrop.vue
+++ b/src/components/AttachmentDragAndDrop.vue
@@ -63,7 +63,7 @@
 
 <script>
 import { NcModal } from '@nextcloud/vue'
-import attachmentUpload from '../mixins/attachmentUpload'
+import attachmentUpload from '../mixins/attachmentUpload.js'
 import { loadState } from '@nextcloud/initial-state'
 
 let maxUploadSizeState
diff --git a/src/components/Controls.vue b/src/components/Controls.vue
index bc0701a7e..f92961bd6 100644
--- a/src/components/Controls.vue
+++ b/src/components/Controls.vue
@@ -217,13 +217,13 @@
 <script>
 import { mapState, mapGetters } from 'vuex'
 import { NcActions, NcActionButton, NcAvatar, NcButton, NcPopover } from '@nextcloud/vue'
-import labelStyle from '../mixins/labelStyle'
-import CardCreateDialog from '../CardCreateDialog'
-import ArchiveIcon from 'vue-material-design-icons/Archive'
-import FilterIcon from 'vue-material-design-icons/Filter'
-import FilterOffIcon from 'vue-material-design-icons/FilterOff'
-import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical'
-import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical'
+import labelStyle from '../mixins/labelStyle.js'
+import CardCreateDialog from '../CardCreateDialog.vue'
+import ArchiveIcon from 'vue-material-design-icons/Archive.vue'
+import FilterIcon from 'vue-material-design-icons/Filter.vue'
+import FilterOffIcon from 'vue-material-design-icons/FilterOff.vue'
+import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue'
+import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue'
 
 export default {
 	name: 'Controls',
diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue
index 35f55cbc7..145b3e11c 100644
--- a/src/components/board/Board.vue
+++ b/src/components/board/Board.vue
@@ -73,11 +73,11 @@
 
 import { Container, Draggable } from 'vue-smooth-dnd'
 import { mapState, mapGetters } from 'vuex'
-import Controls from '../Controls'
-import Stack from './Stack'
+import Controls from '../Controls.vue'
+import Stack from './Stack.vue'
 import { NcEmptyContent } from '@nextcloud/vue'
-import GlobalSearchResults from '../search/GlobalSearchResults'
-import { showError } from '../../helpers/errors'
+import GlobalSearchResults from '../search/GlobalSearchResults.vue'
+import { showError } from '../../helpers/errors.js'
 
 export default {
 	name: 'Board',
diff --git a/src/components/board/BoardSidebar.vue b/src/components/board/BoardSidebar.vue
index 0a5ddefd2..6ce0613ee 100644
--- a/src/components/board/BoardSidebar.vue
+++ b/src/components/board/BoardSidebar.vue
@@ -59,10 +59,10 @@
 
 <script>
 import { mapState, mapGetters } from 'vuex'
-import SharingTabSidebar from './SharingTabSidebar'
-import TagsTabSidebar from './TagsTabSidebar'
-import DeletedTabSidebar from './DeletedTabSidebar'
-import TimelineTabSidebar from './TimelineTabSidebar'
+import SharingTabSidebar from './SharingTabSidebar.vue'
+import TagsTabSidebar from './TagsTabSidebar.vue'
+import DeletedTabSidebar from './DeletedTabSidebar.vue'
+import TimelineTabSidebar from './TimelineTabSidebar.vue'
 import { NcAppSidebar, NcAppSidebarTab } from '@nextcloud/vue'
 
 const capabilities = window.OC.getCapabilities()
diff --git a/src/components/board/DeletedTabSidebar.vue b/src/components/board/DeletedTabSidebar.vue
index a2df35953..4e0d307e9 100644
--- a/src/components/board/DeletedTabSidebar.vue
+++ b/src/components/board/DeletedTabSidebar.vue
@@ -32,7 +32,7 @@
 
 <script>
 import { mapState } from 'vuex'
-import relativeDate from '../../mixins/relativeDate'
+import relativeDate from '../../mixins/relativeDate.js'
 
 export default {
 	name: 'DeletedTabSidebar',
diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue
index a7c97d1c5..7cd674124 100644
--- a/src/components/board/Stack.vue
+++ b/src/components/board/Stack.vue
@@ -133,10 +133,10 @@ import { Container, Draggable } from 'vue-smooth-dnd'
 
 import { NcActions, NcActionButton, NcModal } from '@nextcloud/vue'
 import { showError, showUndo } from '@nextcloud/dialogs'
-import CardItem from '../cards/CardItem'
+import CardItem from '../cards/CardItem.vue'
 
 import '@nextcloud/dialogs/styles/toast.scss'
-import ArchiveIcon from 'vue-material-design-icons/Archive'
+import ArchiveIcon from 'vue-material-design-icons/Archive.vue'
 
 export default {
 	name: 'Stack',
diff --git a/src/components/board/TagsTabSidebar.vue b/src/components/board/TagsTabSidebar.vue
index a6d52e862..4313727af 100644
--- a/src/components/board/TagsTabSidebar.vue
+++ b/src/components/board/TagsTabSidebar.vue
@@ -70,7 +70,7 @@
 <script>
 
 import { mapGetters } from 'vuex'
-import Color from '../../mixins/color'
+import Color from '../../mixins/color.js'
 import { NcColorPicker, NcActions, NcActionButton } from '@nextcloud/vue'
 
 export default {
diff --git a/src/components/board/TimelineTabSidebar.vue b/src/components/board/TimelineTabSidebar.vue
index a65ff3853..5b71f4d24 100644
--- a/src/components/board/TimelineTabSidebar.vue
+++ b/src/components/board/TimelineTabSidebar.vue
@@ -7,7 +7,7 @@
 </template>
 
 <script>
-import ActivityList from '../ActivityList'
+import ActivityList from '../ActivityList.vue'
 
 export default {
 	name: 'TimelineTabSidebar',
diff --git a/src/components/boards/Boards.vue b/src/components/boards/Boards.vue
index 0cd8133a5..512052e91 100644
--- a/src/components/boards/Boards.vue
+++ b/src/components/boards/Boards.vue
@@ -43,8 +43,8 @@
 
 <script>
 
-import BoardItem from './BoardItem'
-import Controls from '../Controls'
+import BoardItem from './BoardItem.vue'
+import Controls from '../Controls.vue'
 
 export default {
 	name: 'Boards',
diff --git a/src/components/card/AttachmentList.vue b/src/components/card/AttachmentList.vue
index 4df418d54..7f9017b00 100644
--- a/src/components/card/AttachmentList.vue
+++ b/src/components/card/AttachmentList.vue
@@ -104,14 +104,14 @@
 <script>
 import axios from '@nextcloud/axios'
 import { NcActions, NcActionButton, ActionLink } from '@nextcloud/vue'
-import AttachmentDragAndDrop from '../AttachmentDragAndDrop'
-import relativeDate from '../../mixins/relativeDate'
+import AttachmentDragAndDrop from '../AttachmentDragAndDrop.vue'
+import relativeDate from '../../mixins/relativeDate.js'
 import { formatFileSize } from '@nextcloud/files'
 import { getCurrentUser } from '@nextcloud/auth'
 import { generateUrl, generateOcsUrl, generateRemoteUrl } from '@nextcloud/router'
 import { mapState, mapActions } from 'vuex'
 import { loadState } from '@nextcloud/initial-state'
-import attachmentUpload from '../../mixins/attachmentUpload'
+import attachmentUpload from '../../mixins/attachmentUpload.js'
 import { getFilePickerBuilder } from '@nextcloud/dialogs'
 const maxUploadSizeState = loadState('deck', 'maxUploadSize')
 
diff --git a/src/components/card/CardSidebar.vue b/src/components/card/CardSidebar.vue
index dc546850f..f08bd147c 100644
--- a/src/components/card/CardSidebar.vue
+++ b/src/components/card/CardSidebar.vue
@@ -85,11 +85,11 @@
 import { NcActionButton, NcAppSidebar, NcAppSidebarTab } from '@nextcloud/vue'
 import { generateUrl } from '@nextcloud/router'
 import { mapState, mapGetters } from 'vuex'
-import CardSidebarTabDetails from './CardSidebarTabDetails'
-import CardSidebarTabAttachments from './CardSidebarTabAttachments'
-import CardSidebarTabComments from './CardSidebarTabComments'
-import CardSidebarTabActivity from './CardSidebarTabActivity'
-import relativeDate from '../../mixins/relativeDate'
+import CardSidebarTabDetails from './CardSidebarTabDetails.vue'
+import CardSidebarTabAttachments from './CardSidebarTabAttachments.vue'
+import CardSidebarTabComments from './CardSidebarTabComments.vue'
+import CardSidebarTabActivity from './CardSidebarTabActivity.vue'
+import relativeDate from '../../mixins/relativeDate.js'
 import moment from '@nextcloud/moment'
 import AttachmentIcon from 'vue-material-design-icons/Paperclip.vue'
 
diff --git a/src/components/card/CardSidebarTabActivity.vue b/src/components/card/CardSidebarTabActivity.vue
index 60d18790d..8b6e1f0cb 100644
--- a/src/components/card/CardSidebarTabActivity.vue
+++ b/src/components/card/CardSidebarTabActivity.vue
@@ -30,7 +30,7 @@
 </template>
 
 <script>
-import ActivityList from '../ActivityList'
+import ActivityList from '../ActivityList.vue'
 
 export default {
 	name: 'CardSidebarTabActivity',
diff --git a/src/components/card/CardSidebarTabAttachments.vue b/src/components/card/CardSidebarTabAttachments.vue
index 06dbd6662..54e03be8e 100644
--- a/src/components/card/CardSidebarTabAttachments.vue
+++ b/src/components/card/CardSidebarTabAttachments.vue
@@ -28,7 +28,7 @@
 </template>
 
 <script>
-import AttachmentList from './AttachmentList'
+import AttachmentList from './AttachmentList.vue'
 export default {
 	name: 'CardSidebarTabAttachments',
 	components: {
diff --git a/src/components/card/CardSidebarTabComments.vue b/src/components/card/CardSidebarTabComments.vue
index 7e50484f7..ff5fbce3c 100644
--- a/src/components/card/CardSidebarTabComments.vue
+++ b/src/components/card/CardSidebarTabComments.vue
@@ -36,8 +36,8 @@
 <script>
 import { mapState, mapGetters } from 'vuex'
 import { NcAvatar } from '@nextcloud/vue'
-import CommentItem from './CommentItem'
-import CommentForm from './CommentForm'
+import CommentItem from './CommentItem.vue'
+import CommentForm from './CommentForm.vue'
 import InfiniteLoading from 'vue-infinite-loading'
 import { getCurrentUser } from '@nextcloud/auth'
 
diff --git a/src/components/card/CardSidebarTabDetails.vue b/src/components/card/CardSidebarTabDetails.vue
index 67c552748..a860de83b 100644
--- a/src/components/card/CardSidebarTabDetails.vue
+++ b/src/components/card/CardSidebarTabDetails.vue
@@ -129,14 +129,14 @@ import { NcAvatar, NcActions, NcActionButton, NcMultiselect, NcDatetimePicker }
 import { loadState } from '@nextcloud/initial-state'
 
 import { CollectionList } from 'nextcloud-vue-collections'
-import Color from '../../mixins/color'
+import Color from '../../mixins/color.js'
 import {
 	getLocale,
 	getDayNamesMin,
 	getFirstDay,
 	getMonthNamesShort,
 } from '@nextcloud/l10n'
-import Description from './Description'
+import Description from './Description.vue'
 
 export default {
 	name: 'CardSidebarTabDetails',
diff --git a/src/components/card/CommentForm.vue b/src/components/card/CommentForm.vue
index eb4d32cdb..bf4ab08b1 100644
--- a/src/components/card/CommentForm.vue
+++ b/src/components/card/CommentForm.vue
@@ -65,7 +65,7 @@
 import { mapState } from 'vuex'
 import { NcUserBubble, NcAvatar } from '@nextcloud/vue'
 import At from 'vue-at'
-import { rawToParsed } from '../../helpers/mentions'
+import { rawToParsed } from '../../helpers/mentions.js'
 
 export default {
 	name: 'CommentForm',
diff --git a/src/components/card/CommentItem.vue b/src/components/card/CommentItem.vue
index 915ec37a8..17236639f 100644
--- a/src/components/card/CommentItem.vue
+++ b/src/components/card/CommentItem.vue
@@ -66,10 +66,10 @@
 <script>
 import { NcAvatar, NcActions, NcActionButton, NcUserBubble } from '@nextcloud/vue'
 import RichText from '@juliushaertl/vue-richtext'
-import CommentForm from './CommentForm'
+import CommentForm from './CommentForm.vue'
 import { getCurrentUser } from '@nextcloud/auth'
 import md5 from 'blueimp-md5'
-import relativeDate from '../../mixins/relativeDate'
+import relativeDate from '../../mixins/relativeDate.js'
 import ReplyIcon from 'vue-material-design-icons/Reply'
 
 const AtMention = {
diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue
index f53cfcc99..3d05db44a 100644
--- a/src/components/card/Description.vue
+++ b/src/components/card/Description.vue
@@ -78,7 +78,7 @@
 import MarkdownIt from 'markdown-it'
 import MarkdownItTaskCheckbox from 'markdown-it-task-checkbox'
 import MarkdownItLinkAttributes from 'markdown-it-link-attributes'
-import AttachmentList from './AttachmentList'
+import AttachmentList from './AttachmentList.vue'
 import { NcActions, NcActionButton, NcModal } from '@nextcloud/vue'
 import { formatFileSize } from '@nextcloud/files'
 import { generateUrl } from '@nextcloud/router'
diff --git a/src/components/cards/CardBadges.vue b/src/components/cards/CardBadges.vue
index 61a79049f..f0dfb7266 100644
--- a/src/components/cards/CardBadges.vue
+++ b/src/components/cards/CardBadges.vue
@@ -49,8 +49,8 @@
 	</div>
 </template>
 <script>
-import NcAvatarList from './AvatarList'
-import CardMenu from './CardMenu'
+import NcAvatarList from './AvatarList.vue'
+import CardMenu from './CardMenu.vue'
 import TextIcon from 'vue-material-design-icons/Text.vue'
 import AttachmentIcon from 'vue-material-design-icons/Paperclip.vue'
 import CheckmarkIcon from 'vue-material-design-icons/CheckboxMarked.vue'
diff --git a/src/components/cards/CardItem.vue b/src/components/cards/CardItem.vue
index 972d1632c..6cc18b21e 100644
--- a/src/components/cards/CardItem.vue
+++ b/src/components/cards/CardItem.vue
@@ -83,12 +83,12 @@
 <script>
 import ClickOutside from 'vue-click-outside'
 import { mapState, mapGetters } from 'vuex'
-import CardBadges from './CardBadges'
-import Color from '../../mixins/color'
-import labelStyle from '../../mixins/labelStyle'
-import AttachmentDragAndDrop from '../AttachmentDragAndDrop'
-import CardMenu from './CardMenu'
-import DueDate from './badges/DueDate'
+import CardBadges from './CardBadges.vue'
+import Color from '../../mixins/color.js'
+import labelStyle from '../../mixins/labelStyle.js'
+import AttachmentDragAndDrop from '../AttachmentDragAndDrop.vue'
+import CardMenu from './CardMenu.vue'
+import DueDate from './badges/DueDate.vue'
 
 export default {
 	name: 'CardItem',
diff --git a/src/components/icons/DeckIcon.vue b/src/components/icons/DeckIcon.vue
index c5948827b..ad39aef34 100644
--- a/src/components/icons/DeckIcon.vue
+++ b/src/components/icons/DeckIcon.vue
@@ -1,38 +1,55 @@
 <template>
-	<svg xmlns="http://www.w3.org/2000/svg"
-		:height="size"
-		:width="size"
-		version="1.1"
-		viewBox="0 0 16 16">
-		<rect ry="1"
-			height="8"
-			width="14"
-			y="7"
-			x="1" />
-		<rect ry=".5"
-			height="1"
-			width="12"
-			y="5"
-			x="2" />
-		<rect ry=".5"
-			height="1"
-			width="10"
-			y="3"
-			x="3" />
-		<rect ry=".5"
-			height="1"
-			width="8"
-			y="1"
-			x="4" />
-	</svg>
+	<span :aria-hidden="!title"
+		:aria-label="title"
+		class="material-design-icon deck-icon"
+		role="img"
+		v-bind="$attrs"
+		@click="$emit('click', $event)">
+		<svg xmlns="http://www.w3.org/2000/svg"
+			:fill="fillColor"
+			:height="size"
+			:width="size"
+			version="1.1"
+			viewBox="0 0 16 16">
+			<rect ry="1"
+				height="8"
+				width="14"
+				y="7"
+				x="1" />
+			<rect ry=".5"
+				height="1"
+				width="12"
+				y="5"
+				x="2" />
+			<rect ry=".5"
+				height="1"
+				width="10"
+				y="3"
+				x="3" />
+			<rect ry=".5"
+				height="1"
+				width="8"
+				y="1"
+				x="4" />
+		</svg>
+	</span>
 </template>
+
 <script>
 export default {
 	name: 'DeckIcon',
 	props: {
+		title: {
+			type: String,
+			default: '',
+		},
+		fillColor: {
+			type: String,
+			default: 'currentColor',
+		},
 		size: {
 			type: Number,
-			default: 16,
+			default: 24,
 		},
 	},
 }
diff --git a/src/components/navigation/AppNavigation.vue b/src/components/navigation/AppNavigation.vue
index a954017a5..31b8d8911 100644
--- a/src/components/navigation/AppNavigation.vue
+++ b/src/components/navigation/AppNavigation.vue
@@ -107,8 +107,8 @@ import axios from '@nextcloud/axios'
 import { mapGetters } from 'vuex'
 import ClickOutside from 'vue-click-outside'
 import { NcAppNavigation, NcAppNavigationItem, NcAppNavigationSettings, NcMultiselect } from '@nextcloud/vue'
-import AppNavigationAddBoard from './AppNavigationAddBoard'
-import AppNavigationBoardCategory from './AppNavigationBoardCategory'
+import AppNavigationAddBoard from './AppNavigationAddBoard.vue'
+import AppNavigationBoardCategory from './AppNavigationBoardCategory.vue'
 import { loadState } from '@nextcloud/initial-state'
 import { generateOcsUrl } from '@nextcloud/router'
 import { getCurrentUser } from '@nextcloud/auth'
diff --git a/src/components/navigation/AppNavigationBoardCategory.vue b/src/components/navigation/AppNavigationBoardCategory.vue
index 71b282dab..805128174 100644
--- a/src/components/navigation/AppNavigationBoardCategory.vue
+++ b/src/components/navigation/AppNavigationBoardCategory.vue
@@ -34,7 +34,7 @@
 </template>
 
 <script>
-import AppNavigationBoard from './AppNavigationBoard'
+import AppNavigationBoard from './AppNavigationBoard.vue'
 import { NcAppNavigationItem } from '@nextcloud/vue'
 
 export default {
diff --git a/src/components/overview/Overview.vue b/src/components/overview/Overview.vue
index f097034c3..a7dedef39 100644
--- a/src/components/overview/Overview.vue
+++ b/src/components/overview/Overview.vue
@@ -80,11 +80,11 @@
 
 <script>
 
-import Controls from '../Controls'
-import CardItem from '../cards/CardItem'
+import Controls from '../Controls.vue'
+import CardItem from '../cards/CardItem.vue'
 import { mapGetters } from 'vuex'
 import moment from '@nextcloud/moment'
-import GlobalSearchResults from '../search/GlobalSearchResults'
+import GlobalSearchResults from '../search/GlobalSearchResults.vue'
 
 const FILTER_UPCOMING = 'upcoming'
 
diff --git a/src/components/search/GlobalSearchResults.vue b/src/components/search/GlobalSearchResults.vue
index 89f8f5398..4fa81e7db 100644
--- a/src/components/search/GlobalSearchResults.vue
+++ b/src/components/search/GlobalSearchResults.vue
@@ -52,13 +52,13 @@
 </template>
 
 <script>
-import CardItem from '../cards/CardItem'
+import CardItem from '../cards/CardItem.vue'
 import { mapState } from 'vuex'
 import axios from '@nextcloud/axios'
 import { generateOcsUrl } from '@nextcloud/router'
 import InfiniteLoading from 'vue-infinite-loading'
 import RichText from '@juliushaertl/vue-richtext'
-import Placeholder from './Placeholder'
+import Placeholder from './Placeholder.vue'
 import { NcActions, NcActionButton } from '@nextcloud/vue'
 
 const createCancelToken = () => axios.CancelToken.source()
diff --git a/src/init-card-reference.js b/src/init-card-reference.js
new file mode 100644
index 000000000..bb8093f21
--- /dev/null
+++ b/src/init-card-reference.js
@@ -0,0 +1,51 @@
+/**
+ * @copyright Copyright (c) 2022 Julien Veyssier <eneiluj@posteo.net>
+ *
+ * @author Julien Veyssier <eneiluj@posteo.net>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { registerWidget } from '@nextcloud/vue-richtext'
+import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip.js'
+import Vue from 'vue'
+import CardReferenceWidget from './views/CardReferenceWidget.vue'
+
+import { translate, translatePlural } from '@nextcloud/l10n'
+
+Vue.prototype.t = translate
+Vue.prototype.n = translatePlural
+Vue.prototype.OC = window.OC
+Vue.prototype.OCA = window.OCA
+Vue.directive('tooltip', Tooltip)
+
+registerWidget('deck-card', (el, { richObjectType, richObject, accessible }) => {
+	// trick to change the wrapper element size, otherwise it always is 100%
+	// which is not very nice with a simple card
+	el.parentNode.style['max-width'] = '400px'
+	el.parentNode.style['min-width'] = '200px'
+	el.parentNode.style['margin-left'] = '0'
+	el.parentNode.style['margin-right'] = '0'
+
+	const Widget = Vue.extend(CardReferenceWidget)
+	new Widget({
+		propsData: {
+			richObjectType,
+			richObject,
+			accessible,
+		},
+	}).$mount(el)
+})
diff --git a/src/init-collections.js b/src/init-collections.js
index 8c1f2024c..f26565ee4 100644
--- a/src/init-collections.js
+++ b/src/init-collections.js
@@ -23,8 +23,8 @@
 import Vue from 'vue'
 
 import './../css/collections.css'
-import FileSharingPicker from './views/FileSharingPicker'
-import { buildSelector } from './helpers/selector'
+import FileSharingPicker from './views/FileSharingPicker.js'
+import { buildSelector } from './helpers/selector.js'
 
 // eslint-disable-next-line
 __webpack_nonce__ = btoa(OC.requestToken);
@@ -44,7 +44,7 @@ window.addEventListener('DOMContentLoaded', () => {
 
 	window.OCP.Collaboration.registerType('deck', {
 		action: () => {
-			const BoardSelector = () => import('./BoardSelector')
+			const BoardSelector = () => import('./BoardSelector.vue')
 			return buildSelector(BoardSelector)
 		},
 		typeString: t('deck', 'Link to a board'),
@@ -53,7 +53,7 @@ window.addEventListener('DOMContentLoaded', () => {
 
 	window.OCP.Collaboration.registerType('deck-card', {
 		action: () => {
-			const CardSelector = () => import('./CardSelector')
+			const CardSelector = () => import('./CardSelector.vue')
 			return buildSelector(CardSelector)
 		},
 		typeString: t('deck', 'Link to a card'),
diff --git a/src/init-dashboard.js b/src/init-dashboard.js
index e4f6a741b..c618fd17d 100644
--- a/src/init-dashboard.js
+++ b/src/init-dashboard.js
@@ -23,7 +23,7 @@
 import Vue from 'vue'
 import Vuex from 'vuex'
 
-import overview from './store/overview'
+import overview from './store/overview.js'
 
 import './css/dashboard.scss'
 
diff --git a/src/init-talk.js b/src/init-talk.js
index 3998a4a88..943cbe22b 100644
--- a/src/init-talk.js
+++ b/src/init-talk.js
@@ -23,9 +23,9 @@
 import Vue from 'vue'
 import { generateUrl } from '@nextcloud/router'
 
-import CardCreateDialog from './CardCreateDialog'
-import { buildSelector } from './helpers/selector'
-import './init-collections'
+import CardCreateDialog from './CardCreateDialog.vue'
+import { buildSelector } from './helpers/selector.js'
+import './init-collections.js'
 
 // eslint-disable-next-line
 __webpack_nonce__ = btoa(OC.requestToken);
diff --git a/src/main.js b/src/main.js
index cacbf01aa..2d1a28132 100644
--- a/src/main.js
+++ b/src/main.js
@@ -20,9 +20,9 @@
  *
  */
 import Vue from 'vue'
-import App from './App'
-import router from './router'
-import store from './store/main'
+import App from './App.vue'
+import router from './router.js'
+import store from './store/main.js'
 import { sync } from 'vuex-router-sync'
 import { translate, translatePlural } from '@nextcloud/l10n'
 import { generateFilePath } from '@nextcloud/router'
@@ -30,7 +30,7 @@ import { showError } from '@nextcloud/dialogs'
 import { subscribe } from '@nextcloud/event-bus'
 import { Tooltip } from '@nextcloud/vue'
 import ClickOutside from 'vue-click-outside'
-import './models'
+import './models/index.js'
 
 // the server snap.js conflicts with vertical scrolling so we disable it
 document.body.setAttribute('data-snap-ignore', 'true')
diff --git a/src/mixins/labelStyle.js b/src/mixins/labelStyle.js
index 8fe1d62b9..3f91b68bb 100644
--- a/src/mixins/labelStyle.js
+++ b/src/mixins/labelStyle.js
@@ -20,7 +20,7 @@
  *
  */
 
-import Color from './color'
+import Color from './color.js'
 
 export default {
 	mixins: [Color],
diff --git a/src/router.js b/src/router.js
index ca0657395..4a4fa961a 100644
--- a/src/router.js
+++ b/src/router.js
@@ -23,13 +23,13 @@
 import Vue from 'vue'
 import Router from 'vue-router'
 import { generateUrl } from '@nextcloud/router'
-import { BOARD_FILTERS } from './store/main'
-import Boards from './components/boards/Boards'
-import Board from './components/board/Board'
-import Sidebar from './components/Sidebar'
-import BoardSidebar from './components/board/BoardSidebar'
-import CardSidebar from './components/card/CardSidebar'
-import Overview from './components/overview/Overview'
+import { BOARD_FILTERS } from './store/main.js'
+import Boards from './components/boards/Boards.vue'
+import Board from './components/board/Board.vue'
+import Sidebar from './components/Sidebar.vue'
+import BoardSidebar from './components/board/BoardSidebar.vue'
+import CardSidebar from './components/card/CardSidebar.vue'
+import Overview from './components/overview/Overview.vue'
 
 Vue.use(Router)
 
diff --git a/src/services/BoardApi.js b/src/services/BoardApi.js
index f6e2e8091..38b1d746f 100644
--- a/src/services/BoardApi.js
+++ b/src/services/BoardApi.js
@@ -22,7 +22,7 @@
 
 import axios from '@nextcloud/axios'
 import { generateUrl } from '@nextcloud/router'
-import './../models'
+import '../models/index.js'
 
 /**
  * This class handles all the api communication with the Deck backend.
diff --git a/src/services/StackApi.js b/src/services/StackApi.js
index 5e2322f93..26d3e7e04 100644
--- a/src/services/StackApi.js
+++ b/src/services/StackApi.js
@@ -22,7 +22,7 @@
 
 import axios from '@nextcloud/axios'
 import { generateUrl } from '@nextcloud/router'
-import './../models'
+import '../models/index.js'
 
 export class StackApi {
 
diff --git a/src/store/attachment.js b/src/store/attachment.js
index 1115b3405..c0db49e37 100644
--- a/src/store/attachment.js
+++ b/src/store/attachment.js
@@ -20,7 +20,7 @@
  *
  */
 
-import { AttachmentApi } from './../services/AttachmentApi'
+import { AttachmentApi } from './../services/AttachmentApi.js'
 import Vue from 'vue'
 
 const apiClient = new AttachmentApi()
diff --git a/src/store/card.js b/src/store/card.js
index 1c339ece0..4c2a902a6 100644
--- a/src/store/card.js
+++ b/src/store/card.js
@@ -20,7 +20,7 @@
  *
  */
 
-import { CardApi } from './../services/CardApi'
+import { CardApi } from './../services/CardApi.js'
 import moment from 'moment'
 import Vue from 'vue'
 
diff --git a/src/store/comment.js b/src/store/comment.js
index ad58a5719..69a0b35b4 100644
--- a/src/store/comment.js
+++ b/src/store/comment.js
@@ -20,7 +20,7 @@
  *
  */
 
-import { CommentApi } from '../services/CommentApi'
+import { CommentApi } from '../services/CommentApi.js'
 import Vue from 'vue'
 
 const apiClient = new CommentApi()
diff --git a/src/store/main.js b/src/store/main.js
index 9c65509bc..52c26ab79 100644
--- a/src/store/main.js
+++ b/src/store/main.js
@@ -27,14 +27,14 @@ import Vue from 'vue'
 import Vuex from 'vuex'
 import axios from '@nextcloud/axios'
 import { generateOcsUrl, generateUrl } from '@nextcloud/router'
-import { BoardApi } from '../services/BoardApi'
-import actions from './actions'
-import stack from './stack'
-import card from './card'
-import comment from './comment'
-import trashbin from './trashbin'
-import attachment from './attachment'
-import overview from './overview'
+import { BoardApi } from '../services/BoardApi.js'
+import actions from './actions.js'
+import stack from './stack.js'
+import card from './card.js'
+import comment from './comment.js'
+import trashbin from './trashbin.js'
+import attachment from './attachment.js'
+import overview from './overview.js'
 Vue.use(Vuex)
 
 const apiClient = new BoardApi()
diff --git a/src/store/overview.js b/src/store/overview.js
index 214ec2901..e6eeb3c02 100644
--- a/src/store/overview.js
+++ b/src/store/overview.js
@@ -22,7 +22,7 @@
 
 import Vue from 'vue'
 import Vuex from 'vuex'
-import { OverviewApi } from '../services/OverviewApi'
+import { OverviewApi } from '../services/OverviewApi.js'
 Vue.use(Vuex)
 
 const apiClient = new OverviewApi()
diff --git a/src/store/stack.js b/src/store/stack.js
index bbb0486be..f55d6211d 100644
--- a/src/store/stack.js
+++ b/src/store/stack.js
@@ -21,8 +21,8 @@
  */
 
 import Vue from 'vue'
-import { StackApi } from './../services/StackApi'
-import applyOrderToArray from './../helpers/applyOrderToArray'
+import { StackApi } from './../services/StackApi.js'
+import applyOrderToArray from './../helpers/applyOrderToArray.js'
 
 const apiClient = new StackApi()
 
diff --git a/src/store/trashbin.js b/src/store/trashbin.js
index c508b88a5..fc90df623 100644
--- a/src/store/trashbin.js
+++ b/src/store/trashbin.js
@@ -20,8 +20,8 @@
  *
  */
 
-import { StackApi } from '../services/StackApi'
-import { CardApi } from '../services/CardApi'
+import { StackApi } from '../services/StackApi.js'
+import { CardApi } from '../services/CardApi.js'
 
 const stackApi = new StackApi()
 const cardApi = new CardApi()
diff --git a/src/views/CardReferenceWidget.vue b/src/views/CardReferenceWidget.vue
new file mode 100644
index 000000000..a57ee0537
--- /dev/null
+++ b/src/views/CardReferenceWidget.vue
@@ -0,0 +1,224 @@
+<!--
+  - @copyright Copyright (c) 2022 2022 Julien Veyssier <eneiluj@posteo.net>
+  -
+  - @author 2022 Julien Veyssier <eneiluj@posteo.net>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -->
+
+<template>
+	<div class="deck-card-reference">
+		<div class="line">
+			<DeckIcon :size="20" class="title-icon" />
+			<strong>
+				<a :href="cardLink"
+					:title="cardTooltip"
+					target="_blank"
+					class="link">
+					{{ card.title }}
+				</a>
+			</strong>
+			<div v-if="dueDate" class="spacer" />
+			<span v-if="dueDate"
+				v-tooltip.top="{ content: formattedDueDate }"
+				class="due-date">
+				<CalendarBlankIcon :size="20"
+					class="icon" />
+				{{ dueDate }}
+			</span>
+		</div>
+		<div class="line">
+			<a v-tooltip.top="{ content: stackTooltip }"
+				:href="boardLink"
+				target="_blank"
+				class="link">
+				{{ t('deck', '{stack} in {board}', { stack: stack.title, board: board.title }) }}
+			</a>
+		</div>
+		<div>
+			<transition-group v-if="card.labels && card.labels.length"
+				name="zoom"
+				tag="ul"
+				class="labels"
+				@click.stop="openCard">
+				<li v-for="label in labelsSorted" :key="label.id" :style="labelStyle(label)">
+					<span>{{ label.title }}</span>
+				</li>
+			</transition-group>
+		</div>
+		<div class="line description-assignees">
+			<TextIcon v-if="card.description" :size="20" class="icon" />
+			<div v-if="card.description"
+				class="description"
+				:title="card.description">
+				{{ card.description }}
+			</div>
+			<div v-if="card.assignedUsers .length > 0"
+				class="spacer" />
+			<AvatarList v-if="card.assignedUsers .length > 0"
+				:users="card.assignedUsers"
+				class="card-assignees" />
+		</div>
+	</div>
+</template>
+
+<script>
+import CalendarBlankIcon from 'vue-material-design-icons/CalendarBlank.vue'
+import TextIcon from 'vue-material-design-icons/Text.vue'
+
+import DeckIcon from '../components/icons/DeckIcon.vue'
+import AvatarList from '../components/cards/AvatarList.vue'
+import labelStyle from '../mixins/labelStyle.js'
+
+import moment from '@nextcloud/moment'
+import { generateUrl } from '@nextcloud/router'
+
+export default {
+	name: 'CardReferenceWidget',
+
+	components: {
+	  AvatarList,
+		DeckIcon,
+		CalendarBlankIcon,
+		TextIcon,
+	},
+
+	mixins: [labelStyle],
+
+	props: {
+		richObjectType: {
+			type: String,
+			default: '',
+		},
+		richObject: {
+			type: Object,
+			default: null,
+		},
+		accessible: {
+			type: Boolean,
+			default: true,
+		},
+	},
+
+	data() {
+		return {
+		}
+	},
+
+	computed: {
+	  card() {
+			return this.richObject.card
+		},
+		board() {
+			return this.richObject.board
+		},
+		stack() {
+			return this.richObject.stack
+		},
+		cardLink() {
+			return generateUrl('/apps/deck/#/board/{boardId}/card/{cardId}', { boardId: this.board.id, cardId: this.card.id })
+		},
+		boardLink() {
+			return generateUrl('/apps/deck/#/board/{boardId}', { boardId: this.board.id })
+		},
+		cardTooltip() {
+			return t('deck', '* Created on {created}\n* Last modified on {lastMod}\n* {nbAttachments} attachments\n* {nbComments} comments', {
+			  created: moment.unix(this.card.createdAt).format('LLL'),
+				lastMod: moment.unix(this.card.lastModified).format('LLL'),
+				nbAttachments: this.card.attachments.length,
+				nbComments: this.card.commentsCount,
+			})
+		},
+		stackTooltip() {
+			return t('deck', '{nbCards} cards', { nbCards: this.stack.cards.length })
+		},
+		dueDate() {
+			return this.card.duedate
+				? moment(this.card.duedate).fromNow()
+				: null
+		},
+		formattedDueDate() {
+			return this.card.duedate
+				? t('deck', 'Due on {date}', { date: moment(this.card.duedate).format('LLL') })
+				: null
+		},
+		labelsSorted() {
+			return [...this.card.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
+		},
+	},
+
+	methods: {
+	},
+}
+</script>
+
+<style scoped lang="scss">
+/* stylelint-disable-next-line no-invalid-position-at-import-rule */
+@import '../css/labels';
+
+.deck-card-reference {
+	width: 100%;
+	white-space: normal;
+	padding: 12px;
+
+	.link {
+		text-decoration: underline;
+	}
+
+	.line {
+		display: flex;
+		align-items: center;
+		.icon {
+			margin-right: 4px;
+		}
+		.title-icon {
+			margin-right: 8px;
+		}
+	}
+
+	.due-date {
+		display: flex;
+		align-items: center;
+	}
+
+	.labels {
+		margin: 8px 0;
+	}
+
+  .description-assignees {
+		width: 100%;
+		display: flex;
+		align-items: center;
+
+		.description {
+			text-overflow: ellipsis;
+			overflow: hidden;
+			white-space: nowrap;
+			margin-right: 8px;
+		}
+
+		.card-assignees {
+			margin-top: 0;
+			height: 36px;
+			flex-grow: unset;
+		}
+  }
+
+	.spacer {
+		flex-grow: 1;
+	}
+}
+</style>
diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue
index 4daf7a000..958d07d8c 100644
--- a/src/views/Dashboard.vue
+++ b/src/views/Dashboard.vue
@@ -59,10 +59,10 @@
 <script>
 import { NcDashboardWidget } from '@nextcloud/vue'
 import { mapGetters } from 'vuex'
-import labelStyle from './../mixins/labelStyle'
-import DueDate from '../components/cards/badges/DueDate'
+import labelStyle from './../mixins/labelStyle.js'
+import DueDate from '../components/cards/badges/DueDate.vue'
 import { generateUrl } from '@nextcloud/router'
-import CardCreateDialog from '../CardCreateDialog'
+import CardCreateDialog from '../CardCreateDialog.vue'
 
 export default {
 	name: 'Dashboard',
diff --git a/src/views/FileSharingPicker.js b/src/views/FileSharingPicker.js
index 099d1f4b9..af6eeb90b 100644
--- a/src/views/FileSharingPicker.js
+++ b/src/views/FileSharingPicker.js
@@ -21,7 +21,7 @@
  */
 
 import Vue from 'vue'
-import { createShare } from '../services/SharingApi'
+import { createShare } from '../services/SharingApi.js'
 
 export default {
 	icon: 'icon-deck',
@@ -33,7 +33,7 @@ export default {
 			container.id = 'deck-board-select'
 			const body = document.getElementById('body-user')
 			body.append(container)
-			const CardSelector = () => import('./../CardSelector')
+			const CardSelector = () => import('./../CardSelector.vue')
 			const ComponentVM = new Vue({
 				render: (h) => h(CardSelector, {
 					title: t('deck', 'Share {file} with a Deck card', { file: decodeURIComponent(self.fileInfo.name) }),
diff --git a/webpack.js b/webpack.js
index d1c817be6..d5db80b35 100644
--- a/webpack.js
+++ b/webpack.js
@@ -1,5 +1,10 @@
 const webpackConfig = require('@nextcloud/webpack-vue-config')
 const path = require('path')
+const ESLintPlugin = require('eslint-webpack-plugin')
+const StyleLintPlugin = require('stylelint-webpack-plugin')
+
+const buildMode = process.env.NODE_ENV
+const isDev = buildMode === 'development'
 
 webpackConfig.entry = {
 	...webpackConfig.entry,
@@ -7,6 +12,7 @@ webpackConfig.entry = {
 	dashboard: path.join(__dirname, 'src', 'init-dashboard.js'),
 	calendar: path.join(__dirname, 'src', 'init-calendar.js'),
 	talk: path.join(__dirname, 'src', 'init-talk.js'),
+	'card-reference': path.join(__dirname, 'src', 'init-card-reference.js'),
 }
 
 webpackConfig.stats = {
@@ -17,4 +23,18 @@ webpackConfig.stats = {
 	modules: true,
 }
 
+webpackConfig.plugins.push(
+	new ESLintPlugin({
+		extensions: ['js', 'vue'],
+		files: 'src',
+		failOnError: !isDev,
+	})
+)
+webpackConfig.plugins.push(
+	new StyleLintPlugin({
+		files: 'src/**/*.{css,scss,vue}',
+		failOnError: !isDev,
+	}),
+)
+
 module.exports = webpackConfig