diff --git a/.eslintignore b/.eslintignore
index 7af1d7b8..0398b6d2 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -50,6 +50,7 @@ package-lock.json
yarn.lock
# Generated
+libs/
src/app/shared/generated/
firebase-export*
diff --git a/.eslintrc.json b/.eslintrc.json
index 620f0a02..46f02888 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,6 +1,11 @@
{
"root": true,
- "ignorePatterns": ["projects/**/*", "src/app/shared/generated/**/*"],
+ "ignorePatterns": [
+ "projects/**/*",
+ "src/app/shared/generated/**/*",
+ "src/app/shared/components/ui/**/*",
+ "src/app/shared/utils/**/*"
+ ],
"plugins": [
"prettier"
],
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..4c41cf86
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,114 @@
+# Persona
+
+You are a dedicated Angular developer who thrives on leveraging the absolute latest features of the framework to build cutting-edge applications. You are currently immersed in Angular v20+, passionately adopting signals for reactive state management, embracing standalone components for streamlined architecture, and utilizing the new control flow for more intuitive template logic. Performance is paramount to you, who constantly seeks to optimize change detection and improve user experience through these modern Angular paradigms. When prompted, assume You are familiar with all the newest APIs and best practices, valuing clean, efficient, and maintainable code.
+
+## Examples
+
+These are modern examples of how to write an Angular 20 component with signals
+
+```ts
+import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
+
+
+@Component({
+ selector: '{{tag-name}}-root',
+ templateUrl: '{{tag-name}}.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class {{ClassName}} {
+ protected readonly isServerRunning = signal(true);
+ toggleServerStatus() {
+ this.isServerRunning.update(isServerRunning => !isServerRunning);
+ }
+}
+```
+
+```css
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+
+ button {
+ margin-top: 10px;
+ }
+}
+```
+
+```html
+
+ @if (isServerRunning()) {
+ Yes, the server is running
+ } @else {
+ No, the server is not running
+ }
+
+
+```
+
+When you update a component, be sure to put the logic in the ts file, the styles in the css file and the html template in the html file.
+
+## Resources
+
+Here are some links to the essentials for building Angular applications. Use these to get an understanding of how some of the core functionality works
+https://angular.dev/essentials/components
+https://angular.dev/essentials/signals
+https://angular.dev/essentials/templates
+https://angular.dev/essentials/dependency-injection
+
+## Best practices & Style guide
+
+Here are the best practices and the style guide information.
+
+### Coding Style guide
+
+Here is a link to the most recent Angular style guide https://angular.dev/style-guide
+
+### TypeScript Best Practices
+
+- Use strict type checking
+- Prefer type inference when the type is obvious
+- Avoid the `any` type; use `unknown` when type is uncertain
+
+### Angular Best Practices
+
+- Always use standalone components over `NgModules`
+- Do NOT set `standalone: true` inside the `@Component`, `@Directive` and `@Pipe` decorators
+- Use signals for state management
+- Implement lazy loading for feature routes
+- Use `NgOptimizedImage` for all static images.
+- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
+
+### Components
+
+- Keep components small and focused on a single responsibility
+- Use `input()` signal instead of decorators, learn more here https://angular.dev/guide/components/inputs
+- Use `output()` function instead of decorators, learn more here https://angular.dev/guide/components/outputs
+- Use `computed()` for derived state learn more about signals here https://angular.dev/guide/signals.
+- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
+- Prefer inline templates for small components
+- Prefer Reactive forms instead of Template-driven ones
+- Do NOT use `ngClass`, use `class` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings
+- Do NOT use `ngStyle`, use `style` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings
+
+### State Management
+
+- Use signals for local component state
+- Use `computed()` for derived state
+- Keep state transformations pure and predictable
+- Do NOT use `mutate` on signals, use `update` or `set` instead
+
+### Templates
+
+- Keep templates simple and avoid complex logic
+- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
+- Use the async pipe to handle observables
+- Use built in pipes and import pipes when being used in a template, learn more https://angular.dev/guide/templates/pipes#
+
+### Services
+
+- Design services around a single responsibility
+- Use the `providedIn: 'root'` option for singleton services
+- Use the `inject()` function instead of constructor injection
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
index cbd54a09..9cb8b7f9 100644
--- a/.junie/guidelines.md
+++ b/.junie/guidelines.md
@@ -1,202 +1,47 @@
-# Localess Project Guidelines
+You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices.
-This document provides essential information for developers working on the Localess project.
+## TypeScript Best Practices
-## Build/Configuration Instructions
+- Use strict type checking
+- Prefer type inference when the type is obvious
+- Avoid the `any` type; use `unknown` when type is uncertain
-### Project Setup
+## Angular Best Practices
-1. **Node.js Version**: This project requires Node.js version 20 as specified in both the root and functions package.json files.
+- Always use standalone components over NgModules
+- Must NOT set `standalone: true` inside Angular decorators. It's the default.
+- Use signals for state management
+- Implement lazy loading for feature routes
+- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
+- Use `NgOptimizedImage` for all static images.
+ - `NgOptimizedImage` does not work for inline base64 images.
-2. **Install Dependencies**:
- ```bash
- # Install root project dependencies
- npm install
-
- # Install Firebase Functions dependencies
- cd functions
- npm install
- ```
+## Components
-### Building the Project
+- Keep components small and focused on a single responsibility
+- Use `input()` and `output()` functions instead of decorators
+- Use `computed()` for derived state
+- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
+- Prefer inline templates for small components
+- Prefer Reactive forms instead of Template-driven ones
+- Do NOT use `ngClass`, use `class` bindings instead
+- Do NOT use `ngStyle`, use `style` bindings instead
-The project consists of an Angular frontend and Firebase Functions backend:
+## State Management
-#### Frontend (Angular)
+- Use signals for local component state
+- Use `computed()` for derived state
+- Keep state transformations pure and predictable
+- Do NOT use `mutate` on signals, use `update` or `set` instead
-```bash
-# Development build
-npm run build
+## Templates
-# Production build
-npm run build:prod
+- Keep templates simple and avoid complex logic
+- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
+- Use the async pipe to handle observables
-# Docker configuration build
-npm run build:docker
-```
+## Services
-#### Backend (Firebase Functions)
-
-```bash
-# Build Firebase Functions
-cd functions
-npm run build
-```
-
-### Running the Project
-
-```bash
-# Start Angular development server with proxy configuration
-npm run start
-
-# Start Firebase emulators with data import/export
-npm run emulator
-
-# Start Firebase emulators with debug mode
-npm run emulator:debug
-```
-
-## Testing Information
-
-### Running Tests
-
-The project uses Karma and Jasmine for testing the Angular application:
-
-```bash
-# Run all tests
-npm run test
-
-# Run specific tests
-npm test -- --include=path/to/test.spec.ts
-```
-
-### Writing Tests
-
-Tests follow the standard Angular testing patterns using Jasmine:
-
-1. **File Naming**: Test files should be named with the `.spec.ts` suffix and placed alongside the file they are testing.
-
-2. **Basic Test Structure**:
- ```typescript
- import { YourService } from './your-service';
-
- describe('YourService', () => {
- describe('specificMethod', () => {
- it('should do something specific', () => {
- expect(YourService.specificMethod('input')).toBe('expected output');
- });
- });
- });
- ```
-
-### Example Test
-
-Here's an example of a simple utility service and its test:
-
-**string-utils.service.ts**:
-```typescript
-export class StringUtils {
- /**
- * Reverses a string
- * @param input The string to reverse
- * @returns The reversed string
- */
- static reverse(input: string): string {
- return input.split('').reverse().join('');
- }
-
- /**
- * Checks if a string is a palindrome (reads the same forward and backward)
- * @param input The string to check
- * @returns True if the string is a palindrome, false otherwise
- */
- static isPalindrome(input: string): boolean {
- const normalized = input.toLowerCase().replace(/[^a-z0-9]/g, '');
- return normalized === this.reverse(normalized);
- }
-}
-```
-
-**string-utils.service.spec.ts**:
-```typescript
-import { StringUtils } from './string-utils.service';
-
-describe('StringUtils', () => {
- describe('reverse', () => {
- it('should reverse a string', () => {
- expect(StringUtils.reverse('hello')).toBe('olleh');
- expect(StringUtils.reverse('world')).toBe('dlrow');
- expect(StringUtils.reverse('')).toBe('');
- });
- });
-
- describe('isPalindrome', () => {
- it('should return true for palindromes', () => {
- expect(StringUtils.isPalindrome('racecar')).toBe(true);
- expect(StringUtils.isPalindrome('A man, a plan, a canal: Panama')).toBe(true);
- expect(StringUtils.isPalindrome('No lemon, no melon')).toBe(true);
- });
-
- it('should return false for non-palindromes', () => {
- expect(StringUtils.isPalindrome('hello')).toBe(false);
- expect(StringUtils.isPalindrome('world')).toBe(false);
- });
-
- it('should handle empty strings', () => {
- expect(StringUtils.isPalindrome('')).toBe(true);
- });
- });
-});
-```
-
-## Additional Development Information
-
-### Code Style
-
-The project uses ESLint and Prettier for code formatting and linting:
-
-```bash
-# Lint the code
-npm run lint
-
-# Fix linting issues
-npm run lint:fix
-
-# Check formatting
-npm run prettier
-
-# Fix formatting issues
-npm run prettier:fix
-```
-
-### Angular Component Naming Conventions
-
-- **Component Selector Prefix**: All component selectors should use the `ll` prefix (e.g., `ll-my-component`).
-- **Component Selector Style**: Component selectors should use kebab-case.
-- **Directive Selector Prefix**: All directive selectors should use the `ll` prefix.
-- **Directive Selector Style**: Directive selectors should use camelCase.
-
-### Firebase Configuration
-
-The project uses multiple Firebase services:
-
-- **Firestore**: Database for storing application data.
-- **Hosting**: For hosting the Angular application.
-- **Functions**: Backend services written in TypeScript.
-- **Storage**: For storing files.
-
-Local development uses Firebase Emulators which can be started with `npm run emulator`.
-
-### Project Structure
-
-- **src/app**: Angular application code.
-- **functions/src**: Firebase Functions code.
-- **src/app/core**: Core functionality used throughout the application.
-- **src/app/shared**: Shared components, services, and utilities.
-
-### Path Aliases
-
-The project uses TypeScript path aliases for cleaner imports:
-
-- `@shared/*`: Maps to `src/app/shared/*`
-- `@core/*`: Maps to `src/app/core/*`
+- Design services around a single responsibility
+- Use the `providedIn: 'root'` option for singleton services
+- Use the `inject()` function instead of constructor injection
diff --git a/.postcssrc.json b/.postcssrc.json
index e092dc7c..bc790554 100644
--- a/.postcssrc.json
+++ b/.postcssrc.json
@@ -1,5 +1,9 @@
{
"plugins": {
- "@tailwindcss/postcss": {}
+ "@tailwindcss/postcss": {
+ "optimize": {
+ "minify": true
+ }
+ }
}
}
diff --git a/.prettierignore b/.prettierignore
index e93938e5..0d6284cf 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -9,6 +9,8 @@ node_modules
dist
package-lock.json
+libs/
+
src/app/shared/generated/
functions
diff --git a/.prettierrc.json b/.prettierrc.json
index 4428eac4..dbb77c45 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -1,4 +1,5 @@
{
+ "plugins": ["prettier-plugin-tailwindcss"],
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
@@ -13,9 +14,9 @@
"endOfLine": "auto",
"overrides": [
{
- "files": "*.component.html",
+ "files": "*.html",
"options": {
- "parser": "angular"
+ "parser": "html"
}
},
{
@@ -25,9 +26,9 @@
}
},
{
- "files": "*.html",
+ "files": "*.component.html",
"options": {
- "parser": "html"
+ "parser": "angular"
}
}
]
diff --git a/Dockerfile b/Dockerfile
index a9032d77..4631042e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,6 +15,7 @@ WORKDIR /app
COPY . .
## UI
RUN npm install
+RUN npm run version:generate
RUN npm run build:docker
## Functions
RUN npm --prefix functions install
diff --git a/angular.json b/angular.json
index ea6a8b3d..d44f3c60 100644
--- a/angular.json
+++ b/angular.json
@@ -41,7 +41,7 @@
"src/scripts"
],
"styles": [
- "src/tailwind.css",
+ "src/styles.css",
"src/styles.scss"
],
"scripts": [
@@ -51,7 +51,7 @@
"browser": "src/main.ts",
"stylePreprocessorOptions": {
"sass": {
- "silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"]
+ "silenceDeprecations": ["color-functions", "global-builtin", "import"]
}
}
},
@@ -102,7 +102,8 @@
"buildTarget": "localess:build:production"
},
"development": {
- "buildTarget": "localess:build:development"
+ "buildTarget": "localess:build:development",
+ "hmr": true
}
},
"defaultConfiguration": "development"
@@ -119,13 +120,10 @@
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
- "karmaConfig": "karma.conf.js",
+ "karmaConfig": "karma.conf.cjs",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
- "styles": [
- "src/tailwind.css",
- "src/styles.scss"
- ],
+ "styles": ["src/styles.css", "src/styles.scss"],
"scripts": []
}
},
diff --git a/cloudbuild.yaml b/cloudbuild.yaml
index 6f31d5b6..fbef6f5e 100644
--- a/cloudbuild.yaml
+++ b/cloudbuild.yaml
@@ -1,12 +1,46 @@
steps:
+ - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
+ id: 'enable-apis'
+ entrypoint: 'bash'
+ allowFailure: true
+ args:
+ - '-c'
+ - |
+ gcloud services enable \
+ firebasestorage.googleapis.com \
+ firebaseextensions.googleapis.com \
+ cloudfunctions.googleapis.com \
+ cloudbuild.googleapis.com \
+ artifactregistry.googleapis.com \
+ run.googleapis.com \
+ eventarc.googleapis.com \
+ pubsub.googleapis.com \
+ storage.googleapis.com \
+ translate.googleapis.com \
+ cloudbilling.googleapis.com \
+ --project=$PROJECT_ID
- name: 'node:22'
id: 'install-deps'
entrypoint: npm
- args: ['install']
+ args: [ 'install' ]
- name: 'node:22'
id: 'install-functions-deps'
entrypoint: npm
- args: ['--prefix', 'functions', 'install', '--platform=linuxmusl']
+ args: [ '--prefix', 'functions', 'install', '--platform=linuxmusl' ]
+ - name: ghcr.io/lessify/firebase:edge
+ id: 'Pull Firebase Apps SDK Config'
+ args: [ 'apps:sdkconfig', '--project=$PROJECT_ID', 'web', '--out=src/environments/firebase-config-tmp.json' ]
+ - name: 'node:22'
+ id: 'Copy Firebase Apps SDK Config'
+ args: [ 'mv', '-f', 'src/environments/firebase-config-tmp.json', 'src/environments/firebase-config.json' ]
+ - name: 'node:22'
+ id: 'version-generate'
+ entrypoint: npm
+ args:
+ - 'run'
+ - 'version:generate'
+ env:
+ - 'COMMIT_SHA=$COMMIT_SHA'
- name: 'node:22'
id: 'build-prod'
entrypoint: npm
@@ -14,13 +48,6 @@ steps:
- 'run'
- 'build:prod'
env:
- - 'LOCALESS_FIREBASE_PROJECT_ID=${_LOCALESS_FIREBASE_PROJECT_ID}'
- - 'LOCALESS_FIREBASE_APP_ID=${_LOCALESS_FIREBASE_APP_ID}'
- - 'LOCALESS_FIREBASE_STORAGE_BUCKET=${_LOCALESS_FIREBASE_STORAGE_BUCKET}'
- - 'LOCALESS_FIREBASE_API_KEY=${_LOCALESS_FIREBASE_API_KEY}'
- - 'LOCALESS_FIREBASE_AUTH_DOMAIN=${_LOCALESS_FIREBASE_AUTH_DOMAIN}'
- - 'LOCALESS_FIREBASE_MESSAGING_SENDER_ID=${_LOCALESS_FIREBASE_MESSAGING_SENDER_ID}'
- - 'LOCALESS_FIREBASE_MEASUREMENT_ID=${_LOCALESS_FIREBASE_MEASUREMENT_ID}'
- 'LOCALESS_AUTH_CUSTOM_DOMAIN=${_LOCALESS_AUTH_CUSTOM_DOMAIN}'
- 'LOCALESS_AUTH_PROVIDERS=${_LOCALESS_AUTH_PROVIDERS}'
- 'LOCALESS_LOGIN_MESSAGE=${_LOCALESS_LOGIN_MESSAGE}'
@@ -28,9 +55,6 @@ steps:
# deploy to firebase
- name: ghcr.io/lessify/firebase:edge
id: 'firebase-deploy'
- args: ['deploy', '--project=$PROJECT_ID', '--only=hosting,functions,storage,firestore', '--force']
- - name: 'gcr.io/cloud-builders/gsutil'
- id: 'gsutil-cors'
- args: ['cors', 'set', 'cors.json', 'gs://${_LOCALESS_FIREBASE_STORAGE_BUCKET}']
+ args: [ 'deploy', '--project=$PROJECT_ID', '--only=hosting,functions,storage,firestore', '--force' ]
options:
logging: CLOUD_LOGGING_ONLY
diff --git a/components.json b/components.json
new file mode 100644
index 00000000..d1d70049
--- /dev/null
+++ b/components.json
@@ -0,0 +1,13 @@
+{
+ "style": "css",
+ "packageManager": "npm",
+ "tailwind": {
+ "css": "src/styles.css",
+ "baseColor": "neutral",
+ "cssVariables": true
+ },
+ "componentsPath": "libs/ui",
+ "buildable": true,
+ "generateAs": "library",
+ "importAlias": "@spartan-ng/helm"
+}
diff --git a/cors.json b/cors.json
deleted file mode 100644
index 79d5a710..00000000
--- a/cors.json
+++ /dev/null
@@ -1,7 +0,0 @@
-[
- {
- "origin": ["*"],
- "method": ["GET", "HEAD"],
- "maxAgeSeconds": 3600
- }
-]
diff --git a/firestore.rules b/firestore.rules
index a23ce861..bee52a22 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -72,6 +72,19 @@ service cloud.firestore {
allow read: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT'));
allow write: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT'));
}
+
+ // WebHooks
+ match /webhooks/{webhookId} {
+ allow read: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT'));
+ allow create: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT'));
+ allow update: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT'));
+ allow delete: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT'));
+
+ // WebHook Logs
+ match /logs/{logId} {
+ allow read: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT'));
+ }
+ }
}
// Users
diff --git a/functions/.eslintrc.cjs b/functions/.eslintrc.cjs
new file mode 100644
index 00000000..c5e3586b
--- /dev/null
+++ b/functions/.eslintrc.cjs
@@ -0,0 +1,39 @@
+module.exports = {
+ root: true,
+ env: {
+ es6: true,
+ node: true,
+ },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:import/errors',
+ 'plugin:import/warnings',
+ 'plugin:import/typescript',
+ 'google',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:prettier/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: ['tsconfig.json', 'tsconfig.dev.json'],
+ sourceType: 'module',
+ },
+ ignorePatterns: [
+ '/lib/**/*', // Ignore built files.
+ ],
+ plugins: ['@typescript-eslint', 'import'],
+ rules: {
+ quotes: ['error', 'single'],
+ 'import/no-unresolved': 0,
+ indent: ['error', 2],
+ 'max-len': ['error', { code: 180 }],
+ 'linebreak-style': 0,
+ 'prettier/prettier': [
+ 'error',
+ {
+ endOfLine: 'auto',
+ },
+ ],
+ },
+};
+
diff --git a/functions/package-lock.json b/functions/package-lock.json
index eb2df551..77a2abc6 100644
--- a/functions/package-lock.json
+++ b/functions/package-lock.json
@@ -1,29 +1,29 @@
{
"name": "functions",
- "version": "2.5.1",
+ "version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "functions",
- "version": "2.5.1",
+ "version": "3.0.0",
"dependencies": {
- "@google-cloud/translate": "^9.2.0",
- "compressing": "^2.0.0",
- "cors": "^2.8.5",
- "deepl-node": "^1.19.1",
- "exiftool-vendored": "^31.1.0",
- "express": "^5.1.0",
- "firebase-admin": "^13.5.0",
- "firebase-functions": "^6.4.0",
+ "@google-cloud/translate": "^9.3.0",
+ "compressing": "^2.1.0",
+ "cors": "^2.8.6",
+ "deepl-node": "^1.24.0",
+ "exiftool-vendored": "^35.13.1",
+ "express": "^5.2.1",
+ "firebase-admin": "^13.7.0",
+ "firebase-functions": "^7.1.0",
"fluent-ffmpeg": "^2.1.3",
- "sharp": "^0.34.4",
+ "sharp": "^0.34.5",
"uuid": "^13.0.0",
- "zod": "^3.25.75"
+ "zod": "^4.3.6"
},
"devDependencies": {
- "@types/express": "^5.0.3",
- "@types/fluent-ffmpeg": "^2.1.27",
+ "@types/express": "^5.0.6",
+ "@types/fluent-ffmpeg": "^2.1.28",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
@@ -31,7 +31,7 @@
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.32.0",
"openapi3-ts": "^4.5.0",
- "typescript": "^5.8.3"
+ "typescript": "^5.9.3"
},
"engines": {
"node": "22"
@@ -48,9 +48,9 @@
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
- "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
+ "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -516,9 +516,9 @@
}
},
"node_modules/@google-cloud/storage": {
- "version": "7.17.1",
- "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.1.tgz",
- "integrity": "sha512-2FMQbpU7qK+OtBPaegC6n+XevgZksobUGo6mGKnXNmeZpvLiAo1gTAE3oTKsrMGDV4VtL8Zzpono0YsK/Q7Iqg==",
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz",
+ "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
@@ -528,7 +528,7 @@
"abort-controller": "^3.0.0",
"async-retry": "^1.3.3",
"duplexify": "^4.1.3",
- "fast-xml-parser": "^4.4.1",
+ "fast-xml-parser": "^5.3.4",
"gaxios": "^6.0.2",
"google-auth-library": "^9.6.3",
"html-entities": "^2.5.2",
@@ -693,9 +693,9 @@
}
},
"node_modules/@google-cloud/translate": {
- "version": "9.2.0",
- "resolved": "https://registry.npmjs.org/@google-cloud/translate/-/translate-9.2.0.tgz",
- "integrity": "sha512-LBKoXMXsM6jyqD9RDO74E3Q8uUn9TWy7YwIrF+WS4I9erdI+VZHxmdffi4sFfQ196FeprfwMMAFa8Oy6u7G8xw==",
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@google-cloud/translate/-/translate-9.3.0.tgz",
+ "integrity": "sha512-OgZ2bCu3P0ZzMhEdYubwyCo/eFFlJMYalozmgOxlVcD51vCYelYUJeVnGlS+3cFQTJQX4RE84bYTKu7W0wqByw==",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/common": "^6.0.0",
@@ -812,9 +812,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz",
- "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
@@ -830,13 +830,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.3"
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz",
- "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
@@ -852,13 +852,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.3"
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz",
- "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
@@ -872,9 +872,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz",
- "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
@@ -888,9 +888,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz",
- "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
@@ -904,9 +904,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz",
- "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
@@ -920,9 +920,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz",
- "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
@@ -935,10 +935,26 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz",
- "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
@@ -952,9 +968,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz",
- "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
@@ -968,9 +984,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz",
- "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
@@ -984,9 +1000,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz",
- "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
@@ -1000,9 +1016,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz",
- "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
@@ -1018,13 +1034,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.3"
+ "@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz",
- "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
@@ -1040,13 +1056,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.3"
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz",
- "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
@@ -1062,13 +1078,35 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.3"
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz",
- "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
@@ -1084,13 +1122,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.3"
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz",
- "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
@@ -1106,13 +1144,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.3"
+ "@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz",
- "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
@@ -1128,13 +1166,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.3"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz",
- "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
@@ -1150,20 +1188,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.3"
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz",
- "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
- "@emnapi/runtime": "^1.5.0"
+ "@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -1173,9 +1211,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz",
- "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
@@ -1192,9 +1230,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz",
- "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
@@ -1211,9 +1249,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz",
- "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
@@ -1229,6 +1267,102 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "license": "MIT"
+ },
+ "node_modules/@isaacs/cliui/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
@@ -1288,11 +1422,21 @@
}
},
"node_modules/@photostructure/tz-lookup": {
- "version": "11.2.1",
- "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.2.1.tgz",
- "integrity": "sha512-ugPtvpdLwGQ8IWezSGFgUCYOpO/XXetfKLNv+UN2jjTYyfIDq9dA21GydGyzXuoQ06nN3VGBd3JxmEu+ZtXScg==",
+ "version": "11.4.0",
+ "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.4.0.tgz",
+ "integrity": "sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==",
"license": "CC0-1.0"
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -1409,15 +1553,15 @@
}
},
"node_modules/@types/express": {
- "version": "5.0.3",
- "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
- "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
- "@types/serve-static": "*"
+ "@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
@@ -1434,9 +1578,9 @@
}
},
"node_modules/@types/fluent-ffmpeg": {
- "version": "2.1.27",
- "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.27.tgz",
- "integrity": "sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==",
+ "version": "2.1.28",
+ "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz",
+ "integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1479,12 +1623,6 @@
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
"license": "MIT"
},
- "node_modules/@types/mime": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
- "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
- "license": "MIT"
- },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -1576,23 +1714,12 @@
}
},
"node_modules/@types/serve-static": {
- "version": "1.15.9",
- "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
- "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
- "@types/node": "*",
- "@types/send": "<1"
- }
- },
- "node_modules/@types/serve-static/node_modules/@types/send": {
- "version": "0.17.5",
- "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
- "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
- "license": "MIT",
- "dependencies": {
- "@types/mime": "^1",
"@types/node": "*"
}
},
@@ -2174,7 +2301,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
@@ -2198,9 +2324,9 @@
"license": "MIT"
},
"node_modules/batch-cluster": {
- "version": "15.0.1",
- "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-15.0.1.tgz",
- "integrity": "sha512-eUmh0ld1AUPKTEmdzwGF9QTSexXAyt9rA1F5zDfW1wUi3okA3Tal4NLdCeFI6aiKpBenQhR6NmK9bW9tBHTGPQ==",
+ "version": "17.3.1",
+ "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-17.3.1.tgz",
+ "integrity": "sha512-/aWEgZKXgvEseV3WEIRyjDoFka9FTrpt5+FYCxn+giUgveGBKxWjz3cl26V3aD+1kvOBP3nmANZZfcXDmKzcAA==",
"license": "MIT",
"engines": {
"node": ">=20"
@@ -2262,42 +2388,49 @@
}
},
"node_modules/body-parser": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
- "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
+ "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
- "debug": "^4.4.0",
+ "debug": "^4.4.3",
"http-errors": "^2.0.0",
- "iconv-lite": "^0.6.3",
+ "iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
- "raw-body": "^3.0.0",
- "type-is": "^2.0.0"
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
- "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
+ "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -2481,9 +2614,9 @@
}
},
"node_modules/compressing": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/compressing/-/compressing-2.0.0.tgz",
- "integrity": "sha512-hRG5wpuy/lkO/oO8AEhSmLw2FVJOs2DnFPtmm0XUVWoDP6k3HAw5RVgyzbbATl0ytjJDCY03DvRiyjHkSHc1Dg==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/compressing/-/compressing-2.1.0.tgz",
+ "integrity": "sha512-CJsNw09YdOqRJ4GJxMIlshK8OEr/0e2jvygRXNf48B73KqsF94OIjcAScq6oargUrT4MZQradg6+5Z+mNYVvDQ==",
"license": "MIT",
"dependencies": {
"@eggjs/yauzl": "^2.11.0",
@@ -2551,9 +2684,9 @@
"license": "MIT"
},
"node_modules/cors": {
- "version": "2.8.5",
- "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
- "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@@ -2561,13 +2694,16 @@
},
"engines": {
"node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -2666,9 +2802,9 @@
"license": "MIT"
},
"node_modules/deepl-node": {
- "version": "1.19.1",
- "resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.19.1.tgz",
- "integrity": "sha512-iV5AZUl+I8TOoOox3N2DvwFdHqict/egRUTmOdnfG3gwq6rwTuYWmr731MEP6JlPebiRukDbfQMGmX9jlsheJg==",
+ "version": "1.24.0",
+ "resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.24.0.tgz",
+ "integrity": "sha512-vZ9jUpzJRvFamgVOfm1LDy3YYJ7k8FhxtAX9whR92EFshLIP9JlYS0HFwXL5yYsfqzXdb/wssGRSWvR48t7nSg==",
"license": "MIT",
"dependencies": {
"@types/node": ">=12.0",
@@ -2755,9 +2891,9 @@
}
},
"node_modules/detect-libc": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
- "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -2815,6 +2951,12 @@
"stream-shift": "^1.0.2"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "license": "MIT"
+ },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -3376,14 +3518,14 @@
}
},
"node_modules/exiftool-vendored": {
- "version": "31.1.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-31.1.0.tgz",
- "integrity": "sha512-q8StxLawHLDvhqv/uoBYCfVbDskn49Cr5ouNCZhh4lgryGu1aymHwK9AvO6RcW2SbPm5MSnQDJOfGp2MW5Nnrw==",
+ "version": "35.13.1",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-35.13.1.tgz",
+ "integrity": "sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==",
"license": "MIT",
"dependencies": {
- "@photostructure/tz-lookup": "^11.2.1",
+ "@photostructure/tz-lookup": "^11.4.0",
"@types/luxon": "^3.7.1",
- "batch-cluster": "^15.0.1",
+ "batch-cluster": "^17.3.1",
"he": "^1.2.0",
"luxon": "^3.7.2"
},
@@ -3391,14 +3533,14 @@
"node": ">=20.0.0"
},
"optionalDependencies": {
- "exiftool-vendored.exe": "13.38.0",
- "exiftool-vendored.pl": "13.38.0"
+ "exiftool-vendored.exe": "13.52.0",
+ "exiftool-vendored.pl": "13.52.0"
}
},
"node_modules/exiftool-vendored.exe": {
- "version": "13.38.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.38.0.tgz",
- "integrity": "sha512-oZx5enTAvSiIAXL+OEk7nNWrfUhEdKUpaGwDjCmz4VKwOa4HbisqyM808xPGPYj8X7XikcME/fq5hvevPeE3cw==",
+ "version": "13.52.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.52.0.tgz",
+ "integrity": "sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==",
"license": "MIT",
"optional": true,
"os": [
@@ -3406,9 +3548,9 @@
]
},
"node_modules/exiftool-vendored.pl": {
- "version": "13.38.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.38.0.tgz",
- "integrity": "sha512-Q3xl1nnwswrsR5344z4NyqvI74fKwla+VJHY1N+32gcDgt8cs9KBsDUwcNzKHSOSa/MjEfniuCJVrQiqR05iag==",
+ "version": "13.52.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.52.0.tgz",
+ "integrity": "sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==",
"license": "MIT",
"optional": true,
"os": [
@@ -3419,18 +3561,19 @@
}
},
"node_modules/express": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
- "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
- "body-parser": "^2.2.0",
+ "body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
+ "depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
@@ -3525,10 +3668,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-xml-builder": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
+ "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/fast-xml-parser": {
- "version": "4.5.3",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
- "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
+ "version": "5.4.2",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz",
+ "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==",
"funding": [
{
"type": "github",
@@ -3538,7 +3694,8 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "strnum": "^1.1.1"
+ "fast-xml-builder": "^1.0.0",
+ "strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -3659,18 +3816,17 @@
}
},
"node_modules/firebase-admin": {
- "version": "13.5.0",
- "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.5.0.tgz",
- "integrity": "sha512-QZOpv1DJRJpH8NcWiL1xXE10tw3L/bdPFlgjcWrqU3ufyOJDYfxB1MMtxiVTwxK16NlybQbEM6ciSich2uWEIQ==",
+ "version": "13.7.0",
+ "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz",
+ "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==",
"license": "Apache-2.0",
"dependencies": {
"@fastify/busboy": "^3.0.0",
"@firebase/database-compat": "^2.0.0",
"@firebase/database-types": "^1.0.6",
- "@types/node": "^22.8.7",
"farmhash-modern": "^1.1.0",
"fast-deep-equal": "^3.1.1",
- "google-auth-library": "^9.14.2",
+ "google-auth-library": "^10.6.1",
"jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.1.0",
"node-forge": "^1.3.1",
@@ -3681,76 +3837,108 @@
},
"optionalDependencies": {
"@google-cloud/firestore": "^7.11.0",
- "@google-cloud/storage": "^7.14.0"
+ "@google-cloud/storage": "^7.19.0"
}
},
- "node_modules/firebase-admin/node_modules/@types/node": {
- "version": "22.18.8",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz",
- "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==",
- "license": "MIT",
+ "node_modules/firebase-admin/node_modules/gaxios": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
+ "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
+ "license": "Apache-2.0",
"dependencies": {
- "undici-types": "~6.21.0"
+ "extend": "^3.0.2",
+ "https-proxy-agent": "^7.0.1",
+ "node-fetch": "^3.3.2",
+ "rimraf": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/firebase-admin/node_modules/gcp-metadata": {
- "version": "6.1.1",
- "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
- "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
+ "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
- "gaxios": "^6.1.1",
- "google-logging-utils": "^0.0.2",
+ "gaxios": "^7.0.0",
+ "google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
- "node": ">=14"
+ "node": ">=18"
+ }
+ },
+ "node_modules/firebase-admin/node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/firebase-admin/node_modules/google-auth-library": {
- "version": "9.15.1",
- "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
- "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
+ "version": "10.6.1",
+ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz",
+ "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
- "gaxios": "^6.1.1",
- "gcp-metadata": "^6.1.0",
- "gtoken": "^7.0.0",
+ "gaxios": "7.1.3",
+ "gcp-metadata": "8.1.2",
+ "google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
- "node": ">=14"
- }
- },
- "node_modules/firebase-admin/node_modules/google-logging-utils": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
- "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=14"
+ "node": ">=18"
}
},
- "node_modules/firebase-admin/node_modules/gtoken": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
- "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
+ "node_modules/firebase-admin/node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
- "gaxios": "^6.0.0",
- "jws": "^4.0.0"
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
},
"engines": {
- "node": ">=14.0.0"
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
}
},
- "node_modules/firebase-admin/node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "license": "MIT"
+ "node_modules/firebase-admin/node_modules/rimraf": {
+ "version": "5.0.10",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
+ "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^10.3.7"
+ },
+ "bin": {
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
},
"node_modules/firebase-admin/node_modules/uuid": {
"version": "11.1.0",
@@ -3766,9 +3954,9 @@
}
},
"node_modules/firebase-functions": {
- "version": "6.4.0",
- "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.4.0.tgz",
- "integrity": "sha512-Q/LGhJrmJEhT0dbV60J4hCkVSeOM6/r7xJS/ccmkXzTWMjo+UPAYX9zlQmGlEjotstZ0U9GtQSJSgbB2Z+TJDg==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.1.0.tgz",
+ "integrity": "sha512-rOE4GD8QLyC3I66sJnoADqlOFR/SUOLNOizpQxO7Vj+rPsDSxAADZPRzxBDGoxyGz4ss7PrHwVJ/+SQAdd4qwA==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.5",
@@ -3781,10 +3969,24 @@
"firebase-functions": "lib/bin/firebase-functions.js"
},
"engines": {
- "node": ">=14.10.0"
+ "node": ">=18.0.0"
},
"peerDependencies": {
- "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
+ "@apollo/server": "^5.2.0",
+ "@as-integrations/express4": "^1.1.2",
+ "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
+ "graphql": "^16.12.0"
+ },
+ "peerDependenciesMeta": {
+ "@apollo/server": {
+ "optional": true
+ },
+ "@as-integrations/express4": {
+ "optional": true
+ },
+ "graphql": {
+ "optional": true
+ }
}
},
"node_modules/firebase-functions/node_modules/@types/express": {
@@ -4230,6 +4432,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/form-data": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz",
@@ -4362,6 +4580,7 @@
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
"license": "Apache-2.0",
+ "optional": true,
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
@@ -4382,6 +4601,7 @@
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
+ "optional": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -4715,9 +4935,9 @@
}
},
"node_modules/google-logging-utils": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.1.tgz",
- "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
+ "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
@@ -5411,6 +5631,7 @@
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=8"
},
@@ -5526,6 +5747,21 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
@@ -5953,7 +6189,6 @@
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -5975,6 +6210,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6022,6 +6266,7 @@
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
@@ -6259,6 +6504,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6305,7 +6556,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6318,6 +6568,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
@@ -6493,20 +6765,40 @@
}
},
"node_modules/raw-body": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
- "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
- "bytes": "3.1.2",
- "http-errors": "2.0.0",
- "iconv-lite": "0.7.0",
- "unpipe": "1.0.0"
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
+ "node_modules/raw-body/node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
@@ -6794,9 +7086,9 @@
"license": "MIT"
},
"node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -6897,15 +7189,15 @@
"license": "ISC"
},
"node_modules/sharp": {
- "version": "0.34.4",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz",
- "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
- "detect-libc": "^2.1.0",
- "semver": "^7.7.2"
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -6914,35 +7206,36 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.4",
- "@img/sharp-darwin-x64": "0.34.4",
- "@img/sharp-libvips-darwin-arm64": "1.2.3",
- "@img/sharp-libvips-darwin-x64": "1.2.3",
- "@img/sharp-libvips-linux-arm": "1.2.3",
- "@img/sharp-libvips-linux-arm64": "1.2.3",
- "@img/sharp-libvips-linux-ppc64": "1.2.3",
- "@img/sharp-libvips-linux-s390x": "1.2.3",
- "@img/sharp-libvips-linux-x64": "1.2.3",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.3",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.3",
- "@img/sharp-linux-arm": "0.34.4",
- "@img/sharp-linux-arm64": "0.34.4",
- "@img/sharp-linux-ppc64": "0.34.4",
- "@img/sharp-linux-s390x": "0.34.4",
- "@img/sharp-linux-x64": "0.34.4",
- "@img/sharp-linuxmusl-arm64": "0.34.4",
- "@img/sharp-linuxmusl-x64": "0.34.4",
- "@img/sharp-wasm32": "0.34.4",
- "@img/sharp-win32-arm64": "0.34.4",
- "@img/sharp-win32-ia32": "0.34.4",
- "@img/sharp-win32-x64": "0.34.4"
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -6955,7 +7248,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7033,6 +7325,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -7113,6 +7417,21 @@
"node": ">=8"
}
},
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/string.prototype.trim": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
@@ -7184,6 +7503,19 @@
"node": ">=8"
}
},
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -7208,9 +7540,9 @@
}
},
"node_modules/strnum": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
- "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
+ "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"funding": [
{
"type": "github",
@@ -7411,7 +7743,8 @@
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/ts-api-utils": {
"version": "1.4.3",
@@ -7670,7 +8003,8 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "license": "BSD-2-Clause"
+ "license": "BSD-2-Clause",
+ "optional": true
},
"node_modules/websocket-driver": {
"version": "0.7.4",
@@ -7700,6 +8034,7 @@
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
@@ -7709,7 +8044,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -7836,6 +8170,24 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -7929,9 +8281,9 @@
}
},
"node_modules/zod": {
- "version": "3.25.76",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
- "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/functions/package.json b/functions/package.json
index 50b37022..d9a50d62 100644
--- a/functions/package.json
+++ b/functions/package.json
@@ -1,6 +1,6 @@
{
"name": "functions",
- "version": "2.5.1",
+ "version": "3.0.0",
"scripts": {
"lint": "eslint --ext .js,.ts .",
"lint:fix": "eslint --fix --ext .js,.ts .",
@@ -19,22 +19,22 @@
},
"main": "lib/index.js",
"dependencies": {
- "@google-cloud/translate": "^9.2.0",
- "compressing": "^2.0.0",
- "cors": "^2.8.5",
- "deepl-node": "^1.19.1",
- "exiftool-vendored": "^31.1.0",
- "express": "^5.1.0",
- "firebase-admin": "^13.5.0",
- "firebase-functions": "^6.4.0",
+ "@google-cloud/translate": "^9.3.0",
+ "compressing": "^2.1.0",
+ "cors": "^2.8.6",
+ "deepl-node": "^1.24.0",
+ "exiftool-vendored": "^35.13.1",
+ "express": "^5.2.1",
+ "firebase-admin": "^13.7.0",
+ "firebase-functions": "^7.1.0",
"fluent-ffmpeg": "^2.1.3",
- "sharp": "^0.34.4",
+ "sharp": "^0.34.5",
"uuid": "^13.0.0",
- "zod": "^3.25.75"
+ "zod": "^4.3.6"
},
"devDependencies": {
- "@types/express": "^5.0.3",
- "@types/fluent-ffmpeg": "^2.1.27",
+ "@types/express": "^5.0.6",
+ "@types/fluent-ffmpeg": "^2.1.28",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
@@ -42,7 +42,7 @@
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.32.0",
"openapi3-ts": "^4.5.0",
- "typescript": "^5.8.3"
+ "typescript": "^5.9.3"
},
"private": true
}
diff --git a/functions/src/contents.ts b/functions/src/contents.ts
index 06c8413b..84a4b20b 100644
--- a/functions/src/contents.ts
+++ b/functions/src/contents.ts
@@ -2,32 +2,40 @@ import { logger } from 'firebase-functions/v2';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { onDocumentDeleted, onDocumentUpdated, onDocumentWritten, DocumentSnapshot } from 'firebase-functions/v2/firestore';
import { FieldValue, UpdateData, WithFieldValue } from 'firebase-admin/firestore';
-import { canPerform } from './utils/security-utils';
+import { AuthData, canPerform } from './utils/user-auth-utils';
import { BATCH_MAX, bucket, firestoreService } from './config';
import {
Content,
+ ContentData,
ContentDocument,
ContentDocumentStorage,
ContentHistory,
ContentHistoryType,
ContentKind,
- ContentLink,
PublishContentData,
Schema,
+ SchemaComponent,
+ SchemaFieldKind,
+ SchemaType,
Space,
+ TranslateContentLocaleData,
UserPermission,
+ WebHookEvent,
+ WebHookPayload,
} from './models';
import {
extractContent,
findAllContentsByParentSlug,
findContentById,
findContentsHistory,
- findDocumentsToPublishByStartFullSlug,
+ findDocumentsToPublishByParentSlug,
findSchemas,
findSpaceById,
spaceContentCachePath,
} from './services';
-import { AuthData } from 'firebase-functions/lib/common/providers/https';
+import { translateWithGoogle } from './services/translate.service';
+import { triggerWebHooksForEvent } from './utils/webhook-utils';
+import { isSchemaFieldKindAITranslatable } from './utils/translate.utils';
// Publish
const publish = onCall(async request => {
@@ -46,17 +54,29 @@ const publish = onCall(async request => {
if (content.kind === ContentKind.DOCUMENT) {
await publishDocument(spaceId, space, contentId, content, contentSnapshot, schemas, auth);
} else if (content.kind === ContentKind.FOLDER) {
- const documentsSnapshot = await findDocumentsToPublishByStartFullSlug(spaceId, `${content.fullSlug}/`).get();
+ const documentsSnapshot = await findDocumentsToPublishByParentSlug(spaceId, content.fullSlug).get();
for (const documentSnapshot of documentsSnapshot.docs) {
const document = documentSnapshot.data() as ContentDocument;
+ const isAlreadyPublished = document.publishedAt ? document.publishedAt.seconds > document.updatedAt.seconds : false;
+ logger.info('[Content::contentPublish] check', document.fullSlug, 'isAlreadyPublished', isAlreadyPublished);
// SKIP if the page was already published, by comparing publishedAt and updatedAt
- if (document.publishedAt && document.publishedAt.seconds > document.updatedAt.seconds) continue;
- await publishDocument(spaceId, space, contentId, document, documentSnapshot, schemas, auth);
+ if (isAlreadyPublished) continue;
+ await publishDocument(spaceId, space, documentSnapshot.id, document, documentSnapshot, schemas, auth);
}
}
- // Save Cache
- logger.info(`[Content::contentPublish] Save file to spaces/${spaceId}/contents/${contentId}/cache.json`);
- await bucket.file(`spaces/${spaceId}/contents/${contentId}/cache.json`).save('');
+
+ // Trigger webhooks for content published event
+ const webhookPayload: WebHookPayload = {
+ event: WebHookEvent.CONTENT_PUBLISHED,
+ spaceId,
+ timestamp: new Date().toISOString(),
+ data: {
+ contentId,
+ content,
+ },
+ };
+ await triggerWebHooksForEvent(spaceId, webhookPayload);
+
return;
} else {
logger.info(`[Content::contentPublish] Content ${contentId} does not exist.`);
@@ -83,29 +103,6 @@ async function publishDocument(
schemas: Map,
auth?: AuthData
) {
- let aggReferences: Record | undefined;
- if (document.references && document.references.length > 0) {
- aggReferences = {};
- for (const refId of document.references) {
- const contentSnapshot = await findContentById(spaceId, refId).get();
- const content = contentSnapshot.data() as Content;
- const link: ContentLink = {
- id: contentSnapshot.id,
- kind: content.kind,
- name: content.name,
- slug: content.slug,
- fullSlug: content.fullSlug,
- parentSlug: content.parentSlug,
- createdAt: content.createdAt.toDate().toISOString(),
- updatedAt: content.updatedAt.toDate().toISOString(),
- };
- if (content.kind === ContentKind.DOCUMENT) {
- link.publishedAt = content.publishedAt?.toDate().toISOString();
- }
- aggReferences[refId] = link;
- }
- }
-
for (const locale of space.locales) {
const documentStorage: ContentDocumentStorage = {
id: documentId,
@@ -126,22 +123,97 @@ async function publishDocument(
documentStorage.data = extractContent(document.data, schemas, locale.id);
}
}
- if (aggReferences) {
- documentStorage.links = aggReferences;
+ if (document.links && document.links.length > 0) {
+ documentStorage.links = document.links;
+ }
+ if (document.references && document.references.length > 0) {
+ documentStorage.references = document.references;
}
// Save generated JSON
logger.info(`[Content::contentPublish] Save file to spaces/${spaceId}/contents/${documentId}/${locale.id}.json`);
await bucket.file(`spaces/${spaceId}/contents/${documentId}/${locale.id}.json`).save(JSON.stringify(documentStorage));
- // Update publishedAt
- await documentSnapshot.ref.update({ publishedAt: FieldValue.serverTimestamp() });
- const addHistory: WithFieldValue = {
- type: ContentHistoryType.PUBLISHED,
- name: auth?.token['name'] || FieldValue.delete(),
- email: auth?.token.email || FieldValue.delete(),
- createdAt: FieldValue.serverTimestamp(),
+ }
+ // Update publishedAt
+ await documentSnapshot.ref.update({ publishedAt: FieldValue.serverTimestamp() });
+ const addHistory: WithFieldValue = {
+ type: ContentHistoryType.PUBLISHED,
+ name: auth?.token['name'] || FieldValue.delete(),
+ email: auth?.token.email || FieldValue.delete(),
+ createdAt: FieldValue.serverTimestamp(),
+ };
+ await findContentsHistory(spaceId, documentId).add(addHistory);
+}
+
+// Unpublish
+const unpublish = onCall(async request => {
+ logger.info('[Content::contentUnpublish] data: ' + JSON.stringify(request.data));
+ logger.info('[Content::contentUnpublish] context.auth: ' + JSON.stringify(request.auth));
+ const { auth, data } = request;
+ if (!canPerform(UserPermission.CONTENT_PUBLISH, auth)) throw new HttpsError('permission-denied', 'permission-denied');
+ const { spaceId, contentId } = data;
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ const contentSnapshot = await findContentById(spaceId, contentId).get();
+ if (spaceSnapshot.exists && contentSnapshot.exists) {
+ const space: Space = spaceSnapshot.data() as Space;
+ const content: Content = contentSnapshot.data() as Content;
+ if (content.kind === ContentKind.DOCUMENT) {
+ await unpublishDocument(spaceId, space, contentId, contentSnapshot, auth);
+ } else if (content.kind === ContentKind.FOLDER) {
+ const documentsSnapshot = await findDocumentsToPublishByParentSlug(spaceId, content.fullSlug).get();
+ for (const documentSnapshot of documentsSnapshot.docs) {
+ const document = documentSnapshot.data() as ContentDocument;
+ // SKIP if the document is not published
+ if (!document.publishedAt) continue;
+ await unpublishDocument(spaceId, space, documentSnapshot.id, documentSnapshot, auth);
+ }
+ }
+
+ // Trigger webhooks for content unpublished event
+ const webhookPayload: WebHookPayload = {
+ event: WebHookEvent.CONTENT_UNPUBLISHED,
+ spaceId,
+ timestamp: new Date().toISOString(),
+ data: {
+ contentId,
+ content,
+ },
};
- await findContentsHistory(spaceId, documentId).add(addHistory);
+ await triggerWebHooksForEvent(spaceId, webhookPayload);
+
+ return;
+ } else {
+ logger.info(`[Content::contentUnpublish] Content ${contentId} does not exist.`);
+ throw new HttpsError('not-found', 'Content not found');
}
+});
+
+/**
+ * Unpublish Document
+ * @param {string} spaceId space id
+ * @param {Space} space
+ * @param {string} documentId
+ * @param {DocumentSnapshot} documentSnapshot
+ * @param {AuthData} auth
+ */
+async function unpublishDocument(spaceId: string, space: Space, documentId: string, documentSnapshot: DocumentSnapshot, auth?: AuthData) {
+ for (const locale of space.locales) {
+ // Delete published JSON files
+ logger.info(`[Content::contentUnpublish] Delete file spaces/${spaceId}/contents/${documentId}/${locale.id}.json`);
+ try {
+ await bucket.file(`spaces/${spaceId}/contents/${documentId}/${locale.id}.json`).delete();
+ } catch (error) {
+ logger.warn(`[Content::contentUnpublish] File not found or already deleted: ${error}`);
+ }
+ }
+ // Clear publishedAt timestamp
+ await documentSnapshot.ref.update({ publishedAt: FieldValue.delete() });
+ const addHistory: WithFieldValue = {
+ type: ContentHistoryType.UNPUBLISHED,
+ name: auth?.token['name'] || FieldValue.delete(),
+ email: auth?.token.email || FieldValue.delete(),
+ createdAt: FieldValue.serverTimestamp(),
+ };
+ await findContentsHistory(spaceId, documentId).add(addHistory);
}
// Firestore events
@@ -171,28 +243,6 @@ const onContentUpdate = onDocumentUpdated('spaces/{spaceId}/contents/{contentId}
const space: Space = spaceSnapshot.data() as Space;
const document: ContentDocument = contentAfter;
const schemas = new Map(schemasSnapshot.docs.map(it => [it.id, it.data() as Schema]));
- let aggReferences: Record | undefined;
- if (document.references && document.references.length > 0) {
- aggReferences = {};
- for (const refId of document.references) {
- const contentSnapshot = await findContentById(spaceId, refId).get();
- const content = contentSnapshot.data() as Content;
- const link: ContentLink = {
- id: contentSnapshot.id,
- kind: content.kind,
- name: content.name,
- slug: content.slug,
- fullSlug: content.fullSlug,
- parentSlug: content.parentSlug,
- createdAt: content.createdAt.toDate().toISOString(),
- updatedAt: content.updatedAt.toDate().toISOString(),
- };
- if (content.kind === ContentKind.DOCUMENT) {
- link.publishedAt = content.publishedAt?.toDate().toISOString();
- }
- aggReferences[refId] = link;
- }
- }
for (const locale of space.locales) {
const documentStorage: ContentDocumentStorage = {
id: event.data.after.id,
@@ -212,16 +262,29 @@ const onContentUpdate = onDocumentUpdated('spaces/{spaceId}/contents/{contentId}
documentStorage.data = extractContent(document.data, schemas, locale.id);
}
}
- if (aggReferences) {
- documentStorage.links = aggReferences;
+ if (document.links && document.links.length > 0) {
+ documentStorage.links = document.links;
+ }
+ if (document.references && document.references.length > 0) {
+ documentStorage.references = document.references;
}
// Save generated JSON
logger.info(`[Content::onUpdate] Save file to spaces/${spaceId}/contents/${contentId}/draft/${locale.id}.json`);
await bucket.file(`spaces/${spaceId}/contents/${contentId}/draft/${locale.id}.json`).save(JSON.stringify(documentStorage));
- // Save Cache
- logger.info(`[Content::onUpdate] Save file to spaces/${spaceId}/contents/${contentId}/draft/cache.json`);
- await bucket.file(`spaces/${spaceId}/contents/${contentId}/draft/cache.json`).save('');
}
+
+ // Trigger webhooks for content saved/updated event
+ const webhookPayload: WebHookPayload = {
+ event: WebHookEvent.CONTENT_UPDATED,
+ spaceId,
+ timestamp: new Date().toISOString(),
+ data: {
+ contentId,
+ content: contentAfter,
+ previousContent: contentBefore,
+ },
+ };
+ await triggerWebHooksForEvent(spaceId, webhookPayload);
}
} else {
// In case it is a PAGE, skip recursion as PAGE doesn't have child
@@ -282,7 +345,20 @@ const onContentDelete = onDocumentDeleted('spaces/{spaceId}/contents/{contentId}
const content = event.data.data() as Content;
logger.info(`[Content::onDelete] eventId='${event.id}' id='${event.data.id}' fullSlug='${content.fullSlug}'`);
- // Logic related to delete, in case a folder is deleted it should be cascaded to all childs
+
+ // Trigger webhooks for content deleted event
+ const webhookPayload: WebHookPayload = {
+ event: WebHookEvent.CONTENT_DELETED,
+ spaceId,
+ timestamp: new Date().toISOString(),
+ data: {
+ contentId,
+ content,
+ },
+ };
+ await triggerWebHooksForEvent(spaceId, webhookPayload);
+
+ // Logic related to delete, in case a folder is deleted it should be cascaded to all children
if (content.kind === ContentKind.DOCUMENT) {
await bucket.deleteFiles({
prefix: `spaces/${spaceId}/contents/${contentId}`,
@@ -386,9 +462,113 @@ const onContentWrite = onDocumentWritten('spaces/{spaceId}/contents/{contentId}'
return;
});
+/**
+ * Recursively collect translatable fields that have a source value but no target translation.
+ * Each task carries the source value and a setter that writes the translation directly into parsedData.
+ * @param {ContentData} data Parsed content data node (mutated in-place via setters)
+ * @param {Map} schemas Schema map
+ * @param {string} sourceLocaleId Source locale identifier
+ * @param {string} targetLocaleId Target locale identifier
+ * @return {Array} List of { fieldName, sourceValue, setter } entries
+ */
+function collectTranslatableTasks(
+ data: ContentData,
+ schemas: Map,
+ sourceLocaleId: string | undefined,
+ targetLocaleId: string
+): Array<{ fieldName: string; sourceValue: string; setter: (value: string) => void }> {
+ const tasks: Array<{ fieldName: string; sourceValue: string; setter: (value: string) => void }> = [];
+ const schema = schemas.get(data.schema);
+ if (!schema || (schema.type !== SchemaType.ROOT && schema.type !== SchemaType.NODE)) return tasks;
+
+ for (const field of (schema as SchemaComponent).fields || []) {
+ if (!isSchemaFieldKindAITranslatable(field.kind)) {
+ continue;
+ }
+ if (field.kind === SchemaFieldKind.SCHEMA) {
+ const nested: ContentData | undefined = data[field.name];
+ if (nested) {
+ tasks.push(...collectTranslatableTasks(nested, schemas, sourceLocaleId, targetLocaleId));
+ }
+ } else if (field.kind === SchemaFieldKind.SCHEMAS) {
+ const items: ContentData[] | undefined = data[field.name];
+ if (Array.isArray(items)) {
+ for (const item of items) {
+ tasks.push(...collectTranslatableTasks(item, schemas, sourceLocaleId, targetLocaleId));
+ }
+ }
+ } else if (field.translatable) {
+ const targetKey = `${field.name}_i18n_${targetLocaleId}`;
+ const sourceValue: string | undefined = sourceLocaleId ? data[`${field.name}_i18n_${sourceLocaleId}`] : data[field.name];
+ const targetValue: string | undefined = data[targetKey];
+ if (sourceValue && !targetValue) {
+ tasks.push({ fieldName: field.name, sourceValue, setter: value => (data[targetKey] = value) });
+ }
+ }
+ }
+ return tasks;
+}
+
+// Translate Content Locale
+const translateLocale = onCall(async request => {
+ logger.info('[Content::translateLocale] data: ' + JSON.stringify(request.data));
+ logger.info('[Content::translateLocale] context.auth: ' + JSON.stringify(request.auth));
+ const { auth, data } = request;
+ if (!canPerform(UserPermission.CONTENT_UPDATE, auth)) throw new HttpsError('permission-denied', 'permission-denied');
+ const { spaceId, contentId, sourceLocaleId, targetLocaleId } = data;
+
+ const [contentSnapshot, schemasSnapshot] = await Promise.all([findContentById(spaceId, contentId).get(), findSchemas(spaceId).get()]);
+
+ if (!contentSnapshot.exists) {
+ logger.info(`[Content::translateLocale] Content ${contentId} does not exist.`);
+ throw new HttpsError('not-found', 'Content not found');
+ }
+
+ const document = contentSnapshot.data() as ContentDocument;
+ if (document.kind !== ContentKind.DOCUMENT || !document.data) {
+ logger.info(`[Content::translateLocale] Content ${contentId} has no translatable data.`);
+ return;
+ }
+
+ const schemas = new Map(schemasSnapshot.docs.map(it => [it.id, it.data() as Schema]));
+ const parsedData: ContentData = typeof document.data === 'string' ? JSON.parse(document.data) : document.data;
+ const tasks = collectTranslatableTasks(parsedData, schemas, sourceLocaleId, targetLocaleId);
+
+ if (tasks.length === 0) {
+ logger.info(`[Content::translateLocale] No translatable fields found for content ${contentId}.`);
+ return;
+ }
+
+ // Translate all fields concurrently
+ const results = await Promise.all(
+ tasks.map(async ({ fieldName, sourceValue, setter }) => {
+ try {
+ const translatedValue = await translateWithGoogle(sourceValue, sourceLocaleId, targetLocaleId);
+ return { fieldName, setter, translatedValue };
+ } catch (e) {
+ logger.error(`[Content::translateLocale] Failed to translate field '${fieldName}': ${e}`);
+ return { fieldName, setter, translatedValue: null };
+ }
+ })
+ );
+
+ // Apply translated values back into parsedData via setters, then persist as JSON string
+ let counter = 0;
+ for (const { setter, translatedValue } of results) {
+ if (translatedValue) {
+ setter(translatedValue);
+ counter++;
+ }
+ }
+ await contentSnapshot.ref.update({ data: JSON.stringify(parsedData), updatedAt: FieldValue.serverTimestamp() });
+ logger.info(`[Content::translateLocale] Successfully updated ${counter} fields for content ${contentId}.`);
+});
+
export const content = {
publish: publish,
+ unpublish: unpublish,
onupdate: onContentUpdate,
ondelete: onContentDelete,
onwrite: onContentWrite,
+ translatelocale: translateLocale,
};
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 1ec68c22..93a616f5 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -29,6 +29,8 @@ export { translation } from './translations';
export { user } from './users';
+export { webhook } from './webhooks';
+
export { v1 as publicv1 } from './v1';
// Plugins API
diff --git a/functions/src/models/asset.model.ts b/functions/src/models/asset.model.ts
index b0449b88..2ef87136 100644
--- a/functions/src/models/asset.model.ts
+++ b/functions/src/models/asset.model.ts
@@ -44,7 +44,7 @@ export type AssetMetadata =
width?: number;
height?: number;
orientation?: 'landscape' | 'portrait' | 'squarish';
- duration?: number;
+ duration?: number | string;
}
| {
type: 'video';
@@ -52,7 +52,7 @@ export type AssetMetadata =
width?: number;
height?: number;
orientation?: 'landscape' | 'portrait' | 'squarish';
- duration?: number;
+ duration?: number | string;
};
// Import and Export
diff --git a/functions/src/models/asset.zod.ts b/functions/src/models/asset.zod.ts
index 469c499c..28e2e266 100644
--- a/functions/src/models/asset.zod.ts
+++ b/functions/src/models/asset.zod.ts
@@ -3,7 +3,7 @@ import { AssetKind } from './asset.model';
export const assetBaseSchema = z.object({
id: z.string(),
- kind: z.nativeEnum(AssetKind),
+ kind: z.enum(AssetKind),
name: z.string(),
parentPath: z.string(),
});
diff --git a/functions/src/models/content-history.model.ts b/functions/src/models/content-history.model.ts
index 85fff4a1..6d53d685 100644
--- a/functions/src/models/content-history.model.ts
+++ b/functions/src/models/content-history.model.ts
@@ -2,12 +2,18 @@ import { Timestamp } from 'firebase-admin/firestore';
export enum ContentHistoryType {
PUBLISHED = 'PUBLISHED',
+ UNPUBLISHED = 'UNPUBLISHED',
CREATE = 'CREATE',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
}
-export type ContentHistory = ContentHistoryPublish | ContentHistoryCreate | ContentHistoryUpdate | ContentHistoryDelete;
+export type ContentHistory =
+ | ContentHistoryPublish
+ | ContentHistoryUnpublish
+ | ContentHistoryCreate
+ | ContentHistoryUpdate
+ | ContentHistoryDelete;
export interface ContentHistoryBase {
type: ContentHistoryType;
@@ -20,6 +26,10 @@ export interface ContentHistoryPublish extends ContentHistoryBase {
type: ContentHistoryType.PUBLISHED;
}
+export interface ContentHistoryUnpublish extends ContentHistoryBase {
+ type: ContentHistoryType.UNPUBLISHED;
+}
+
export interface ContentHistoryCreate extends ContentHistoryBase {
type: ContentHistoryType.CREATE;
cName: string;
diff --git a/functions/src/models/content.model.ts b/functions/src/models/content.model.ts
index b59babab..00ef9f87 100644
--- a/functions/src/models/content.model.ts
+++ b/functions/src/models/content.model.ts
@@ -26,6 +26,7 @@ export interface ContentDocument extends Co
data?: T | string;
publishedAt?: Timestamp;
assets?: string[];
+ links?: string[];
references?: string[];
}
@@ -45,6 +46,22 @@ export interface PublishContentData {
// Storage
export interface ContentDocumentStorage {
+ id: string;
+ name: string;
+ kind: ContentKind;
+ slug: string;
+ locale: string;
+ parentSlug: string;
+ fullSlug: string;
+ data?: ContentData;
+ createdAt: string;
+ updatedAt: string;
+ publishedAt?: string;
+ links?: string[];
+ references?: string[];
+}
+
+export interface ContentDocumentApi {
id: string;
name: string;
kind: ContentKind;
@@ -57,6 +74,7 @@ export interface ContentDocumentStorage {
updatedAt: string;
publishedAt?: string;
links?: Record;
+ references?: Record;
}
export interface ContentData extends Record {
@@ -99,19 +117,6 @@ export interface RichTextContent {
content?: RichTextContent[];
}
-// export type RichTextContent = {
-// type?: string;
-// attrs?: Record;
-// content?: RichTextContent[];
-// marks?: {
-// type: string;
-// attrs?: Record;
-// [key: string]: any;
-// }[];
-// text?: string;
-// [key: string]: any;
-// };
-
// Import and Export
export interface ContentFolderExport extends Omit {
id: string;
@@ -122,3 +127,10 @@ export interface ContentDocumentExport extends Omit {
id: string;
}
+
+export type TranslationUpdate = z.infer;
+
+export interface TranslateLocaleData {
+ spaceId: string;
+ sourceLocaleId: string;
+ targetLocaleId: string;
+}
diff --git a/functions/src/models/translation.zod.ts b/functions/src/models/translation.zod.ts
index b6f14b91..048377fe 100644
--- a/functions/src/models/translation.zod.ts
+++ b/functions/src/models/translation.zod.ts
@@ -5,7 +5,7 @@ import { z } from 'zod';
export const translationSchema = z.object({
id: z.string(),
- type: z.nativeEnum(TranslationType),
+ type: z.enum(TranslationType),
locales: z.record(z.string(), z.string()),
labels: z.array(z.string()).optional(),
description: z.string().optional(),
@@ -14,3 +14,9 @@ export const translationSchema = z.object({
export const zTranslationExportArraySchema = z.array(translationSchema);
export const zTranslationFlatExportSchema = z.record(z.string(), z.string());
+
+export const zTranslationUpdateSchema = z.object({
+ dryRun: z.boolean().optional(),
+ type: z.enum(['add-missing', 'update-existing', 'delete-missing']),
+ values: z.record(z.string(), z.string()),
+});
diff --git a/functions/src/models/webhook.model.ts b/functions/src/models/webhook.model.ts
new file mode 100644
index 00000000..bce54b3d
--- /dev/null
+++ b/functions/src/models/webhook.model.ts
@@ -0,0 +1,46 @@
+import { Timestamp } from 'firebase-admin/firestore';
+
+export interface WebHook {
+ name: string;
+ url: string;
+ enabled: boolean;
+ events: WebHookEvent[];
+ headers?: Record;
+ secret?: string;
+ createdAt: Timestamp;
+ updatedAt: Timestamp;
+}
+
+export enum WebHookEvent {
+ CONTENT_PUBLISHED = 'content.published',
+ CONTENT_UNPUBLISHED = 'content.unpublished',
+ CONTENT_DELETED = 'content.deleted',
+ CONTENT_UPDATED = 'content.updated',
+}
+
+export type WebHookStatus = 'success' | 'failure';
+
+export interface WebHookPayload {
+ event: WebHookEvent;
+ spaceId: string;
+ timestamp: string;
+ data: WebHookPayloadData;
+ signature?: string;
+}
+
+export interface WebHookPayloadData {
+ contentId: string;
+ content: any;
+ previousContent?: any;
+}
+
+export interface WebHookLog {
+ webhookId: string;
+ event: WebHookEvent;
+ url: string;
+ status: WebHookStatus;
+ statusCode?: number;
+ responseTime?: number;
+ errorMessage?: string;
+ createdAt: Timestamp;
+}
diff --git a/functions/src/services/asset.service.ts b/functions/src/services/asset.service.ts
index e20040c4..6f549a6e 100644
--- a/functions/src/services/asset.service.ts
+++ b/functions/src/services/asset.service.ts
@@ -198,6 +198,11 @@ export async function updateMetadataByRef(assetRef: DocumentReference): Promise<
}
}
} else {
+ if (asset.kind === AssetKind.FILE && asset.inProgress) {
+ await assetRef.update({
+ inProgress: FieldValue.delete(),
+ });
+ }
return;
}
await assetRef.update(update);
diff --git a/functions/src/services/content.service.ts b/functions/src/services/content.service.ts
index 47b2adb2..efc0ce42 100644
--- a/functions/src/services/content.service.ts
+++ b/functions/src/services/content.service.ts
@@ -1,13 +1,16 @@
import { DocumentReference, Query, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions/v2';
-import { firestoreService } from '../config';
+import { bucket, firestoreService } from '../config';
import {
Content,
ContentData,
+ ContentDocumentApi,
ContentDocumentExport,
+ ContentDocumentStorage,
ContentExport,
ContentFolderExport,
ContentKind,
+ ContentLink,
Schema,
SchemaFieldKind,
SchemaType,
@@ -71,17 +74,17 @@ export function findContentsByStartFullSlug(spaceId: string, startFullSlug: stri
}
/**
- * find Content by Full Slug
+ * find Content by Parent Slug
* @param {string} spaceId Space identifier
- * @param {string} startFullSlug Start Full Slug path
+ * @param {string} parentSlug Parent Slug path
* @return {DocumentReference} document reference to the space
*/
-export function findDocumentsToPublishByStartFullSlug(spaceId: string, startFullSlug: string): Query {
- logger.info(`[findDocumentsToPublishByStartFullSlug] spaceId=${spaceId} startFullSlug=${startFullSlug}`);
+export function findDocumentsToPublishByParentSlug(spaceId: string, parentSlug: string): Query {
+ logger.info(`[findDocumentsToPublishByParentSlug] spaceId=${spaceId} parentSlug=${parentSlug}`);
return firestoreService
.collection(`spaces/${spaceId}/contents`)
- .where('fullSlug', '>=', startFullSlug)
- .where('fullSlug', '<', `${startFullSlug}~`)
+ .where('parentSlug', '>=', parentSlug)
+ .where('parentSlug', '<', `${parentSlug}/~`)
.where('kind', '==', ContentKind.DOCUMENT);
}
@@ -130,21 +133,6 @@ export function contentLocaleCachePath(spaceId: string, contentId: string, local
}
}
-/**
- * construct content cache path, will return url to the cache file for cache version identifier
- * @param {string} spaceId
- * @param {string} contentId
- * @param {string} version
- * @return {string} path
- */
-export function contentCachePath(spaceId: string, contentId: string, version: string | 'draft' | undefined): string {
- if (version === 'draft') {
- return `spaces/${spaceId}/contents/${contentId}/${version}/cache.json`;
- } else {
- return `spaces/${spaceId}/contents/${contentId}/cache.json`;
- }
-}
-
/**
* construct content cache path, will return url to the cache file for cache version identifier
* @param {string} spaceId
@@ -226,3 +214,75 @@ export function docContentToExport(docId: string, content: Content): ContentExpo
}
return undefined;
}
+
+/**
+ * Resolve references for a single content document
+ * @param {string} spaceId Space identifier
+ * @param {ContentDocumentStorage} content Content document
+ * @param {string} locale Locale identifier
+ * @param {string} version Content version
+ * @return {Promise>} Map of reference ID to content
+ */
+export async function resolveReferences(
+ spaceId: string,
+ content: ContentDocumentStorage,
+ locale: string,
+ version: string | 'draft' | undefined
+): Promise | undefined> {
+ if (!content.references || content.references.length === 0) {
+ return undefined;
+ }
+ const resolvedReferences: Record = {};
+ await Promise.all(
+ content.references.map(async refId => {
+ try {
+ const refCachePath = contentLocaleCachePath(spaceId, refId, locale, version);
+ const [exists] = await bucket.file(refCachePath).exists();
+
+ if (exists) {
+ const [fileContent] = await bucket.file(refCachePath).download();
+ resolvedReferences[refId] = JSON.parse(fileContent.toString());
+ } else {
+ logger.warn(`[ReferenceResolver::resolveReferences] Reference ${refId} not found at ${refCachePath}`);
+ }
+ } catch (error) {
+ logger.error(`[ReferenceResolver::resolveReferences] Failed to resolve reference ${refId}:`, error);
+ }
+ })
+ );
+ return resolvedReferences;
+}
+
+/**
+ * Resolve links for a single content document
+ * @param {string} spaceId Space identifier
+ * @param {ContentDocumentStorage} content Content document
+ * @return {Promise>} Map of reference ID to content
+ */
+export async function resolveLinks(spaceId: string, content: ContentDocumentStorage): Promise | undefined> {
+ if (!content.links || content.links.length === 0) {
+ return undefined;
+ }
+ const resolvedLinks: Record = {};
+ await Promise.all(
+ content.links.map(async linkId => {
+ const contentSnapshot = await findContentById(spaceId, linkId).get();
+ const content = contentSnapshot.data() as Content;
+ const link: ContentLink = {
+ id: contentSnapshot.id,
+ kind: content.kind,
+ name: content.name,
+ slug: content.slug,
+ fullSlug: content.fullSlug,
+ parentSlug: content.parentSlug,
+ createdAt: content.createdAt.toDate().toISOString(),
+ updatedAt: content.updatedAt.toDate().toISOString(),
+ };
+ if (content.kind === ContentKind.DOCUMENT) {
+ link.publishedAt = content.publishedAt?.toDate().toISOString();
+ }
+ resolvedLinks[linkId] = link;
+ })
+ );
+ return resolvedLinks;
+}
diff --git a/functions/src/services/index.ts b/functions/src/services/index.ts
index e0646e05..f2494df8 100644
--- a/functions/src/services/index.ts
+++ b/functions/src/services/index.ts
@@ -9,3 +9,4 @@ export * from './token.service';
export * from './translation.service';
export * from './translation-history.service';
export * from './user.service';
+export * from './webhook.service';
diff --git a/functions/src/services/open-api.service.ts b/functions/src/services/open-api.service.ts
index d93ba29e..7049949e 100644
--- a/functions/src/services/open-api.service.ts
+++ b/functions/src/services/open-api.service.ts
@@ -147,13 +147,22 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject
},
{
type: 'object',
- description: 'References of all links used in the content.',
+ description: 'All links used in the content.',
properties: {
links: {
$ref: '#/components/schemas/Links',
},
},
},
+ {
+ type: 'object',
+ description: 'All references used in the content.',
+ properties: {
+ references: {
+ $ref: '#/components/schemas/References',
+ },
+ },
+ },
],
example: {
id: 'WLWc4vOACzG1QjK9AEo9',
@@ -282,6 +291,51 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject
},
},
},
+ References: {
+ description: 'Key-Value Object. Where Key is Unique identifier for the Content object and Value is Content.',
+ type: 'object',
+ additionalProperties: {
+ $ref: '#/components/schemas/Content',
+ },
+ example: {
+ '4pLYWyN47c076mSfpWIy': {
+ id: '4pLYWyN47c076mSfpWIy',
+ kind: 'DOCUMENT',
+ name: 'Options',
+ slug: 'options',
+ fullSlug: 'docs/schemas/options',
+ parentSlug: 'docs/schemas',
+ createdAt: '2024-05-01T09:56:00.923Z',
+ updatedAt: '2024-05-01T09:57:06.445Z',
+ publishedAt: '2024-05-01T13:03:31.672Z',
+ data: {},
+ },
+ '5L2ELYsmaM6C0fOBzKp0': {
+ id: '5L2ELYsmaM6C0fOBzKp0',
+ kind: 'DOCUMENT',
+ name: 'Translations',
+ slug: 'translations',
+ fullSlug: 'docs/api/translations',
+ parentSlug: 'docs/api',
+ createdAt: '2024-05-04T14:03:54.100Z',
+ updatedAt: '2024-05-07T14:03:57.457Z',
+ publishedAt: '2024-05-07T11:05:46.094Z',
+ data: {},
+ },
+ '7fUavXjDpFuHGdWgTFXy': {
+ id: '7fUavXjDpFuHGdWgTFXy',
+ kind: 'DOCUMENT',
+ name: 'Overview',
+ slug: 'overview',
+ fullSlug: 'docs/content/overview',
+ parentSlug: 'docs/content',
+ createdAt: '2024-04-30T20:57:41.827Z',
+ updatedAt: '2024-04-30T21:31:47.344Z',
+ publishedAt: '2024-05-01T13:02:41.814Z',
+ data: {},
+ },
+ },
+ },
};
const rootSchemas: string[] = [];
for (const [key, item] of schemasById.entries()) {
@@ -311,7 +365,7 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject
openapi: '3.0.3',
info: {
title: 'Localess Open API Specification',
- version: '2.5.1',
+ version: '3.0.0',
description: 'Fetch data from Localess via REST API',
contact: {
name: 'Lessify Team',
@@ -368,6 +422,17 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject
example: '1706217382028',
},
},
+ {
+ name: 'version',
+ in: 'query',
+ description: 'Translation version.',
+ required: false,
+ schema: {
+ type: 'string',
+ enum: ['draft'],
+ example: 'draft',
+ },
+ },
{
name: 'token',
in: 'query',
@@ -559,6 +624,26 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject
example: 'fb6oTcVjbnyLCMhO2iLY',
},
},
+ {
+ name: 'resolveReference',
+ in: 'query',
+ description: 'Resolve all references.',
+ required: false,
+ schema: {
+ type: 'boolean',
+ example: 'true',
+ },
+ },
+ {
+ name: 'resolveLink',
+ in: 'query',
+ description: 'Resolve all links.',
+ required: false,
+ schema: {
+ type: 'boolean',
+ example: 'true',
+ },
+ },
],
responses: {
'200': {
@@ -649,6 +734,26 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject
example: 'fb6oTcVjbnyLCMhO2iLY',
},
},
+ {
+ name: 'resolveReference',
+ in: 'query',
+ description: 'Resolve all references.',
+ required: false,
+ schema: {
+ type: 'boolean',
+ example: 'true',
+ },
+ },
+ {
+ name: 'resolveLink',
+ in: 'query',
+ description: 'Resolve all links.',
+ required: false,
+ schema: {
+ type: 'boolean',
+ example: 'true',
+ },
+ },
],
responses: {
'200': {
@@ -902,58 +1007,31 @@ export function fieldToOpenApiSchemaDefinition(field: SchemaField): [string, Sch
];
}
if (field.kind === SchemaFieldKind.OPTION) {
- if (field.source === undefined || field.source === 'self') {
- return [
- field.name,
- {
- type: 'string',
- description: field.description,
- enum: field.options?.map(it => it.value),
- },
- ];
- } else {
- const name = field.source || 'unknown';
- const pascalName = name[0].toUpperCase() + name.slice(1);
- return [
- field.name,
- {
- description: field.description,
- $ref: `#/components/schemas/${pascalName}`,
- },
- ];
- }
+ const name = field.source || 'unknown';
+ const pascalName = name[0].toUpperCase() + name.slice(1);
+ return [
+ field.name,
+ {
+ description: field.description,
+ $ref: `#/components/schemas/${pascalName}`,
+ },
+ ];
}
if (field.kind === SchemaFieldKind.OPTIONS) {
- if (field.source === undefined || field.source === 'self') {
- return [
- field.name,
- {
- type: 'array',
- description: field.description,
- minItems: field.minValues,
- maxItems: field.maxValues,
- items: {
- type: 'string',
- enum: field.options?.map(it => it.value),
- },
- },
- ];
- } else {
- const name = field.source;
- const pascalName = name[0].toUpperCase() + name.slice(1);
- return [
- field.name,
- {
- type: 'array',
- description: field.description,
- minItems: field.minValues,
- maxItems: field.maxValues,
- items: {
- $ref: `#/components/schemas/${pascalName}`,
- },
+ const name = field.source;
+ const pascalName = name[0].toUpperCase() + name.slice(1);
+ return [
+ field.name,
+ {
+ type: 'array',
+ description: field.description,
+ minItems: field.minValues,
+ maxItems: field.maxValues,
+ items: {
+ $ref: `#/components/schemas/${pascalName}`,
},
- ];
- }
+ },
+ ];
}
if (field.kind === SchemaFieldKind.LINK) {
return [
diff --git a/functions/src/services/translate.service.ts b/functions/src/services/translate.service.ts
index 3b55068c..c0cb4773 100644
--- a/functions/src/services/translate.service.ts
+++ b/functions/src/services/translate.service.ts
@@ -53,41 +53,48 @@ export async function translateCloud(content: string, sourceLocale: string | nul
}
} else {
// Google Translate
- if (sourceLocale && !GCP_SUPPORT_LOCALES.has(sourceLocale)) {
- throw new HttpsError('invalid-argument', `Unsupported source locale : '${sourceLocale}'`);
- }
- if (!GCP_SUPPORT_LOCALES.has(targetLocale)) {
- throw new HttpsError('invalid-argument', `Unsupported target locale : '${targetLocale}'`);
- }
+ return await translateWithGoogle(content, sourceLocale, targetLocale);
+ }
+}
- const projectId = firebaseConfig.projectId;
- let locationId; // firebaseConfig.locationId || 'global'
- if (firebaseConfig.locationId && firebaseConfig.locationId.startsWith('us-')) {
- locationId = 'us-central1';
- } else {
- locationId = 'global';
- }
+/**
+ * Translate content with Google Translate
+ * @param {string} content
+ * @param {string | null} sourceLocale
+ * @param {string} targetLocale
+ */
+export async function translateWithGoogle(content: string, sourceLocale: string | undefined | null, targetLocale: string): Promise {
+ if (sourceLocale && !GCP_SUPPORT_LOCALES.has(sourceLocale)) {
+ throw new HttpsError('invalid-argument', `Unsupported source locale : '${sourceLocale}'`);
+ }
+ if (!GCP_SUPPORT_LOCALES.has(targetLocale)) {
+ throw new HttpsError('invalid-argument', `Unsupported target locale : '${targetLocale}'`);
+ }
- const tRequest: protos.google.cloud.translation.v3.ITranslateTextRequest = {
- parent: `projects/${projectId}/locations/${locationId}`,
- contents: [content],
- mimeType: 'text/plain',
- sourceLanguageCode: sourceLocale,
- targetLanguageCode: targetLocale,
- };
- try {
- const [responseTranslateText] = await translationService.translateText(tRequest);
- if (responseTranslateText.translations && responseTranslateText.translations.length > 0) {
- return responseTranslateText.translations[0].translatedText || '';
- } else {
- return '';
- }
- } catch (e) {
- logger.error(e);
- throw new HttpsError(
- 'failed-precondition',
- `Cloud Translation API has not been used in project ${projectId} before or it is disabled`
- );
+ const projectId = firebaseConfig.projectId;
+ let locationId; // firebaseConfig.locationId || 'global'
+ if (firebaseConfig.locationId && firebaseConfig.locationId.startsWith('us-')) {
+ locationId = 'us-central1';
+ } else {
+ locationId = 'global';
+ }
+
+ const tRequest: protos.google.cloud.translation.v3.ITranslateTextRequest = {
+ parent: `projects/${projectId}/locations/${locationId}`,
+ contents: [content],
+ mimeType: 'text/plain',
+ sourceLanguageCode: sourceLocale,
+ targetLanguageCode: targetLocale,
+ };
+ try {
+ const [responseTranslateText] = await translationService.translateText(tRequest);
+ if (responseTranslateText.translations && responseTranslateText.translations.length > 0) {
+ return responseTranslateText.translations[0].translatedText || '';
+ } else {
+ return '';
}
+ } catch (e) {
+ logger.error(e);
+ throw new HttpsError('failed-precondition', `Cloud Translation API has not been used in project ${projectId} before or it is disabled`);
}
}
diff --git a/functions/src/services/translation.service.ts b/functions/src/services/translation.service.ts
index 9cec3e85..b63cb07b 100644
--- a/functions/src/services/translation.service.ts
+++ b/functions/src/services/translation.service.ts
@@ -24,3 +24,37 @@ export function findTranslations(spaceId: string, fromDate?: number): Query {
}
return translationsRef;
}
+
+/**
+ * delete Translations
+ * @param {string} spaceId
+ * @return {void}
+ */
+export function deleteTranslations(spaceId: string): Promise {
+ const translationsRef = firestoreService.collection(`spaces/${spaceId}/translations`);
+ return firestoreService.recursiveDelete(translationsRef);
+}
+
+/**
+ * construct translation locale cache path, will return url to the generated translation JSON file
+ * @param {string} spaceId
+ * @param {string} locale
+ * @param {string} version
+ * @return {string} path
+ */
+export function translationLocaleCachePath(spaceId: string, locale: string, version: string | 'draft' | undefined): string {
+ if (version === 'draft') {
+ return `spaces/${spaceId}/translations/draft/${locale}.json`;
+ } else {
+ return `spaces/${spaceId}/translations/${locale}.json`;
+ }
+}
+
+/**
+ * construct translation cache path, will return url to the cache file for cache version identifier
+ * @param {string} spaceId
+ * @return {string} path
+ */
+export function spaceTranslationCachePath(spaceId: string): string {
+ return `spaces/${spaceId}/translations/cache.json`;
+}
diff --git a/functions/src/services/webhook.service.ts b/functions/src/services/webhook.service.ts
new file mode 100644
index 00000000..89deec25
--- /dev/null
+++ b/functions/src/services/webhook.service.ts
@@ -0,0 +1,47 @@
+import { DocumentReference, Query } from 'firebase-admin/firestore';
+import { logger } from 'firebase-functions/v2';
+import { firestoreService } from '../config';
+import { WebHookEvent } from '../models';
+
+/**
+ * find WebHook by ID
+ * @param {string} spaceId Space identifier
+ * @param {string} id WebHook identifier
+ * @return {DocumentReference} document reference to the webhook
+ */
+export function findWebHookById(spaceId: string, id: string): DocumentReference {
+ logger.info(`[findWebHookById] spaceId=${spaceId} id=${id}`);
+ return firestoreService.doc(`spaces/${spaceId}/webhooks/${id}`);
+}
+
+/**
+ * find all WebHooks by Space ID
+ * @param {string} spaceId Space identifier
+ * @return {Query} collection query
+ */
+export function findAllWebHooks(spaceId: string): Query {
+ logger.info(`[findAllWebHooks] spaceId=${spaceId}`);
+ return firestoreService.collection(`spaces/${spaceId}/webhooks`);
+}
+
+/**
+ * find enabled WebHooks by Space ID and Event
+ * @param {string} spaceId Space identifier
+ * @param {WebHookEvent} event Event type
+ * @return {Query} collection query
+ */
+export function findEnabledWebHooksByEvent(spaceId: string, event: WebHookEvent): Query {
+ logger.info(`[findEnabledWebHooksByEvent] spaceId=${spaceId} event=${event}`);
+ return firestoreService.collection(`spaces/${spaceId}/webhooks`).where('enabled', '==', true).where('events', 'array-contains', event);
+}
+
+/**
+ * find WebHook logs
+ * @param {string} spaceId Space identifier
+ * @param {string} webhookId WebHook identifier
+ * @return {Query} collection query
+ */
+export function findWebHookLogs(spaceId: string, webhookId: string): Query {
+ logger.info(`[findWebHookLogs] spaceId=${spaceId} webhookId=${webhookId}`);
+ return firestoreService.collection(`spaces/${spaceId}/webhooks/${webhookId}/logs`).orderBy('createdAt', 'desc').limit(100);
+}
diff --git a/functions/src/setup.ts b/functions/src/setup.ts
index a815e54b..ef1cfa6c 100644
--- a/functions/src/setup.ts
+++ b/functions/src/setup.ts
@@ -1,7 +1,7 @@
import { FieldValue } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions/v2';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
-import { authService, firestoreService } from './config';
+import { authService, bucket, firestoreService } from './config';
import { DEFAULT_LOCALE } from './models';
import { createSpace } from './services';
@@ -18,36 +18,55 @@ export const setup = onCall(async request => {
const setupRef = firestoreService.doc('configs/setup');
const setupSnapshot = await setupRef.get();
if (setupSnapshot.exists) {
- logger.info('[setup] The configuration already exists.');
+ logger.info('[setup] ✅ The configuration already exists.');
throw new HttpsError('already-exists', 'The configuration already exists.');
- } else {
- const adminUser = await authService.createUser({
- displayName: request.data.displayName || 'Admin',
- email: request.data.email,
- password: request.data.password,
- emailVerified: true,
- disabled: false,
- });
- await authService.setCustomUserClaims(adminUser.uid, { role: 'admin' });
-
- await setupRef.set(
+ }
+ // Create first admin user
+ logger.info('[setup] 🔧 Creating the first admin user...');
+ const adminUser = await authService.createUser({
+ displayName: request.data.displayName || 'Admin',
+ email: request.data.email,
+ password: request.data.password,
+ emailVerified: true,
+ disabled: false,
+ });
+ await authService.setCustomUserClaims(adminUser.uid, { role: 'admin' });
+ logger.info(`[setup] ✅ First admin user created with UID: ${adminUser.uid}`);
+ // Setup
+ // Bucket CORS
+ logger.info('[setup] Check Bucket CORS:');
+ if (bucket.metadata.cors === undefined) {
+ logger.info('[setup] Check Bucket CORS: 🔧 Setting configuration');
+ await bucket.setCorsConfiguration([
{
- createdAt: FieldValue.serverTimestamp(),
+ origin: ['*'],
+ method: ['GET', 'HEAD'],
+ maxAgeSeconds: 3600,
},
- { merge: true }
- );
-
- // TODO update user role in firestore
+ ]);
+ logger.info('[setup] Check Bucket CORS: 💾 Configuration Saved');
+ } else {
+ logger.info('[setup] Check Bucket CORS: ✅ Configuration already exists');
+ }
+ logger.info('[setup] Check Bucket CORS: ✅ Done');
- // Create first Space
- await createSpace({
- name: 'Hello World',
- locales: [DEFAULT_LOCALE],
- localeFallback: DEFAULT_LOCALE,
+ await setupRef.set(
+ {
createdAt: FieldValue.serverTimestamp(),
- updatedAt: FieldValue.serverTimestamp(),
- });
- // Create token for the first user
- return await authService.createCustomToken(adminUser.uid);
- }
+ },
+ { merge: true }
+ );
+
+ // TODO update user role in firestore
+ // Create first Space
+ await createSpace({
+ name: 'Hello World',
+ locales: [DEFAULT_LOCALE],
+ localeFallback: DEFAULT_LOCALE,
+ createdAt: FieldValue.serverTimestamp(),
+ updatedAt: FieldValue.serverTimestamp(),
+ });
+ // Create token for the first user
+ // TODO: Not working as it require a special role
+ // return await authService.createCustomToken(adminUser.uid);
});
diff --git a/functions/src/tasks.ts b/functions/src/tasks.ts
index ac0132d7..381f92b5 100644
--- a/functions/src/tasks.ts
+++ b/functions/src/tasks.ts
@@ -12,15 +12,30 @@ import {
ContentExport,
ContentFolder,
ContentKind,
+ isTaskAssetExport,
+ isTaskAssetImport,
+ isTaskAssetRegenMetadata,
+ isTaskContentExport,
+ isTaskContentImport,
+ isTaskSchemaExport,
+ isTaskSchemaImport,
+ isTaskTranslationExport,
+ isTaskTranslationImport,
Schema,
SchemaComponent,
SchemaEnum,
SchemaExport,
SchemaType,
Task,
+ TaskAssetExport,
+ TaskContentExport,
TaskExportMetadata,
+ TaskImport,
TaskKind,
+ TaskSchemaExport,
TaskStatus,
+ TaskTranslationExport,
+ TaskTranslationImport,
Translation,
TranslationExport,
TranslationType,
@@ -76,12 +91,15 @@ const onTaskCreate = onDocumentCreated(
status: TaskStatus.IN_PROGRESS,
updatedAt: FieldValue.serverTimestamp(),
};
- if (task.kind.endsWith('_IMPORT')) {
- if (task.tmpPath) {
- const newPath = `spaces/${spaceId}/tasks/${taskId}/original`;
- await bucket.file(task.tmpPath).move(newPath);
- updateToInProgress.tmpPath = FieldValue.delete();
- }
+ if (
+ task.kind === TaskKind.ASSET_IMPORT ||
+ task.kind === TaskKind.CONTENT_IMPORT ||
+ task.kind === TaskKind.SCHEMA_IMPORT ||
+ task.kind === TaskKind.TRANSLATION_IMPORT
+ ) {
+ const newPath = `spaces/${spaceId}/tasks/${taskId}/original`;
+ await bucket.file(task.tmpPath).move(newPath);
+ (updateToInProgress as UpdateData).tmpPath = FieldValue.delete();
}
// Update to IN_PROGRESS
logger.info(`[Task:onCreate] update='${JSON.stringify(updateToInProgress)}'`);
@@ -92,14 +110,15 @@ const onTaskCreate = onDocumentCreated(
updatedAt: FieldValue.serverTimestamp(),
};
- if (task.kind === TaskKind.ASSET_EXPORT) {
+ if (isTaskAssetExport(task)) {
const metadata = await assetsExport(spaceId, taskId, task);
logger.info(`[Task:onCreate] metadata='${JSON.stringify(metadata)}'`);
- updateToFinished['file'] = {
+
+ (updateToFinished as UpdateData).file = {
name: `asset-export-${taskId}.lla.zip`,
size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size),
};
- } else if (task.kind === TaskKind.ASSET_IMPORT) {
+ } else if (isTaskAssetImport(task)) {
const errors = await assetsImport(spaceId, taskId);
if (errors) {
updateToFinished.status = TaskStatus.ERROR;
@@ -110,15 +129,15 @@ const onTaskCreate = onDocumentCreated(
updateToFinished.trace = JSON.stringify(errors.format());
}
}
- } else if (task.kind === TaskKind.ASSET_REGEN_METADATA) {
+ } else if (isTaskAssetRegenMetadata(task)) {
await assetRegenerateMetadata(spaceId);
- } else if (task.kind === TaskKind.CONTENT_EXPORT) {
+ } else if (isTaskContentExport(task)) {
const metadata = await contentsExport(spaceId, taskId, task);
- updateToFinished['file'] = {
+ (updateToFinished as UpdateData).file = {
name: `content-export-${taskId}.llc.zip`,
size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size),
};
- } else if (task.kind === TaskKind.CONTENT_IMPORT) {
+ } else if (isTaskContentImport(task)) {
const errors = await contentsImport(spaceId, taskId);
if (errors) {
updateToFinished.status = TaskStatus.ERROR;
@@ -129,13 +148,13 @@ const onTaskCreate = onDocumentCreated(
updateToFinished.trace = JSON.stringify(errors.format());
}
}
- } else if (task.kind === TaskKind.SCHEMA_EXPORT) {
+ } else if (isTaskSchemaExport(task)) {
const metadata = await schemasExport(spaceId, taskId, task);
- updateToFinished['file'] = {
+ (updateToFinished as UpdateData).file = {
name: `schema-export-${taskId}.lls.zip`,
size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size),
};
- } else if (task.kind === TaskKind.SCHEMA_IMPORT) {
+ } else if (isTaskSchemaImport(task)) {
const errors = await schemasImport(spaceId, taskId);
if (errors) {
updateToFinished.status = TaskStatus.ERROR;
@@ -146,21 +165,21 @@ const onTaskCreate = onDocumentCreated(
updateToFinished.trace = JSON.stringify(errors.format());
}
}
- } else if (task.kind === TaskKind.TRANSLATION_EXPORT) {
+ } else if (isTaskTranslationExport(task)) {
if (task.locale) {
const metadata = await translationsExportJsonFlat(spaceId, taskId, task);
- updateToFinished['file'] = {
+ (updateToFinished as UpdateData).file = {
name: `translation-${task.locale}-export-${taskId}.json`,
size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size),
};
} else {
const metadata = await translationsExport(spaceId, taskId, task);
- updateToFinished['file'] = {
+ (updateToFinished as UpdateData).file = {
name: `translation-export-${taskId}.llt.zip`,
size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size),
};
}
- } else if (task.kind === TaskKind.TRANSLATION_IMPORT) {
+ } else if (isTaskTranslationImport(task)) {
let errors: any;
if (task.locale) {
errors = await translationsImportJsonFlat(spaceId, taskId, task);
@@ -189,7 +208,7 @@ const onTaskCreate = onDocumentCreated(
* @param {string} taskId original task
* @param {Task} task original task
*/
-async function assetsExport(spaceId: string, taskId: string, task: Task): Promise {
+async function assetsExport(spaceId: string, taskId: string, task: TaskAssetExport): Promise {
const exportAssets: (AssetExport | undefined)[] = [];
if (task.path) {
// Only specific folder or asset
@@ -387,7 +406,7 @@ async function assetRegenerateMetadata(spaceId: string): Promise {
* @param {string} taskId original task
* @param {Task} task original task
*/
-async function contentsExport(spaceId: string, taskId: string, task: Task): Promise {
+async function contentsExport(spaceId: string, taskId: string, task: TaskContentExport): Promise {
const exportContents: (ContentExport | undefined)[] = [];
if (task.path) {
// Only specific folder or document
@@ -567,7 +586,7 @@ async function contentsImport(spaceId: string, taskId: string): Promise {
+async function schemasExport(spaceId: string, taskId: string, task: TaskSchemaExport): Promise {
const exportSchemas: SchemaExport[] = [];
const schemasSnapshot = await findSchemas(spaceId, task.fromDate).get();
schemasSnapshot.docs
@@ -716,7 +735,7 @@ async function schemasImport(spaceId: string, taskId: string): Promise {
+async function translationsExport(spaceId: string, taskId: string, task: TaskTranslationExport): Promise {
const exportTranslations: TranslationExport[] = [];
const translationsSnapshot = await findTranslations(spaceId, task.fromDate).get();
translationsSnapshot.docs
@@ -763,7 +782,7 @@ async function translationsExport(spaceId: string, taskId: string, task: Task):
* @param {string} taskId original task
* @param {Task} task original task
*/
-async function translationsExportJsonFlat(spaceId: string, taskId: string, task: Task): Promise {
+async function translationsExportJsonFlat(spaceId: string, taskId: string, task: TaskTranslationExport): Promise {
const exportTranslations: Record = {};
const translationsSnapshot = await findTranslations(spaceId, task.fromDate).get();
translationsSnapshot.docs
@@ -852,7 +871,11 @@ async function translationsImport(spaceId: string, taskId: string): Promise {
+async function translationsImportJsonFlat(
+ spaceId: string,
+ taskId: string,
+ task: TaskTranslationImport
+): Promise {
const tmpTaskFolder = TMP_TASK_FOLDER + taskId;
mkdirSync(tmpTaskFolder);
const jsonPath = `${tmpTaskFolder}/task.json`;
diff --git a/functions/src/translate.ts b/functions/src/translate.ts
index 1967b557..56e0ff37 100644
--- a/functions/src/translate.ts
+++ b/functions/src/translate.ts
@@ -1,6 +1,6 @@
import { logger } from 'firebase-functions/v2';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
-import { canPerform } from './utils/security-utils';
+import { canPerform } from './utils/user-auth-utils';
import { TranslateData, UserPermission } from './models';
import { translateCloud } from './services/translate.service';
import { isEmulatorEnabled } from './config';
diff --git a/functions/src/translations.ts b/functions/src/translations.ts
index 6df87be6..a0eabdfd 100644
--- a/functions/src/translations.ts
+++ b/functions/src/translations.ts
@@ -1,12 +1,20 @@
-import { FieldValue, WithFieldValue } from 'firebase-admin/firestore';
+import { FieldValue, UpdateData, WithFieldValue } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions/v2';
import { onDocumentCreated, onDocumentWritten } from 'firebase-functions/v2/firestore';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { bucket, firestoreService } from './config';
-import { PublishTranslationsData, Space, Translation, TranslationHistory, TranslationHistoryType, UserPermission } from './models';
-import { findSpaceById, findTranslations, findTranslationsHistory } from './services';
-import { translateCloud } from './services/translate.service';
-import { canPerform } from './utils/security-utils';
+import {
+ PublishTranslationsData,
+ Space,
+ TranslateLocaleData,
+ Translation,
+ TranslationHistory,
+ TranslationHistoryType,
+ UserPermission,
+} from './models';
+import { deleteTranslations, findSpaceById, findTranslations, findTranslationsHistory, spaceTranslationCachePath } from './services';
+import { translateCloud, translateWithGoogle } from './services/translate.service';
+import { canPerform } from './utils/user-auth-utils';
// Publish
const publish = onCall(async request => {
@@ -38,17 +46,12 @@ const publish = onCall(async request => {
progress[locale.id] = counter;
// Save generated JSON
logger.info(`[translationsPublish] Save file to spaces/${spaceId}/translations/${locale.id}.json`);
- bucket.file(`spaces/${spaceId}/translations/${locale.id}.json`).save(JSON.stringify(localeStorage), (err?: Error | null) => {
- if (err) {
- logger.error(`[translationsPublish] Can not save file for Space(${spaceId}) and Locale(${locale})`);
- logger.error(err);
- }
- });
+ await bucket.file(`spaces/${spaceId}/translations/${locale.id}.json`).save(JSON.stringify(localeStorage));
}
await spaceSnapshot.ref.update('progress.translations', progress);
// Save Cache
- logger.info(`[translationsPublish] Save file to spaces/${spaceId}/translations/cache.json`);
- await bucket.file(`spaces/${spaceId}/translations/cache.json`).save('');
+ logger.info(`[translationsPublish] Save file to ${spaceTranslationCachePath(spaceId)}`);
+ await bucket.file(spaceTranslationCachePath(spaceId)).save('');
const addHistory: WithFieldValue = {
type: TranslationHistoryType.PUBLISHED,
name: auth?.token['name'] || FieldValue.delete(),
@@ -63,6 +66,15 @@ const publish = onCall(async request => {
}
});
+const deleteAll = onCall<{ spaceId: string }>(async request => {
+ logger.info('[translationsDeleteAll] data: ' + JSON.stringify(request.data));
+ logger.info('[translationsDeleteAll] context.auth: ' + JSON.stringify(request.auth));
+ const { auth, data } = request;
+ if (!canPerform(UserPermission.SPACE_MANAGEMENT, auth)) throw new HttpsError('permission-denied', 'permission-denied');
+ const { spaceId } = data;
+ await deleteTranslations(spaceId);
+});
+
const onCreate = onDocumentCreated('spaces/{spaceId}/translations/{translationId}', async event => {
logger.info(`[Translation:onCreate] eventId='${event.id}'`);
logger.info(`[Translation:onCreate] params='${JSON.stringify(event.params)}'`);
@@ -74,7 +86,7 @@ const onCreate = onDocumentCreated('spaces/{spaceId}/translations/{translationId
logger.info(`[Translation:onCreate] data='${JSON.stringify(translation)}'`);
// No Auto Translate field define
if (translation.autoTranslate === undefined) return;
- const update: any = {
+ const update: UpdateData = {
autoTranslate: FieldValue.delete(),
updatedAt: FieldValue.serverTimestamp(),
};
@@ -90,9 +102,8 @@ const onCreate = onDocumentCreated('spaces/{spaceId}/translations/{translationId
if (locale.id === space.localeFallback.id) continue;
try {
const tValue = await translateCloud(localeValue, space.localeFallback.id, locale.id);
- const field = 'locales.' + locale.id;
if (tValue) {
- update[field] = tValue;
+ update[`locales.${locale.id}`] = tValue;
}
} catch (e) {
logger.error(e);
@@ -105,6 +116,101 @@ const onCreate = onDocumentCreated('spaces/{spaceId}/translations/{translationId
return;
});
+const onWriteToDraft = onDocumentWritten('spaces/{spaceId}/translations/{translationId}', async event => {
+ logger.info(`[Translation:onWriteToDraft] eventId='${event.id}'`);
+ logger.info(`[Translation:onWriteToDraft] params='${JSON.stringify(event.params)}'`);
+ const { spaceId, translationId } = event.params;
+
+ // No Data
+ if (!event.data) return;
+
+ const { before, after } = event.data;
+ const beforeData = before.data() as Translation | undefined;
+ const afterData = after.data() as Translation | undefined;
+
+ // Skip if both before and after are undefined (shouldn't happen)
+ if (!beforeData && !afterData) {
+ logger.warn(`[Translation:onWriteToDraft] eventId='${event.id}' Both before and after data are undefined`);
+ return;
+ }
+
+ // Check if locales have changed (skip regeneration if only metadata changed)
+ if (beforeData && afterData) {
+ const localesChanged = JSON.stringify(beforeData.locales) !== JSON.stringify(afterData.locales);
+ if (!localesChanged) {
+ logger.info(`[Translation:onWriteToDraft] translationId='${translationId}' Locales unchanged, skipping draft generation`);
+ return;
+ }
+ }
+
+ // For create or update, generate draft files
+ if (afterData) {
+ logger.info(`[Translation:onWriteToDraft] eventId='${event.id}' translationId='${translationId}' Generating draft files`);
+
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ const translationsSnapshot = await findTranslations(spaceId).get();
+
+ if (spaceSnapshot.exists && !translationsSnapshot.empty) {
+ const space: Space = spaceSnapshot.data() as Space;
+
+ for (const locale of space.locales) {
+ const localeStorage: Record = {};
+ for (const translation of translationsSnapshot.docs) {
+ const tr = translation.data() as Translation;
+ let value = tr.locales[locale.id];
+ if (value) {
+ // check the value is not empty string
+ value = value || tr.locales[space.localeFallback.id];
+ } else {
+ value = tr.locales[space.localeFallback.id];
+ }
+ localeStorage[translation.id] = value;
+ }
+ // Save generated JSON to draft
+ logger.info(`[Translation:onWriteToDraft] Save file to spaces/${spaceId}/translations/draft/${locale.id}.json`);
+ await bucket.file(`spaces/${spaceId}/translations/draft/${locale.id}.json`).save(JSON.stringify(localeStorage));
+ }
+ }
+ } else if (beforeData) {
+ // For delete, regenerate draft files without the deleted translation
+ logger.info(
+ `[Translation:onWriteToDraft] eventId='${event.id}' translationId='${translationId}' Regenerating draft files after delete`
+ );
+
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ const translationsSnapshot = await findTranslations(spaceId).get();
+
+ if (spaceSnapshot.exists) {
+ const space: Space = spaceSnapshot.data() as Space;
+
+ for (const locale of space.locales) {
+ const localeStorage: Record = {};
+
+ // Only include remaining translations (deleted one is already removed from collection)
+ if (!translationsSnapshot.empty) {
+ for (const translation of translationsSnapshot.docs) {
+ const tr = translation.data() as Translation;
+ let value = tr.locales[locale.id];
+ if (value) {
+ value = value || tr.locales[space.localeFallback.id];
+ } else {
+ value = tr.locales[space.localeFallback.id];
+ }
+ localeStorage[translation.id] = value;
+ }
+ }
+
+ // Save generated JSON to draft (will be empty object if no translations left)
+ logger.info(`[Translation:onWriteToDraft] Save file to spaces/${spaceId}/translations/draft/${locale.id}.json`);
+ await bucket.file(`spaces/${spaceId}/translations/draft/${locale.id}.json`).save(JSON.stringify(localeStorage));
+ }
+ }
+ }
+ logger.info(`[Translation:onWriteToDraft] Save file to ${spaceTranslationCachePath(spaceId)}`);
+ await bucket.file(spaceTranslationCachePath(spaceId)).save('');
+ return;
+});
+
const onWriteToHistory = onDocumentWritten('spaces/{spaceId}/translations/{translationId}', async event => {
logger.info(`[Translation:onWrite] eventId='${event.id}'`);
logger.info(`[Translation:onWrite] params='${JSON.stringify(event.params)}'`);
@@ -167,8 +273,60 @@ const onWriteToHistory = onDocumentWritten('spaces/{spaceId}/translations/{trans
return;
});
+const translateLocale = onCall(async request => {
+ logger.info('[translationsTranslateLocale] data: ' + JSON.stringify(request.data));
+ logger.info('[translationsTranslateLocale] context.auth: ' + JSON.stringify(request.auth));
+ const { auth, data } = request;
+ if (!canPerform(UserPermission.TRANSLATION_UPDATE, auth)) throw new HttpsError('permission-denied', 'permission-denied');
+ const { spaceId, sourceLocaleId, targetLocaleId } = data;
+
+ const translationsSnapshot = await findTranslations(spaceId).get();
+ if (translationsSnapshot.empty) {
+ logger.info(`[translationsTranslateLocale] Space ${spaceId} has no translations.`);
+ return;
+ }
+
+ const bulk = firestoreService.bulkWriter();
+ const candidates = translationsSnapshot.docs.filter(doc => {
+ const translation = doc.data() as Translation;
+ const sourceValue = translation.locales[sourceLocaleId];
+ const targetValue = translation.locales[targetLocaleId];
+ return sourceValue && !targetValue;
+ });
+
+ const results = await Promise.all(
+ candidates.map(async doc => {
+ const sourceValue = (doc.data() as Translation).locales[sourceLocaleId];
+ try {
+ const translatedValue = await translateWithGoogle(sourceValue, sourceLocaleId, targetLocaleId);
+ return { doc, translatedValue };
+ } catch (e) {
+ logger.error(`[translationsTranslateLocale] Failed to translate '${doc.id}': ${e}`);
+ return { doc, translatedValue: null };
+ }
+ })
+ );
+
+ let counter = 0;
+ for (const { doc, translatedValue } of results) {
+ if (translatedValue) {
+ const update: UpdateData = {
+ [`locales.${targetLocaleId}`]: translatedValue,
+ updatedAt: FieldValue.serverTimestamp(),
+ };
+ bulk.update(doc.ref, update);
+ counter++;
+ }
+ }
+ await bulk.close();
+ logger.info(`[translationsTranslateLocale] Bulk successfully updated ${counter}.`);
+});
+
export const translation = {
publish: publish,
oncreate: onCreate,
+ onwritetodraft: onWriteToDraft,
onwritetohistory: onWriteToHistory,
+ deleteall: deleteAll,
+ translatelocale: translateLocale,
};
diff --git a/functions/src/users.ts b/functions/src/users.ts
index e989818a..2bc504c0 100644
--- a/functions/src/users.ts
+++ b/functions/src/users.ts
@@ -2,7 +2,7 @@ import { logger } from 'firebase-functions';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { FieldValue, Timestamp } from 'firebase-admin/firestore';
import { authService } from './config';
-import { canPerform } from './utils/security-utils';
+import { canPerform } from './utils/user-auth-utils';
import { User, UserInvite, UserPermission } from './models';
import { beforeUserCreated, beforeUserSignedIn } from 'firebase-functions/v2/identity';
import { findUserById } from './services';
diff --git a/functions/src/utils/api-auth-utils.ts b/functions/src/utils/api-auth-utils.ts
new file mode 100644
index 00000000..080850f0
--- /dev/null
+++ b/functions/src/utils/api-auth-utils.ts
@@ -0,0 +1,32 @@
+import { isTokenV1, isTokenV2, Token, TokenPermission } from '../models';
+
+/**
+ * Check if Token contains required permission
+ * @param {TokenPermission} permission - a user permission
+ * @param {Token} token request Token
+ * @return {boolean} true in case role is present
+ */
+export function canPerform(permission: TokenPermission, token: Token): boolean {
+ if (isTokenV1(token)) {
+ return (
+ permission === TokenPermission.TRANSLATION_PUBLIC ||
+ permission === TokenPermission.TRANSLATION_DRAFT ||
+ permission === TokenPermission.CONTENT_PUBLIC ||
+ permission === TokenPermission.CONTENT_DRAFT
+ );
+ }
+ if (isTokenV2(token)) {
+ return token.permissions.includes(permission);
+ }
+ return false;
+}
+
+/**
+ * Check if Token contains any of the required permissions
+ * @param {TokenPermission[]} permissions
+ * @param {Token} token request Token
+ * @return {boolean} true in case any role is present
+ */
+export function canPerformAny(permissions: TokenPermission[], token: Token): boolean {
+ return permissions.some(permission => canPerform(permission, token));
+}
diff --git a/functions/src/utils/translate.utils.ts b/functions/src/utils/translate.utils.ts
new file mode 100644
index 00000000..ad149c80
--- /dev/null
+++ b/functions/src/utils/translate.utils.ts
@@ -0,0 +1,16 @@
+import { SchemaFieldKind } from '../models';
+
+export function isSchemaFieldKindAITranslatable(kind: SchemaFieldKind): boolean {
+ switch (kind) {
+ case SchemaFieldKind.TEXT:
+ case SchemaFieldKind.TEXTAREA:
+ case SchemaFieldKind.MARKDOWN:
+ case SchemaFieldKind.SCHEMA:
+ case SchemaFieldKind.SCHEMAS:
+ return true;
+ case SchemaFieldKind.RICH_TEXT:
+ return false; // nested JSON not supported at the moment
+ default:
+ return false;
+ }
+}
diff --git a/functions/src/utils/security-utils.ts b/functions/src/utils/user-auth-utils.ts
similarity index 79%
rename from functions/src/utils/security-utils.ts
rename to functions/src/utils/user-auth-utils.ts
index 2bbe8e79..8193a63d 100644
--- a/functions/src/utils/security-utils.ts
+++ b/functions/src/utils/user-auth-utils.ts
@@ -1,7 +1,16 @@
-import { AuthData } from 'firebase-functions/lib/common/providers/https';
+import { DecodedIdToken } from 'firebase-admin/auth';
import { ROLE_ADMIN, ROLE_CUSTOM } from '../config';
import { UserPermission } from '../models';
+export interface AuthData {
+ /** The user's uid from the request's ID token. */
+ uid: string;
+ /** The decoded claims of the ID token after verification. */
+ token: DecodedIdToken;
+ /** The raw ID token as parsed from the header. */
+ rawToken: string;
+}
+
/**
* Check roles of authenticated user
* @param {string} role roles to check
diff --git a/functions/src/utils/webhook-utils.ts b/functions/src/utils/webhook-utils.ts
new file mode 100644
index 00000000..5a839484
--- /dev/null
+++ b/functions/src/utils/webhook-utils.ts
@@ -0,0 +1,130 @@
+import { createHmac } from 'crypto';
+import { logger } from 'firebase-functions/v2';
+import { WebHook, WebHookPayload, WebHookLog, WebHookStatus } from '../models';
+import { FieldValue, WithFieldValue } from 'firebase-admin/firestore';
+import { findEnabledWebHooksByEvent } from '../services';
+import { firestoreService } from '../config';
+
+/**
+ * Generate HMAC signature for webhook payload
+ * @param {string} secret Secret key
+ * @param {string} payload JSON payload
+ * @return {string} HMAC signature
+ */
+export function generateSignature(secret: string, payload: string): string {
+ return 'sha256=' + createHmac('sha256', secret).update(payload).digest('hex');
+}
+
+/**
+ * Trigger webhook with retry logic
+ * @param {string} spaceId Space identifier
+ * @param {string} webhookId WebHook identifier
+ * @param {WebHook} webhook WebHook configuration
+ * @param {WebHookPayload} payload Payload to send
+ */
+export async function triggerWebHook(spaceId: string, webhookId: string, webhook: WebHook, payload: WebHookPayload): Promise {
+ const startTime = Date.now();
+ const payloadJson = JSON.stringify(payload);
+
+ // Add signature if secret is configured
+ if (webhook.secret) {
+ payload.signature = generateSignature(webhook.secret, payloadJson);
+ }
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'Localess-WebHook/1.0',
+ 'X-Webhook-Event': payload.event,
+ ...(webhook.headers || {}),
+ };
+
+ if (payload.signature) {
+ headers['X-Webhook-Signature'] = payload.signature;
+ }
+
+ try {
+ logger.info(`[triggerWebHook] Sending webhook to ${webhook.url}`);
+
+ const response = await fetch(webhook.url, {
+ method: 'POST',
+ headers,
+ body: payloadJson,
+ signal: AbortSignal.timeout(30000), // 30 second timeout
+ });
+
+ const responseTime = Date.now() - startTime;
+ const status: WebHookStatus = response.ok ? 'success' : 'failure';
+
+ // Log the webhook execution
+ await logWebHookExecution(spaceId, webhookId, {
+ webhookId: webhookId,
+ event: payload.event,
+ url: webhook.url,
+ status: status,
+ statusCode: response.status,
+ responseTime: responseTime,
+ createdAt: FieldValue.serverTimestamp(),
+ });
+
+ if (response.ok) {
+ logger.info(`[triggerWebHook] Webhook succeeded (${responseTime}ms)`);
+ } else {
+ logger.error(`[triggerWebHook] Webhook failed: HTTP ${response.status}: ${response.statusText}`);
+ }
+ } catch (error: any) {
+ const responseTime = Date.now() - startTime;
+ logger.error(`[triggerWebHook] Webhook error: ${error.message}`);
+
+ // Log the failed execution
+ await logWebHookExecution(spaceId, webhookId, {
+ webhookId: webhookId,
+ event: payload.event,
+ url: webhook.url,
+ status: 'failure',
+ responseTime: responseTime,
+ errorMessage: error.message,
+ createdAt: FieldValue.serverTimestamp(),
+ });
+ }
+}
+
+/**
+ * Log webhook execution
+ * @param {string} spaceId Space identifier
+ * @param {string} webhookId WebHook identifier
+ * @param {WebHookLog} log Log entry
+ */
+async function logWebHookExecution(spaceId: string, webhookId: string, log: WithFieldValue): Promise {
+ try {
+ await firestoreService.collection(`spaces/${spaceId}/webhooks/${webhookId}/logs`).add(log);
+ } catch (error: any) {
+ logger.error(`[logWebHookExecution] Failed to log webhook execution: ${error.message}`);
+ }
+}
+
+/**
+ * Trigger all webhooks for a specific event
+ * @param {string} spaceId Space identifier
+ * @param {WebHookPayload} payload Webhook payload
+ */
+export async function triggerWebHooksForEvent(spaceId: string, payload: WebHookPayload): Promise {
+ try {
+ const webhooksSnapshot = await findEnabledWebHooksByEvent(spaceId, payload.event).get();
+
+ if (webhooksSnapshot.empty) {
+ logger.info(`[triggerWebHooksForEvent] No webhooks configured for event ${payload.event}`);
+ return;
+ }
+
+ logger.info(`[triggerWebHooksForEvent] Triggering ${webhooksSnapshot.size} webhook(s) for event ${payload.event}`);
+
+ const promises = webhooksSnapshot.docs.map(doc => {
+ const webhook = doc.data() as WebHook;
+ return triggerWebHook(spaceId, doc.id, webhook, payload);
+ });
+
+ await Promise.allSettled(promises);
+ } catch (error: any) {
+ logger.error(`[triggerWebHooksForEvent] Error triggering webhooks: ${error.message}`);
+ }
+}
diff --git a/functions/src/v1.ts b/functions/src/v1.ts
index 6fe9492c..696fe255 100644
--- a/functions/src/v1.ts
+++ b/functions/src/v1.ts
@@ -1,445 +1,16 @@
import cors from 'cors';
import express from 'express';
-import { Query } from 'firebase-admin/firestore';
-import { logger } from 'firebase-functions';
-import { HttpsError, onRequest } from 'firebase-functions/v2/https';
-import os from 'os';
-import sharp from 'sharp';
-import { bucket, CACHE_ASSET_MAX_AGE, CACHE_MAX_AGE, CACHE_SHARE_MAX_AGE, firestoreService, TEN_MINUTES } from './config';
-import { AssetFile, Content, ContentKind, ContentLink, Space } from './models';
-import {
- contentCachePath,
- contentLocaleCachePath,
- extractThumbnail,
- findContentByFullSlug,
- findSpaceById,
- findTokenById,
- identifySpaceLocale,
- spaceContentCachePath,
- validateToken,
-} from './services';
+import { onRequest } from 'firebase-functions/v2/https';
+import { CDN } from './v1/cdn';
+import { MANAGE } from './v1/manage';
+import { DEV_TOOLS } from './v1/dev-tools';
// API V1
const expressApp = express();
expressApp.use(cors({ origin: true }));
-
-expressApp.get('/api/v1/spaces/:spaceId/translations/:locale', async (req, res) => {
- logger.info('v1 spaces translations params : ' + JSON.stringify(req.params));
- logger.info('v1 spaces translations query : ' + JSON.stringify(req.query));
- const { spaceId, locale } = req.params;
- const { cv, token } = req.query;
-
- if (!validateToken(token)) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const spaceSnapshot = await findSpaceById(spaceId).get();
- if (!spaceSnapshot.exists) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
-
- const tokenSnapshot = await findTokenById(spaceId, token?.toString() || '').get();
- if (!tokenSnapshot.exists) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
-
- const cachePath = `spaces/${spaceId}/translations/cache.json`;
- const [exists] = await bucket.file(cachePath).exists();
- if (exists) {
- const [metadata] = await bucket.file(cachePath).getMetadata();
- logger.info('v1 spaces translations cache meta : ' + JSON.stringify(metadata));
- if (cv === undefined || cv != metadata.generation) {
- let url = `/api/v1/spaces/${spaceId}/translations/${locale}?cv=${metadata.generation}`;
- if (token) {
- url += `&token=${token}`;
- }
- logger.info(`v1 spaces translate redirect to => ${url}`);
- res.redirect(url);
- return;
- } else {
- const space = spaceSnapshot.data() as Space;
- let actualLocale = locale;
- if (!space.locales.some(it => it.id === locale)) {
- actualLocale = space.localeFallback.id;
- }
- bucket
- .file(`spaces/${spaceId}/translations/${actualLocale}.json`)
- .download()
- .then(content => {
- res
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .contentType('application/json; charset=utf-8')
- .send(content.toString());
- })
- .catch(() => {
- res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.'));
- });
- }
- } else {
- res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.'));
- return;
- }
-});
-
-expressApp.get('/api/v1/spaces/:spaceId/links', async (req, res) => {
- logger.info('v1 spaces links params: ' + JSON.stringify(req.params));
- logger.info('v1 spaces links query: ' + JSON.stringify(req.query));
- const { spaceId } = req.params;
- const { kind, parentSlug, excludeChildren, cv, token } = req.query;
- if (!validateToken(token)) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const spaceSnapshot = await findSpaceById(spaceId).get();
- if (!spaceSnapshot.exists) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const tokenSnapshot = await findTokenById(spaceId, token?.toString() || '').get();
- if (!tokenSnapshot.exists) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const cachePath = spaceContentCachePath(spaceId);
- const [exists] = await bucket.file(cachePath).exists();
- if (exists) {
- const [metadata] = await bucket.file(cachePath).getMetadata();
- logger.info('v1 spaces links cache meta : ' + JSON.stringify(metadata));
- if (cv === undefined || cv != metadata.generation) {
- let url = `/api/v1/spaces/${spaceId}/links?cv=${metadata.generation}`;
- if (parentSlug !== undefined) {
- url += `&parentSlug=${parentSlug}`;
- }
- if (excludeChildren === 'true') {
- url += `&excludeChildren=${excludeChildren}`;
- }
- if (kind === ContentKind.DOCUMENT || kind === ContentKind.FOLDER) {
- url += `&kind=${kind}`;
- }
- if (token) {
- url += `&token=${token}`;
- }
- res.redirect(url);
- return;
- } else {
- let contentsQuery: Query = firestoreService.collection(`spaces/${spaceId}/contents`);
- if (parentSlug) {
- if (excludeChildren === 'true') {
- contentsQuery = contentsQuery.where('parentSlug', '==', parentSlug);
- } else {
- contentsQuery = contentsQuery.where('parentSlug', '>=', parentSlug).where('parentSlug', '<', `${parentSlug}/~`);
- }
- } else {
- if (excludeChildren === 'true') {
- contentsQuery = contentsQuery.where('parentSlug', '==', '');
- }
- }
- if (kind && (kind === ContentKind.DOCUMENT || kind === ContentKind.FOLDER)) {
- contentsQuery = contentsQuery.where('kind', '==', kind);
- }
- const contentsSnapshot = await contentsQuery.get();
-
- const response: Record = contentsSnapshot.docs
- .map(contentSnapshot => {
- const content = contentSnapshot.data() as Content;
- const link: ContentLink = {
- id: contentSnapshot.id,
- kind: content.kind,
- name: content.name,
- slug: content.slug,
- fullSlug: content.fullSlug,
- parentSlug: content.parentSlug,
- createdAt: content.createdAt.toDate().toISOString(),
- updatedAt: content.updatedAt.toDate().toISOString(),
- };
- if (content.kind === ContentKind.DOCUMENT) {
- link.publishedAt = content.publishedAt?.toDate().toISOString();
- }
- return link;
- })
- .reduce(
- (acc, item) => {
- acc[item.id] = item;
- return acc;
- },
- {} as Record
- );
- res
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .contentType('application/json; charset=utf-8')
- .send(response);
- return;
- }
- } else {
- res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.'));
- return;
- }
-});
-
-expressApp.get('/api/v1/spaces/:spaceId/contents/slugs/*slug', async (req, res) => {
- logger.info('v1 spaces content params: ' + JSON.stringify(req.params));
- logger.info('v1 spaces content query: ' + JSON.stringify(req.query));
- logger.info('v1 spaces content url: ' + req.url);
- const { spaceId } = req.params;
- const { cv, locale, version, token } = req.query;
- const params: Record = req.params;
- const slug = params['slug'] as string[];
- const fullSlug = slug.join('/');
- let contentId = '';
- if (!validateToken(token)) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const spaceSnapshot = await findSpaceById(spaceId).get();
- if (!spaceSnapshot.exists) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const tokenSnapshot = await findTokenById(spaceId, token?.toString() || '').get();
- if (!tokenSnapshot.exists) {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const contentsSnapshot = await findContentByFullSlug(spaceId, fullSlug).get();
- // logger.info('v1 spaces contents', contentsSnapshot.size);
- if (contentsSnapshot.empty) {
- // No records in database
- res.status(404).send(new HttpsError('not-found', 'Slug not found'));
- return;
- } else {
- contentId = contentsSnapshot.docs[0].id;
- }
- const cachePath = contentCachePath(spaceId, contentId, version as string | undefined);
- logger.info('v1 spaces content cachePath: ' + cachePath);
- const [exists] = await bucket.file(cachePath).exists();
- if (exists) {
- const [metadata] = await bucket.file(cachePath).getMetadata();
- // logger.info('v1 spaces content cache meta : ' + JSON.stringify(metadata));
- if (cv === undefined || cv != metadata.generation) {
- let url = `/api/v1/spaces/${spaceId}/contents/slugs/${fullSlug}?cv=${metadata.generation}`;
- if (locale) {
- url += `&locale=${locale}`;
- }
- if (version) {
- url += `&version=${version}`;
- }
- if (token) {
- url += `&token=${token}`;
- }
- logger.info(`v1 spaces content redirect to => ${url}`);
- res.redirect(url);
- return;
- } else {
- const space = spaceSnapshot.data() as Space;
- const actualLocale = identifySpaceLocale(space, locale as string | undefined);
- logger.info(`v1 spaces content slug locale identified as => ${actualLocale}`);
- const filePath = contentLocaleCachePath(spaceId, contentId, actualLocale, version as string | undefined);
- bucket
- .file(filePath)
- .download()
- .then(content => {
- res
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .contentType('application/json; charset=utf-8')
- .send(content.toString());
- })
- .catch(() => {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`)
- .send(new HttpsError('not-found', 'File not found, on path. Please Publish again.'));
- });
- }
- } else {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`)
- .send(new HttpsError('not-found', 'File not found, Publish first.'));
- return;
- }
-});
-
-expressApp.get('/api/v1/spaces/:spaceId/contents/:contentId', async (req, res) => {
- logger.info('v1 spaces content params: ' + JSON.stringify(req.params));
- logger.info('v1 spaces content query: ' + JSON.stringify(req.query));
- const { spaceId, contentId } = req.params;
- const { cv, locale, version, token } = req.query;
- if (!validateToken(token)) {
- logger.info('v1 spaces content Token Not Valid string: ' + token);
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const spaceSnapshot = await findSpaceById(spaceId).get();
- if (!spaceSnapshot.exists) {
- logger.info('v1 spaces content Space not exist: ' + spaceId);
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const tokenSnapshot = await findTokenById(spaceId, token?.toString() || '').get();
- if (!tokenSnapshot.exists) {
- logger.info('v1 spaces content Token not exist: ' + token);
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .send(new HttpsError('not-found', 'Not found'));
- return;
- }
- const cachePath = contentCachePath(spaceId, contentId, version as string | undefined);
- const [exists] = await bucket.file(cachePath).exists();
- if (exists) {
- const [metadata] = await bucket.file(cachePath).getMetadata();
- // logger.info('v1 spaces content cache meta : ' + JSON.stringify(metadata));
- if (cv === undefined || cv != metadata.generation) {
- let url = `/api/v1/spaces/${spaceId}/contents/${contentId}?cv=${metadata.generation}`;
- if (locale) {
- url += `&locale=${locale}`;
- }
- if (version) {
- url += `&version=${version}`;
- }
- if (token) {
- url += `&token=${token}`;
- }
- logger.info(`v1 spaces content redirect to => ${url}`);
- res.redirect(url);
- return;
- } else {
- const space = spaceSnapshot.data() as Space;
- const actualLocale = identifySpaceLocale(space, locale as string | undefined);
- logger.info(`v1 spaces content id locale identified as => ${actualLocale}`);
- const filePath = contentLocaleCachePath(spaceId, contentId, actualLocale, version as string | undefined);
- bucket
- .file(filePath)
- .download()
- .then(content => {
- res
- .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
- .contentType('application/json; charset=utf-8')
- .send(content.toString());
- })
- .catch(() => {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`)
- .send(new HttpsError('not-found', 'File not found, on path. Please Publish again.'));
- });
- }
- } else {
- res
- .status(404)
- .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`)
- .send(new HttpsError('not-found', 'File not found, Publish first.'));
- return;
- }
-});
-
-expressApp.get('/api/v1/spaces/:spaceId/assets/:assetId', async (req, res) => {
- logger.info('v1 spaces asset params: ' + JSON.stringify(req.params));
- logger.info('v1 spaces asset query: ' + JSON.stringify(req.query));
- const { spaceId, assetId } = req.params;
- const { w: width, download, thumbnail } = req.query;
-
- const assetFile = bucket.file(`spaces/${spaceId}/assets/${assetId}/original`);
- const [exists] = await assetFile.exists();
- const assetSnapshot = await firestoreService.doc(`spaces/${spaceId}/assets/${assetId}`).get();
- let overwriteType: string | undefined;
- logger.info(`v1 spaces asset: ${exists} & ${assetSnapshot.exists}`);
- if (exists && assetSnapshot.exists) {
- const asset = assetSnapshot.data() as AssetFile;
- const tempFilePath = `${os.tmpdir()}/assets-${assetId}`;
- let filename = `${asset.name}${asset.extension}`;
- // apply resize for valid 'w' parameter and images
- if (asset.type.startsWith('image/') && width && !Number.isNaN(width)) {
- if (asset.type === 'image/webp' || asset.type === 'image/gif') {
- // possible animated or single frame webp/gif
- const [file] = await assetFile.download();
- let sharpFile = sharp(file);
- const sharpFileMetadata = await sharpFile.metadata();
- const isAnimated = sharpFileMetadata.pages !== undefined;
- if (thumbnail && isAnimated) {
- // Thumbnail with Animation: Remove animations, to reduce load
- filename = `${asset.name}-w${width}-thumbnail${asset.extension}`;
- sharpFile = sharp(file, { page: 0, pages: 1 });
- await sharpFile.resize(parseInt(width.toString(), 10)).toFile(tempFilePath);
- } else if (thumbnail && !isAnimated) {
- // thumbnail without Animation
- filename = `${asset.name}-w${width}-thumbnail${asset.extension}`;
- // single frame webp/gif
- await sharpFile.resize(parseInt(width.toString(), 10)).toFile(tempFilePath);
- } else {
- // Animated
- filename = `${asset.name}-w${width}${asset.extension}`;
- // animated webp/gif
- // TODO no way now to resize animated files
- await assetFile.download({ destination: tempFilePath });
- }
- } else if (asset.type === 'image/svg+xml') {
- // svg, cannot resize
- await assetFile.download({ destination: tempFilePath });
- } else {
- // other images
- filename = `${asset.name}-w${width}${asset.extension}`;
- const [file] = await assetFile.download();
- await sharp(file).resize(parseInt(width.toString(), 10)).toFile(tempFilePath);
- }
- } else if (asset.type.startsWith('video/') && width && !Number.isNaN(width) && thumbnail) {
- await assetFile.download({ destination: tempFilePath });
- await extractThumbnail(tempFilePath, `screenshot-${assetId}.webp`);
- filename = `${asset.name}-w${width}-thumbnail.webp`;
- overwriteType = 'image/webp';
- await sharp(`${os.tmpdir()}/screenshot-${assetId}.webp`).resize(parseInt(width.toString(), 10)).toFile(tempFilePath);
- } else {
- await assetFile.download({ destination: tempFilePath });
- }
- let disposition = `inline; filename="${encodeURI(filename)}"`;
- if (download !== undefined) {
- disposition = `form-data; filename="${encodeURI(filename)}"`;
- }
- res
- .header('Cache-Control', `public, max-age=${CACHE_ASSET_MAX_AGE}, s-maxage=${CACHE_ASSET_MAX_AGE}`)
- .header('Content-Disposition', disposition)
- .contentType(overwriteType || asset.type)
- .sendFile(tempFilePath);
- return;
- } else {
- res.status(404).header('Cache-Control', 'no-cache').send(new HttpsError('not-found', 'Not found.'));
- return;
- }
-});
+expressApp.use(express.json());
+expressApp.use('/', CDN);
+expressApp.use('/', DEV_TOOLS);
+expressApp.use('/', MANAGE);
export const v1 = onRequest({ memory: '512MiB' }, expressApp);
diff --git a/functions/src/v1/cdn.ts b/functions/src/v1/cdn.ts
new file mode 100644
index 00000000..a223f9ca
--- /dev/null
+++ b/functions/src/v1/cdn.ts
@@ -0,0 +1,448 @@
+import { Router } from 'express';
+import { Query } from 'firebase-admin/firestore';
+import { logger } from 'firebase-functions';
+import { HttpsError } from 'firebase-functions/v2/https';
+import os from 'os';
+import sharp from 'sharp';
+import { bucket, CACHE_ASSET_MAX_AGE, CACHE_MAX_AGE, CACHE_SHARE_MAX_AGE, firestoreService, TEN_MINUTES } from '../config';
+import {
+ AssetFile,
+ Content,
+ ContentDocumentApi,
+ ContentDocumentStorage,
+ ContentKind,
+ ContentLink,
+ Space,
+ TokenPermission,
+} from '../models';
+import {
+ contentLocaleCachePath,
+ extractThumbnail,
+ findContentByFullSlug,
+ findSpaceById,
+ identifySpaceLocale,
+ resolveLinks,
+ resolveReferences,
+ spaceContentCachePath,
+ translationLocaleCachePath,
+ spaceTranslationCachePath,
+} from '../services';
+import {
+ requireContentPermissions,
+ requireTokenPermissions,
+ RequestWithToken,
+ requireTranslationPermissions,
+} from './middleware/query-auth.middleware';
+
+// eslint-disable-next-line new-cap
+export const CDN = Router();
+
+CDN.get('/api/v1/spaces/:spaceId/translations/:locale', requireTranslationPermissions(), async (req: RequestWithToken, res) => {
+ logger.info('v1 spaces translations params : ' + JSON.stringify(req.params));
+ logger.info('v1 spaces translations query : ' + JSON.stringify(req.query));
+ const { spaceId, locale } = req.params;
+ const { cv, version } = req.query;
+ const token = req.tokenId;
+
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ if (!spaceSnapshot.exists) {
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .send(new HttpsError('not-found', 'Not found'));
+ return;
+ }
+
+ const cachePath = spaceTranslationCachePath(spaceId);
+ const [exists] = await bucket.file(cachePath).exists();
+ if (exists) {
+ const [metadata] = await bucket.file(cachePath).getMetadata();
+ logger.info('v1 spaces translations cache meta : ' + JSON.stringify(metadata));
+ if (cv === undefined || cv != metadata.generation) {
+ let url = `/api/v1/spaces/${spaceId}/translations/${locale}?cv=${metadata.generation}`;
+ if (version) {
+ url += `&version=${version}`;
+ }
+ if (token) {
+ url += `&token=${token}`;
+ }
+ logger.info(`v1 spaces translate redirect to => ${url}`);
+ res.redirect(url);
+ return;
+ } else {
+ const space = spaceSnapshot.data() as Space;
+ let actualLocale = locale;
+ if (!space.locales.some(it => it.id === locale)) {
+ actualLocale = space.localeFallback.id;
+ }
+ const filePath = translationLocaleCachePath(spaceId, actualLocale, version as string | undefined);
+ bucket
+ .file(filePath)
+ .download()
+ .then(content => {
+ res
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .contentType('application/json; charset=utf-8')
+ .send(content.toString());
+ })
+ .catch(() => {
+ res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.'));
+ });
+ }
+ } else {
+ res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.'));
+ return;
+ }
+});
+
+CDN.get(
+ '/api/v1/spaces/:spaceId/links',
+ requireTokenPermissions([TokenPermission.CONTENT_PUBLIC, TokenPermission.CONTENT_DRAFT, TokenPermission.DEV_TOOLS]),
+ async (req: RequestWithToken, res) => {
+ logger.info('v1 spaces links params: ' + JSON.stringify(req.params));
+ logger.info('v1 spaces links query: ' + JSON.stringify(req.query));
+ const { spaceId } = req.params;
+ const { kind, parentSlug, excludeChildren, cv } = req.query;
+ const token = req.tokenId;
+
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ if (!spaceSnapshot.exists) {
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .send(new HttpsError('not-found', 'Not found'));
+ return;
+ }
+
+ const cachePath = spaceContentCachePath(spaceId);
+ const [exists] = await bucket.file(cachePath).exists();
+ if (exists) {
+ const [metadata] = await bucket.file(cachePath).getMetadata();
+ logger.info('v1 spaces links cache meta : ' + JSON.stringify(metadata));
+ if (cv === undefined || cv != metadata.generation) {
+ let url = `/api/v1/spaces/${spaceId}/links?cv=${metadata.generation}`;
+ if (parentSlug !== undefined) {
+ url += `&parentSlug=${parentSlug}`;
+ }
+ if (excludeChildren === 'true') {
+ url += `&excludeChildren=${excludeChildren}`;
+ }
+ if (kind === ContentKind.DOCUMENT || kind === ContentKind.FOLDER) {
+ url += `&kind=${kind}`;
+ }
+ if (token) {
+ url += `&token=${token}`;
+ }
+ res.redirect(url);
+ return;
+ } else {
+ let contentsQuery: Query = firestoreService.collection(`spaces/${spaceId}/contents`);
+ if (parentSlug) {
+ if (excludeChildren === 'true') {
+ contentsQuery = contentsQuery.where('parentSlug', '==', parentSlug);
+ } else {
+ contentsQuery = contentsQuery.where('parentSlug', '>=', parentSlug).where('parentSlug', '<', `${parentSlug}/~`);
+ }
+ } else {
+ if (excludeChildren === 'true') {
+ contentsQuery = contentsQuery.where('parentSlug', '==', '');
+ }
+ }
+ if (kind && (kind === ContentKind.DOCUMENT || kind === ContentKind.FOLDER)) {
+ contentsQuery = contentsQuery.where('kind', '==', kind);
+ }
+ const contentsSnapshot = await contentsQuery.get();
+
+ const response: Record = contentsSnapshot.docs
+ .map(contentSnapshot => {
+ const content = contentSnapshot.data() as Content;
+ const link: ContentLink = {
+ id: contentSnapshot.id,
+ kind: content.kind,
+ name: content.name,
+ slug: content.slug,
+ fullSlug: content.fullSlug,
+ parentSlug: content.parentSlug,
+ createdAt: content.createdAt.toDate().toISOString(),
+ updatedAt: content.updatedAt.toDate().toISOString(),
+ };
+ if (content.kind === ContentKind.DOCUMENT) {
+ link.publishedAt = content.publishedAt?.toDate().toISOString();
+ }
+ return link;
+ })
+ .reduce(
+ (acc, item) => {
+ acc[item.id] = item;
+ return acc;
+ },
+ {} as Record
+ );
+ res
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .contentType('application/json; charset=utf-8')
+ .send(response);
+ return;
+ }
+ } else {
+ res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.'));
+ return;
+ }
+ }
+);
+
+CDN.get('/api/v1/spaces/:spaceId/contents/slugs/*slug', requireContentPermissions(), async (req: RequestWithToken, res) => {
+ logger.info('v1 spaces content params: ' + JSON.stringify(req.params));
+ logger.info('v1 spaces content query: ' + JSON.stringify(req.query));
+ const { spaceId } = req.params;
+ const { cv, locale, version, resolveReference, resolveLink } = req.query;
+ const token = req.tokenId;
+ const params: Record = req.params;
+ const slug = params['slug'] as string[];
+ const fullSlug = slug.join('/');
+ let contentId = '';
+
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ if (!spaceSnapshot.exists) {
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .send(new HttpsError('not-found', 'Not found'));
+ return;
+ }
+
+ const contentsSnapshot = await findContentByFullSlug(spaceId, fullSlug).get();
+ // logger.info('v1 spaces contents', contentsSnapshot.size);
+ if (contentsSnapshot.empty) {
+ // No records in database
+ res.status(404).send(new HttpsError('not-found', 'Slug not found'));
+ return;
+ } else {
+ contentId = contentsSnapshot.docs[0].id;
+ }
+ const cacheCheckPath = spaceContentCachePath(spaceId);
+ const cacheCheckFile = bucket.file(cacheCheckPath);
+ logger.info('v1 spaces content cachePath: ' + cacheCheckPath);
+ const [exists] = await cacheCheckFile.exists();
+ if (exists) {
+ const [metadata] = await cacheCheckFile.getMetadata();
+ // logger.info('v1 spaces content cache meta : ' + JSON.stringify(metadata));
+ if (cv === undefined || cv != metadata.generation) {
+ let url = `/api/v1/spaces/${spaceId}/contents/slugs/${fullSlug}?cv=${metadata.generation}`;
+ if (locale) {
+ url += `&locale=${locale}`;
+ }
+ if (version) {
+ url += `&version=${version}`;
+ }
+ if (token) {
+ url += `&token=${token}`;
+ }
+ if (resolveReference) {
+ url += `&resolveReference=${resolveReference}`;
+ }
+ if (resolveLink) {
+ url += `&resolveLink=${resolveLink}`;
+ }
+ logger.info(`v1 spaces content redirect to => ${url}`);
+ res.redirect(url);
+ return;
+ } else {
+ const space = spaceSnapshot.data() as Space;
+ const actualLocale = identifySpaceLocale(space, locale as string | undefined);
+ logger.info(`v1 spaces content slug locale identified as => ${actualLocale}`);
+ const filePath = contentLocaleCachePath(spaceId, contentId, actualLocale, version as string | undefined);
+ bucket
+ .file(filePath)
+ .download()
+ .then(async content => {
+ const contentData: ContentDocumentStorage = JSON.parse(content.toString());
+ const { links, references, ...rest } = contentData;
+ const response: ContentDocumentApi = { ...rest };
+ if (resolveLink === 'true') {
+ logger.info(`v1 spaces content id resolve links => ${links}`);
+ response.links = await resolveLinks(spaceId, contentData);
+ }
+ if (resolveReference === 'true') {
+ logger.info(`v1 spaces content slug resolve refs => ${references}`);
+ response.references = await resolveReferences(spaceId, contentData, actualLocale, version as string | undefined);
+ }
+ res
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .contentType('application/json; charset=utf-8')
+ .send(response);
+ })
+ .catch(() => {
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`)
+ .send(new HttpsError('not-found', 'File not found, on path. Please Publish again. The content is cached for 10 minutes.'));
+ });
+ }
+ } else {
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`)
+ .send(new HttpsError('not-found', 'File not found, Publish first. The content is cached for 10 minutes.'));
+ return;
+ }
+});
+
+CDN.get('/api/v1/spaces/:spaceId/contents/:contentId', requireContentPermissions(), async (req: RequestWithToken, res) => {
+ logger.info('v1 spaces content params: ' + JSON.stringify(req.params));
+ logger.info('v1 spaces content query: ' + JSON.stringify(req.query));
+ const { spaceId, contentId } = req.params;
+ const { cv, locale, version, resolveReference, resolveLink } = req.query;
+ const token = req.tokenId;
+
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ if (!spaceSnapshot.exists) {
+ logger.info('v1 spaces content Space not exist: ' + spaceId);
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .send(new HttpsError('not-found', 'Not found'));
+ return;
+ }
+
+ const cacheCheckPath = spaceContentCachePath(spaceId);
+ const cacheCheckFile = bucket.file(cacheCheckPath);
+ logger.info('v1 spaces content cachePath: ' + cacheCheckPath);
+ const [exists] = await cacheCheckFile.exists();
+ if (exists) {
+ const [metadata] = await cacheCheckFile.getMetadata();
+ // logger.info('v1 spaces content cache meta : ' + JSON.stringify(metadata));
+ if (cv === undefined || cv != metadata.generation) {
+ let url = `/api/v1/spaces/${spaceId}/contents/${contentId}?cv=${metadata.generation}`;
+ if (locale) {
+ url += `&locale=${locale}`;
+ }
+ if (version) {
+ url += `&version=${version}`;
+ }
+ if (token) {
+ url += `&token=${token}`;
+ }
+ if (resolveReference) {
+ url += `&resolveReference=${resolveReference}`;
+ }
+ if (resolveLink) {
+ url += `&resolveLink=${resolveLink}`;
+ }
+ logger.info(`v1 spaces content redirect to => ${url}`);
+ res.redirect(url);
+ return;
+ } else {
+ const space = spaceSnapshot.data() as Space;
+ const actualLocale = identifySpaceLocale(space, locale as string | undefined);
+ logger.info(`v1 spaces content id locale identified as => ${actualLocale}`);
+ const filePath = contentLocaleCachePath(spaceId, contentId, actualLocale, version as string | undefined);
+ bucket
+ .file(filePath)
+ .download()
+ .then(async content => {
+ const contentData: ContentDocumentStorage = JSON.parse(content.toString());
+ const { links, references, ...rest } = contentData;
+ const response: ContentDocumentApi = { ...rest };
+ if (resolveLink === 'true') {
+ logger.info(`v1 spaces content id resolve links => ${links}`);
+ response.links = await resolveLinks(spaceId, contentData);
+ }
+ if (resolveReference === 'true') {
+ logger.info(`v1 spaces content id resolve refs => ${references}`);
+ response.references = await resolveReferences(spaceId, contentData, actualLocale, version as string | undefined);
+ }
+ res
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .contentType('application/json; charset=utf-8')
+ .send(response);
+ })
+ .catch(() => {
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`)
+ .send(new HttpsError('not-found', 'File not found, on path. Please Publish again. The content is cached for 10 minutes.'));
+ });
+ }
+ } else {
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`)
+ .send(new HttpsError('not-found', 'File not found, Publish first. The content is cached for 10 minutes.'));
+ return;
+ }
+});
+
+CDN.get('/api/v1/spaces/:spaceId/assets/:assetId', async (req, res) => {
+ logger.info('v1 spaces asset params: ' + JSON.stringify(req.params));
+ logger.info('v1 spaces asset query: ' + JSON.stringify(req.query));
+ const { spaceId, assetId } = req.params;
+ const { w: width, download, thumbnail } = req.query;
+
+ const assetFile = bucket.file(`spaces/${spaceId}/assets/${assetId}/original`);
+ const [exists] = await assetFile.exists();
+ const assetSnapshot = await firestoreService.doc(`spaces/${spaceId}/assets/${assetId}`).get();
+ let overwriteType: string | undefined;
+ logger.info(`v1 spaces asset: ${exists} & ${assetSnapshot.exists}`);
+ if (exists && assetSnapshot.exists) {
+ const asset = assetSnapshot.data() as AssetFile;
+ const tempFilePath = `${os.tmpdir()}/assets-${assetId}`;
+ let filename = `${asset.name}${asset.extension}`;
+ // apply resize for valid 'w' parameter and images
+ if (asset.type.startsWith('image/') && width && !Number.isNaN(width)) {
+ if (asset.type === 'image/webp' || asset.type === 'image/gif') {
+ // possible animated or single frame webp/gif
+ const [file] = await assetFile.download();
+ let sharpFile = sharp(file);
+ const sharpFileMetadata = await sharpFile.metadata();
+ const isAnimated = sharpFileMetadata.pages !== undefined;
+ if (thumbnail && isAnimated) {
+ // Thumbnail with Animation: Remove animations, to reduce load
+ filename = `${asset.name}-w${width}-thumbnail${asset.extension}`;
+ sharpFile = sharp(file, { page: 0, pages: 1 });
+ await sharpFile.resize(parseInt(width.toString(), 10)).toFile(tempFilePath);
+ } else if (thumbnail && !isAnimated) {
+ // thumbnail without Animation
+ filename = `${asset.name}-w${width}-thumbnail${asset.extension}`;
+ // single frame webp/gif
+ await sharpFile.resize(parseInt(width.toString(), 10)).toFile(tempFilePath);
+ } else {
+ // Animated
+ filename = `${asset.name}-w${width}${asset.extension}`;
+ // animated webp/gif
+ // TODO no way now to resize animated files
+ await assetFile.download({ destination: tempFilePath });
+ }
+ } else if (asset.type === 'image/svg+xml') {
+ // svg, cannot resize
+ await assetFile.download({ destination: tempFilePath });
+ } else {
+ // other images
+ filename = `${asset.name}-w${width}${asset.extension}`;
+ const [file] = await assetFile.download();
+ await sharp(file).resize(parseInt(width.toString(), 10)).toFile(tempFilePath);
+ }
+ } else if (asset.type.startsWith('video/') && width && !Number.isNaN(width) && thumbnail) {
+ await assetFile.download({ destination: tempFilePath });
+ await extractThumbnail(tempFilePath, `screenshot-${assetId}.webp`);
+ filename = `${asset.name}-w${width}-thumbnail.webp`;
+ overwriteType = 'image/webp';
+ await sharp(`${os.tmpdir()}/screenshot-${assetId}.webp`).resize(parseInt(width.toString(), 10)).toFile(tempFilePath);
+ } else {
+ await assetFile.download({ destination: tempFilePath });
+ }
+ let disposition = `inline; filename="${encodeURI(filename)}"`;
+ if (download !== undefined) {
+ disposition = `form-data; filename="${encodeURI(filename)}"`;
+ }
+ res
+ .header('Cache-Control', `public, max-age=${CACHE_ASSET_MAX_AGE}, s-maxage=${CACHE_ASSET_MAX_AGE}`)
+ .header('Content-Disposition', disposition)
+ .contentType(overwriteType || asset.type)
+ .sendFile(tempFilePath);
+ return;
+ } else {
+ res.status(404).header('Cache-Control', 'no-cache').send(new HttpsError('not-found', 'Not found.'));
+ return;
+ }
+});
diff --git a/functions/src/v1/dev-tools.ts b/functions/src/v1/dev-tools.ts
new file mode 100644
index 00000000..2983eb89
--- /dev/null
+++ b/functions/src/v1/dev-tools.ts
@@ -0,0 +1,59 @@
+import { Router } from 'express';
+import { logger } from 'firebase-functions';
+import { HttpsError } from 'firebase-functions/v2/https';
+import { CACHE_MAX_AGE, CACHE_SHARE_MAX_AGE } from '../config';
+import { Schema, Space, TokenPermission } from '../models';
+import { findSchemas, findSpaceById, generateOpenApi } from '../services';
+import { requireTokenPermissions, RequestWithToken } from './middleware/query-auth.middleware';
+
+// eslint-disable-next-line new-cap
+export const DEV_TOOLS = Router();
+
+DEV_TOOLS.get('/api/v1/spaces/:spaceId', requireTokenPermissions([TokenPermission.DEV_TOOLS]), async (req: RequestWithToken, res) => {
+ logger.info('v1 spaces params : ' + JSON.stringify(req.params));
+ logger.info('v1 spaces query : ' + JSON.stringify(req.query));
+ const { spaceId } = req.params;
+
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ if (!spaceSnapshot.exists) {
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .send(new HttpsError('not-found', 'Not found'));
+ return;
+ }
+ const space = spaceSnapshot.data() as Space;
+
+ res.json({
+ id: spaceSnapshot.id,
+ name: space.name,
+ locales: space.locales,
+ localeFallback: space.localeFallback,
+ createdAt: space.createdAt.toDate().toISOString(),
+ updatedAt: space.updatedAt.toDate().toISOString(),
+ });
+});
+
+DEV_TOOLS.get(
+ '/api/v1/spaces/:spaceId/open-api',
+ requireTokenPermissions([TokenPermission.DEV_TOOLS]),
+ async (req: RequestWithToken, res) => {
+ logger.info('v1 spaces content params: ' + JSON.stringify(req.params));
+ logger.info('v1 spaces content query: ' + JSON.stringify(req.query));
+ const { spaceId } = req.params;
+
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ if (!spaceSnapshot.exists) {
+ logger.info('v1 spaces content Space not exist: ' + spaceId);
+ res
+ .status(404)
+ .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`)
+ .send(new HttpsError('not-found', 'Not found'));
+ return;
+ }
+
+ const schemasSnapshot = await findSchemas(spaceId).get();
+ const schemaById = new Map(schemasSnapshot.docs.map(it => [it.id, it.data() as Schema]));
+ res.json(generateOpenApi(schemaById));
+ }
+);
diff --git a/functions/src/v1/manage.ts b/functions/src/v1/manage.ts
new file mode 100644
index 00000000..843021b5
--- /dev/null
+++ b/functions/src/v1/manage.ts
@@ -0,0 +1,130 @@
+import { Router } from 'express';
+import { FieldValue, UpdateData, WithFieldValue } from 'firebase-admin/firestore';
+import { HttpsError } from 'firebase-functions/https';
+import { logger } from 'firebase-functions/v2';
+import { firestoreService } from '../config';
+import { Space, TokenPermission, Translation, TranslationType, zTranslationUpdateSchema } from '../models';
+import { findSpaceById, findTranslationById, findTranslations } from '../services';
+import { RequestWithToken, requireTokenPermissions } from './middleware/api-key-auth.middleware';
+
+// eslint-disable-next-line new-cap
+export const MANAGE = Router();
+
+MANAGE.post(
+ '/api/v1/spaces/:spaceId/translations/:locale',
+ requireTokenPermissions([TokenPermission.DEV_TOOLS]),
+ async (req: RequestWithToken, res) => {
+ logger.info('v1 spaces translations update params : ' + JSON.stringify(req.params));
+ logger.info('v1 spaces translations update body : ' + JSON.stringify(req.body));
+ // req.token contains the validated token object
+ // req.tokenId contains the token string
+ const { spaceId, locale } = req.params;
+ const body = zTranslationUpdateSchema.safeParse(req.body);
+ if (body.success) {
+ const { dryRun, type, values } = body.data;
+ const spaceSnapshot = await findSpaceById(spaceId).get();
+ const space = spaceSnapshot.data() as Space;
+ if (!space.locales.some(it => it.id === locale)) {
+ logger.error(`Locale ${locale} is not in space locales`);
+ res
+ .status(400)
+ .send(new HttpsError('invalid-argument', 'Locale not supported by this space', `Locale ${locale} is not in space locales`));
+ return;
+ }
+ if (type === 'add-missing') {
+ // Handle adding missing translations
+ const fetchPromises = Object.getOwnPropertyNames(values).map(id => findTranslationById(spaceId, id).get());
+ const snapshots = await Promise.all(fetchPromises);
+ const translationIds = snapshots.filter(it => !it.exists).map(it => it.id);
+ if (translationIds.length === 0) {
+ logger.info('No missing translations to add');
+ res.status(200).send({ message: 'No missing translations to add' });
+ return;
+ }
+ if (dryRun) {
+ logger.info(`[DryRun] Would add ${translationIds.length} missing translations`, translationIds);
+ res
+ .status(200)
+ .send({ message: `[DryRun] Would add ${translationIds.length} missing translations`, ids: translationIds, dryRun: true });
+ return;
+ }
+ // Now `missing` contains the IDs of translations that are missing and can be added
+ const bulk = firestoreService.bulkWriter();
+ translationIds.forEach(id => {
+ const ref = findTranslationById(spaceId, id);
+ const data: WithFieldValue = {
+ type: TranslationType.STRING,
+ locales: {
+ [locale]: values[id],
+ },
+ createdAt: FieldValue.serverTimestamp(),
+ updatedAt: FieldValue.serverTimestamp(),
+ };
+ bulk.create(ref, data);
+ });
+ await bulk.close();
+ logger.info(`Added ${translationIds.length} missing translations`, translationIds);
+ res.status(200).send({ message: `Added ${translationIds.length} missing translations`, ids: translationIds });
+ return;
+ } else if (type === 'update-existing') {
+ // Handle updating existing translations
+ const fetchPromises = Object.getOwnPropertyNames(values).map(id => findTranslationById(spaceId, id).get());
+ const snapshots = await Promise.all(fetchPromises);
+ const translationIds = snapshots.filter(it => it.exists).map(it => it.id);
+ if (translationIds.length === 0) {
+ logger.info('No translations to update');
+ res.status(200).send({ message: 'No translations to update' });
+ return;
+ }
+ if (dryRun) {
+ logger.info(`[DryRun] Would update ${translationIds.length} translations`, translationIds);
+ res
+ .status(200)
+ .send({ message: `[DryRun] Would update ${translationIds.length} translations`, ids: translationIds, dryRun: true });
+ return;
+ }
+ // Now `missing` contains the IDs of translations that are missing and can be added
+ const bulk = firestoreService.bulkWriter();
+ translationIds.forEach(id => {
+ const ref = findTranslationById(spaceId, id);
+ const data: UpdateData = {
+ updatedAt: FieldValue.serverTimestamp(),
+ };
+ data[`locales.${locale}`] = values[id];
+ bulk.update(ref, data);
+ });
+ await bulk.close();
+ logger.info(`Updated ${translationIds.length} translations`, translationIds);
+ res.status(200).send({ message: `Updated ${translationIds.length} translations`, ids: translationIds });
+ return;
+ } else if (type === 'delete-missing') {
+ // Handle deleting missing translations
+ const translationsSnapshot = await findTranslations(spaceId).get();
+ const translationIds = translationsSnapshot.docs.filter(it => values[it.id] === undefined).map(it => it.id);
+ if (translationIds.length === 0) {
+ logger.info('No translations to delete');
+ res.status(200).send({ message: 'No translations to delete' });
+ return;
+ }
+ if (dryRun) {
+ logger.info(`[DryRun] Would delete ${translationIds.length} missing translations`, translationIds);
+ res
+ .status(200)
+ .send({ message: `[DryRun] Would delete ${translationIds.length} missing translations`, ids: translationIds, dryRun: true });
+ return;
+ }
+ const bulk = firestoreService.bulkWriter();
+ translationIds.forEach(id => {
+ const ref = findTranslationById(spaceId, id);
+ bulk.delete(ref);
+ });
+ await bulk.close();
+ logger.info(`Delete ${translationIds.length} missing translations`, translationIds);
+ res.status(200).send({ message: `Added ${translationIds.length} missing translations`, ids: translationIds });
+ return;
+ }
+ }
+ logger.error('Bad request body', body.error);
+ res.status(400).send(new HttpsError('invalid-argument', 'Bad request body', body.error));
+ }
+);
diff --git a/functions/src/v1/middleware/PERMISSIONS.md b/functions/src/v1/middleware/PERMISSIONS.md
new file mode 100644
index 00000000..9a0df009
--- /dev/null
+++ b/functions/src/v1/middleware/PERMISSIONS.md
@@ -0,0 +1,76 @@
+# V1 API Endpoints - Permission Matrix
+
+| Endpoint | Method | Required Permissions | Notes |
+|----------|--------|-------------------|------------------------------------------------------------|
+| `/api/v1/spaces/:spaceId` | GET | DEV_TOOLS | Space metadata |
+| `/api/v1/spaces/:spaceId/translations/:locale` | GET | PUBLIC \| DRAFT \| DEV_TOOLS | Translation files |
+| `/api/v1/spaces/:spaceId/translations/:locale` | POST | DEV_TOOLS | Update translations |
+| `/api/v1/spaces/:spaceId/links` | GET | PUBLIC \| DRAFT \| DEV_TOOLS | Content links |
+| `/api/v1/spaces/:spaceId/contents/slugs/*slug` | GET | **Conditional** | Published: PUBLIC \| DRAFT \| DEV_TOOLS,
Draft: DRAFT \| DEV_TOOLS|
+| `/api/v1/spaces/:spaceId/contents/:contentId` | GET | **Conditional** | Published: PUBLIC \| DRAFT \| DEV_TOOLS,
Draft: DRAFT \| DEV_TOOLS |
+| `/api/v1/spaces/:spaceId/open-api` | GET | DEV_TOOLS | OpenAPI schema |
+| `/api/v1/spaces/:spaceId/assets/:assetId` | GET | None | Public asset access |
+
+## Conditional Permissions
+
+Content endpoints (`/contents/:contentId` and `/contents/slugs/*`) use conditional logic:
+- **Without `version` parameter**: Requires `PUBLIC` OR `DRAFT` permission (published content)
+- **With `version` parameter**: Requires `DRAFT` permission only (draft/preview content)
+
+## Usage Examples
+
+```typescript
+// Basic endpoint with multiple permissions
+router.get(
+ '/api/v1/spaces/:spaceId',
+ requireTokenPermissions([TokenPermission.PUBLIC, TokenPermission.DRAFT, TokenPermission.DEV_TOOLS]),
+ async (req: RequestWithToken, res) => {
+ // Handler code
+ }
+);
+
+// Endpoint with single permission
+router.get(
+ '/api/v1/spaces/:spaceId/open-api',
+ requireTokenPermissions([TokenPermission.DEV_TOOLS]),
+ async (req: RequestWithToken, res) => {
+ // Handler code
+ }
+);
+
+// Content endpoint with conditional logic
+router.get(
+ '/api/v1/spaces/:spaceId/contents/:contentId',
+ requireContentPermissions(),
+ async (req: RequestWithToken, res) => {
+ // Handler code
+ }
+);
+```
+
+## Request Requirements
+
+All endpoints expect:
+1. **Route Parameter**: `spaceId` in the URL path
+2. **Query Parameter**: `token` with a valid 20-character token ID
+
+Example request:
+```
+GET /api/v1/spaces/my-space-id/contents/abc123?token=12345678901234567890
+```
+
+## Token Permissions
+
+Token permissions are stored in Firestore and can be:
+- **V1 Tokens** (legacy): Automatically have `PUBLIC` and `DRAFT` permissions
+- **V2 Tokens**: Have explicit permission arrays defined in the token document
+
+```typescript
+interface TokenV2 {
+ version: 2;
+ name: string;
+ permissions: TokenPermission[]; // ['PUBLIC', 'DRAFT', 'DEV_TOOLS']
+ createdAt: Timestamp;
+ updatedAt: Timestamp;
+}
+```
diff --git a/functions/src/v1/middleware/README.md b/functions/src/v1/middleware/README.md
new file mode 100644
index 00000000..e65d3d82
--- /dev/null
+++ b/functions/src/v1/middleware/README.md
@@ -0,0 +1,137 @@
+# Authentication Middleware
+
+This middleware provides token-based authentication and role-based authorization for V1 API endpoints.
+
+## Features
+
+- Validates token format and existence
+- Fetches token data from Firestore
+- Checks token permissions against required roles
+- Attaches token information to the request object
+- Provides clear error messages for authentication failures
+
+## Usage
+
+### Basic Usage
+
+```typescript
+import { Router } from 'express';
+import { TokenPermission } from '../models';
+import { requireTokenPermissions, RequestWithToken } from './middleware';
+
+const router = Router();
+
+// Require a single permission
+router.get(
+ '/api/v1/spaces/:spaceId/content',
+ requireTokenPermissions([TokenPermission.PUBLIC]),
+ async (req: RequestWithToken, res) => {
+ // Access validated token
+ const token = req.token;
+ const tokenId = req.tokenId;
+
+ // Your route logic here
+ }
+);
+
+// Require multiple permissions (token must have at least one)
+router.post(
+ '/api/v1/spaces/:spaceId/content',
+ requireTokenPermissions([TokenPermission.DRAFT, TokenPermission.DEV_TOOLS]),
+ async (req: RequestWithToken, res) => {
+ // Only tokens with DRAFT or DEV_TOOLS permission can access this
+ }
+);
+```
+
+### Using the Helper Function
+
+For single permission checks, you can use the convenience function:
+
+```typescript
+import { requireTokenPermission } from './middleware';
+
+router.get(
+ '/api/v1/spaces/:spaceId/dev-tools',
+ requireTokenPermission(TokenPermission.DEV_TOOLS),
+ async (req: RequestWithToken, res) => {
+ // Only tokens with DEV_TOOLS permission
+ }
+);
+```
+
+## Request Requirements
+
+The middleware expects:
+1. **spaceId** in route parameters: `/api/v1/spaces/:spaceId/...`
+2. **token** in query parameters: `?token=your-token-here`
+
+Example request:
+```
+POST /api/v1/spaces/my-space-id/content?token=abc123xyz456
+```
+
+## Response Codes
+
+- **401 Unauthorized**: Invalid token format, token not found
+- **403 Forbidden**: Token exists but lacks required permissions
+- **400 Bad Request**: Missing required spaceId parameter
+- **500 Internal Server Error**: Error during token verification
+
+## Token Permissions
+
+Available permissions (from `TokenPermission` enum):
+- `PUBLIC` - Read-only access to public content
+- `DRAFT` - Access to draft content
+- `DEV_TOOLS` - Access to development tools
+
+## Extended Request Type
+
+When using the middleware, type your request handler with `RequestWithToken` to access:
+
+```typescript
+interface RequestWithToken extends Request {
+ token?: Token; // The full token object from Firestore
+ tokenId?: string; // The token ID string
+}
+```
+
+## Example Integration
+
+```typescript
+import { Router } from 'express';
+import { TokenPermission } from '../models';
+import { requireTokenPermissions, RequestWithToken } from './middleware';
+
+export const API = Router();
+
+// Public content - requires PUBLIC permission
+API.get(
+ '/api/v1/spaces/:spaceId/content/:contentId',
+ requireTokenPermissions([TokenPermission.PUBLIC]),
+ async (req: RequestWithToken, res) => {
+ const { spaceId, contentId } = req.params;
+ // Fetch and return content
+ }
+);
+
+// Draft content - requires DRAFT or DEV_TOOLS permission
+API.get(
+ '/api/v1/spaces/:spaceId/drafts/:draftId',
+ requireTokenPermissions([TokenPermission.DRAFT, TokenPermission.DEV_TOOLS]),
+ async (req: RequestWithToken, res) => {
+ const { spaceId, draftId } = req.params;
+ // Fetch and return draft content
+ }
+);
+
+// Admin operations - requires DEV_TOOLS permission
+API.post(
+ '/api/v1/spaces/:spaceId/settings',
+ requireTokenPermission(TokenPermission.DEV_TOOLS),
+ async (req: RequestWithToken, res) => {
+ const { spaceId } = req.params;
+ // Update space settings
+ }
+);
+```
diff --git a/functions/src/v1/middleware/api-key-auth.middleware.ts b/functions/src/v1/middleware/api-key-auth.middleware.ts
new file mode 100644
index 00000000..91deaa7c
--- /dev/null
+++ b/functions/src/v1/middleware/api-key-auth.middleware.ts
@@ -0,0 +1,77 @@
+import { NextFunction, Request, Response } from 'express';
+import { HttpsError } from 'firebase-functions/v2/https';
+import { Token, TokenPermission } from '../../models';
+import { findTokenById, validateToken } from '../../services';
+import { canPerformAny } from '../../utils/api-auth-utils';
+
+const AUTH_HEADER = 'X-API-KEY';
+/**
+ * Extended Express Request with token information
+ */
+export interface RequestWithToken extends Request {
+ token?: Token;
+ tokenId?: string;
+}
+
+/**
+ * Middleware factory that creates an authentication middleware checking for specific token permissions
+ * @param {TokenPermission[]} requiredPermissions - List of permissions that the token must have
+ * @return {Function} Express middleware function
+ */
+export function requireTokenPermissions(requiredPermissions: TokenPermission[]) {
+ return async (req: Request, res: Response, next: NextFunction): Promise => {
+ const { spaceId } = req.params;
+ const tokenId = req.header(AUTH_HEADER);
+
+ // Validate token format
+ if (!validateToken(tokenId)) {
+ res.status(401).send(new HttpsError('unauthenticated', 'Invalid or missing token'));
+ return;
+ }
+
+ // Validate spaceId exists
+ if (!spaceId) {
+ res.status(400).send(new HttpsError('invalid-argument', 'Space ID is required'));
+ return;
+ }
+
+ try {
+ // Fetch token from Firestore
+ const tokenSnapshot = await findTokenById(spaceId, tokenId as string).get();
+
+ if (!tokenSnapshot.exists) {
+ res.status(401).send(new HttpsError('unauthenticated', 'Token not found'));
+ return;
+ }
+
+ const token = tokenSnapshot.data() as Token;
+
+ // Check if token has any of the required permissions
+ const hasPermission = canPerformAny(requiredPermissions, token);
+
+ if (!hasPermission) {
+ res
+ .status(403)
+ .send(new HttpsError('permission-denied', `Token does not have required permissions: ${requiredPermissions.join(', ')}`));
+ return;
+ }
+
+ // Attach token and tokenId to request for use in route handlers
+ (req as RequestWithToken).token = token;
+ (req as RequestWithToken).tokenId = tokenId as string;
+
+ next();
+ } catch (error) {
+ res.status(500).send(new HttpsError('internal', 'Failed to verify token'));
+ }
+ };
+}
+
+/**
+ * Middleware that checks for a single token permission
+ * @param {TokenPermission} permission - The permission required
+ * @return {Function} Express middleware function
+ */
+export function requireTokenPermission(permission: TokenPermission) {
+ return requireTokenPermissions([permission]);
+}
diff --git a/functions/src/v1/middleware/query-auth.middleware.ts b/functions/src/v1/middleware/query-auth.middleware.ts
new file mode 100644
index 00000000..78b2fe07
--- /dev/null
+++ b/functions/src/v1/middleware/query-auth.middleware.ts
@@ -0,0 +1,204 @@
+import { NextFunction, Request, Response } from 'express';
+import { HttpsError } from 'firebase-functions/v2/https';
+import { Token, TokenPermission } from '../../models';
+import { findTokenById, validateToken } from '../../services';
+import { canPerformAny } from '../../utils/api-auth-utils';
+
+/**
+ * Extended Express Request with token information
+ */
+export interface RequestWithToken extends Request {
+ token?: Token;
+ tokenId?: string;
+}
+
+/**
+ * Middleware factory that creates an authentication middleware checking for specific token permissions
+ * @param {TokenPermission[]} requiredPermissions - List of permissions that the token must have
+ * @return {Function} Express middleware function
+ */
+export function requireTokenPermissions(requiredPermissions: TokenPermission[]) {
+ return async (req: Request, res: Response, next: NextFunction): Promise => {
+ const { spaceId } = req.params;
+ const { token: tokenId } = req.query;
+
+ // Validate token format
+ if (!validateToken(tokenId)) {
+ res.status(401).send(new HttpsError('unauthenticated', 'Invalid or missing token'));
+ return;
+ }
+
+ // Validate spaceId exists
+ if (!spaceId) {
+ res.status(400).send(new HttpsError('invalid-argument', 'Space ID is required'));
+ return;
+ }
+
+ try {
+ // Fetch token from Firestore
+ const tokenSnapshot = await findTokenById(spaceId, tokenId as string).get();
+
+ if (!tokenSnapshot.exists) {
+ res.status(401).send(new HttpsError('unauthenticated', 'Token not found'));
+ return;
+ }
+
+ const token = tokenSnapshot.data() as Token;
+
+ // Check if token has any of the required permissions
+ const hasPermission = canPerformAny(requiredPermissions, token);
+
+ if (!hasPermission) {
+ res
+ .status(403)
+ .send(new HttpsError('permission-denied', `Token does not have required permissions: ${requiredPermissions.join(', ')}`));
+ return;
+ }
+
+ // Attach token and tokenId to request for use in route handlers
+ (req as RequestWithToken).token = token;
+ (req as RequestWithToken).tokenId = tokenId as string;
+
+ next();
+ } catch (error) {
+ res.status(500).send(new HttpsError('internal', 'Failed to verify token'));
+ }
+ };
+}
+
+/**
+ * Middleware that checks for a single token permission
+ * @param {TokenPermission} permission - The permission required
+ * @return {Function} Express middleware function
+ */
+export function requireTokenPermission(permission: TokenPermission) {
+ return requireTokenPermissions([permission]);
+}
+
+/**
+ * Middleware for content endpoints that checks permissions based on version query parameter
+ * Published content (no version) requires PUBLIC or DRAFT permission
+ * Draft content (with version) requires DRAFT permission
+ * @return {Function} Express middleware function
+ */
+export function requireContentPermissions() {
+ return async (req: Request, res: Response, next: NextFunction): Promise => {
+ const { spaceId } = req.params;
+ const { token: tokenId, version } = req.query;
+
+ // Validate token format
+ if (!validateToken(tokenId)) {
+ res.status(401).send(new HttpsError('unauthenticated', 'Invalid or missing token'));
+ return;
+ }
+
+ // Validate spaceId exists
+ if (!spaceId) {
+ res.status(400).send(new HttpsError('invalid-argument', 'Space ID is required'));
+ return;
+ }
+
+ try {
+ // Fetch token from Firestore
+ const tokenSnapshot = await findTokenById(spaceId, tokenId as string).get();
+
+ if (!tokenSnapshot.exists) {
+ res.status(401).send(new HttpsError('unauthenticated', 'Token not found'));
+ return;
+ }
+
+ const token = tokenSnapshot.data() as Token;
+
+ // Check permissions: version requires DRAFT, published (no version) requires PUBLIC or DRAFT
+ const hasRequiredPermission =
+ version !== undefined
+ ? canPerformAny([TokenPermission.CONTENT_DRAFT, TokenPermission.DEV_TOOLS], token)
+ : canPerformAny([TokenPermission.CONTENT_PUBLIC, TokenPermission.CONTENT_DRAFT, TokenPermission.DEV_TOOLS], token);
+
+ if (!hasRequiredPermission) {
+ res
+ .status(403)
+ .send(
+ new HttpsError(
+ 'permission-denied',
+ version !== undefined ? 'Draft content requires DRAFT permission' : 'Published content requires PUBLIC or DRAFT permission'
+ )
+ );
+ return;
+ }
+
+ // Attach token and tokenId to request for use in route handlers
+ (req as RequestWithToken).token = token;
+ (req as RequestWithToken).tokenId = tokenId as string;
+
+ next();
+ } catch (error) {
+ res.status(500).send(new HttpsError('internal', 'Failed to verify token'));
+ }
+ };
+}
+
+/**
+ * Middleware for translation endpoints that checks permissions based on version query parameter
+ * Published translation (no version) requires PUBLIC or DRAFT permission
+ * Draft translation (with version) requires DRAFT permission
+ * @return {Function} Express middleware function
+ */
+export function requireTranslationPermissions() {
+ return async (req: Request, res: Response, next: NextFunction): Promise => {
+ const { spaceId } = req.params;
+ const { token: tokenId, version } = req.query;
+
+ // Validate token format
+ if (!validateToken(tokenId)) {
+ res.status(401).send(new HttpsError('unauthenticated', 'Invalid or missing token'));
+ return;
+ }
+
+ // Validate spaceId exists
+ if (!spaceId) {
+ res.status(400).send(new HttpsError('invalid-argument', 'Space ID is required'));
+ return;
+ }
+
+ try {
+ // Fetch token from Firestore
+ const tokenSnapshot = await findTokenById(spaceId, tokenId as string).get();
+
+ if (!tokenSnapshot.exists) {
+ res.status(401).send(new HttpsError('unauthenticated', 'Token not found'));
+ return;
+ }
+
+ const token = tokenSnapshot.data() as Token;
+
+ // Check permissions: version requires DRAFT, published (no version) requires PUBLIC or DRAFT
+ const hasRequiredPermission =
+ version !== undefined
+ ? canPerformAny([TokenPermission.TRANSLATION_DRAFT, TokenPermission.DEV_TOOLS], token)
+ : canPerformAny([TokenPermission.TRANSLATION_PUBLIC, TokenPermission.TRANSLATION_DRAFT, TokenPermission.DEV_TOOLS], token);
+
+ if (!hasRequiredPermission) {
+ res
+ .status(403)
+ .send(
+ new HttpsError(
+ 'permission-denied',
+ version !== undefined
+ ? 'Draft translation requires DRAFT permission'
+ : 'Published translation requires PUBLIC or DRAFT permission'
+ )
+ );
+ return;
+ }
+
+ // Attach token and tokenId to request for use in route handlers
+ (req as RequestWithToken).token = token;
+ (req as RequestWithToken).tokenId = tokenId as string;
+
+ next();
+ } catch (error) {
+ res.status(500).send(new HttpsError('internal', 'Failed to verify token'));
+ }
+ };
+}
diff --git a/functions/src/webhooks.ts b/functions/src/webhooks.ts
new file mode 100644
index 00000000..531d2a88
--- /dev/null
+++ b/functions/src/webhooks.ts
@@ -0,0 +1,20 @@
+import { logger } from 'firebase-functions/v2';
+import { onDocumentDeleted } from 'firebase-functions/v2/firestore';
+import { firestoreService } from './config';
+
+const onWebHookDelete = onDocumentDeleted('spaces/{spaceId}/webhooks/{webhookId}', async event => {
+ const { id, params, data } = event;
+ logger.info(`[WebHook::onDelete] eventId='${id}'`);
+ logger.info(`[WebHook::onDelete] params='${JSON.stringify(params)}'`);
+ const { spaceId, webhookId } = params;
+
+ if (data) {
+ logger.info(`[WebHook::onDelete] spaceId='${spaceId}' webhookId='${webhookId}'`);
+ await firestoreService.recursiveDelete(data.ref);
+ }
+ return;
+});
+
+export const webhook = {
+ ondelete: onWebHookDelete,
+};
diff --git a/functions/tsconfig.json b/functions/tsconfig.json
index 8e177329..8fdc4a62 100644
--- a/functions/tsconfig.json
+++ b/functions/tsconfig.json
@@ -6,7 +6,7 @@
"outDir": "lib",
"sourceMap": true,
"strict": true,
- "target": "ES2020",
+ "target": "ES2022",
"esModuleInterop": true,
"resolveJsonModule": true
},
diff --git a/karma.conf.js b/karma.conf.cjs
similarity index 100%
rename from karma.conf.js
rename to karma.conf.cjs
diff --git a/libs/ui/accordion/src/index.ts b/libs/ui/accordion/src/index.ts
new file mode 100644
index 00000000..203e837f
--- /dev/null
+++ b/libs/ui/accordion/src/index.ts
@@ -0,0 +1,19 @@
+import { HlmAccordion } from './lib/hlm-accordion';
+import { HlmAccordionContent } from './lib/hlm-accordion-content';
+import { HlmAccordionIcon } from './lib/hlm-accordion-icon';
+import { HlmAccordionItem } from './lib/hlm-accordion-item';
+import { HlmAccordionTrigger } from './lib/hlm-accordion-trigger';
+
+export * from './lib/hlm-accordion';
+export * from './lib/hlm-accordion-content';
+export * from './lib/hlm-accordion-icon';
+export * from './lib/hlm-accordion-item';
+export * from './lib/hlm-accordion-trigger';
+
+export const HlmAccordionImports = [
+ HlmAccordion,
+ HlmAccordionItem,
+ HlmAccordionTrigger,
+ HlmAccordionIcon,
+ HlmAccordionContent,
+] as const;
diff --git a/libs/ui/accordion/src/lib/hlm-accordion-content.ts b/libs/ui/accordion/src/lib/hlm-accordion-content.ts
new file mode 100644
index 00000000..81118040
--- /dev/null
+++ b/libs/ui/accordion/src/lib/hlm-accordion-content.ts
@@ -0,0 +1,21 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { BrnAccordionContent } from '@spartan-ng/brain/accordion';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-accordion-content',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }],
+ template: `
+
+
+
+ `,
+})
+export class HlmAccordionContent {
+ constructor() {
+ classes(
+ () => 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-[var(--brn-accordion-content-height)]',
+ );
+ }
+}
diff --git a/libs/ui/accordion/src/lib/hlm-accordion-icon.ts b/libs/ui/accordion/src/lib/hlm-accordion-icon.ts
new file mode 100644
index 00000000..7bae01a6
--- /dev/null
+++ b/libs/ui/accordion/src/lib/hlm-accordion-icon.ts
@@ -0,0 +1,18 @@
+import { Directive } from '@angular/core';
+import { provideIcons } from '@ng-icons/core';
+import { lucideChevronDown } from '@ng-icons/lucide';
+import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'ng-icon[hlmAccordionIcon], ng-icon[hlmAccIcon]',
+ providers: [provideIcons({ lucideChevronDown }), provideHlmIconConfig({ size: 'sm' })],
+})
+export class HlmAccordionIcon {
+ constructor() {
+ classes(
+ () =>
+ 'text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180',
+ );
+ }
+}
diff --git a/libs/ui/accordion/src/lib/hlm-accordion-item.ts b/libs/ui/accordion/src/lib/hlm-accordion-item.ts
new file mode 100644
index 00000000..840ea778
--- /dev/null
+++ b/libs/ui/accordion/src/lib/hlm-accordion-item.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { BrnAccordionItem } from '@spartan-ng/brain/accordion';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAccordionItem],brn-accordion-item[hlm],hlm-accordion-item',
+ hostDirectives: [
+ {
+ directive: BrnAccordionItem,
+ inputs: ['isOpened'],
+ outputs: ['openedChange'],
+ },
+ ],
+})
+export class HlmAccordionItem {
+ constructor() {
+ classes(() => 'border-border flex flex-1 flex-col border-b');
+ }
+}
diff --git a/libs/ui/accordion/src/lib/hlm-accordion-trigger.ts b/libs/ui/accordion/src/lib/hlm-accordion-trigger.ts
new file mode 100644
index 00000000..7c704d8e
--- /dev/null
+++ b/libs/ui/accordion/src/lib/hlm-accordion-trigger.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { BrnAccordionTrigger } from '@spartan-ng/brain/accordion';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAccordionTrigger]',
+ hostDirectives: [BrnAccordionTrigger],
+ host: {
+ '[style.--tw-ring-offset-shadow]': '"0 0 #000"',
+ },
+})
+export class HlmAccordionTrigger {
+ constructor() {
+ classes(
+ () =>
+ 'group focus-visible:ring-ring focus-visible:ring-offset-background flex w-full items-center justify-between py-4 text-left font-medium transition-all hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none [&[data-state=open]>ng-icon]:rotate-180',
+ );
+ }
+}
diff --git a/libs/ui/accordion/src/lib/hlm-accordion.ts b/libs/ui/accordion/src/lib/hlm-accordion.ts
new file mode 100644
index 00000000..efd8098b
--- /dev/null
+++ b/libs/ui/accordion/src/lib/hlm-accordion.ts
@@ -0,0 +1,13 @@
+import { Directive } from '@angular/core';
+import { BrnAccordion } from '@spartan-ng/brain/accordion';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAccordion], hlm-accordion',
+ hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'dir', 'orientation'] }],
+})
+export class HlmAccordion {
+ constructor() {
+ classes(() => 'flex data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col');
+ }
+}
diff --git a/libs/ui/aspect-ratio/src/index.ts b/libs/ui/aspect-ratio/src/index.ts
new file mode 100644
index 00000000..0b362caa
--- /dev/null
+++ b/libs/ui/aspect-ratio/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmAspectRatio } from './lib/helm-aspect-ratio';
+
+export * from './lib/helm-aspect-ratio';
+
+export const HlmAspectRatioImports = [HlmAspectRatio] as const;
diff --git a/libs/ui/aspect-ratio/src/lib/helm-aspect-ratio.ts b/libs/ui/aspect-ratio/src/lib/helm-aspect-ratio.ts
new file mode 100644
index 00000000..111bf52e
--- /dev/null
+++ b/libs/ui/aspect-ratio/src/lib/helm-aspect-ratio.ts
@@ -0,0 +1,35 @@
+import { type NumberInput, coerceNumberProperty } from '@angular/cdk/coercion';
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+const parseDividedString = (value: NumberInput): NumberInput => {
+ if (typeof value !== 'string' || !value.includes('/')) return value;
+ return value
+ .split('/')
+ .map((v) => Number.parseInt(v, 10))
+ .reduce((a, b) => a / b);
+};
+
+@Directive({
+ selector: '[hlmAspectRatio]',
+ host: { '[style.padding-bottom.%]': '100 / ratio()' },
+})
+export class HlmAspectRatio {
+ /**
+ * Aspect ratio of the element, defined as width / height.
+ */
+ public readonly ratio = input(1, {
+ alias: 'hlmAspectRatio',
+ transform: (value: NumberInput) => {
+ const coerced = coerceNumberProperty(parseDividedString(value));
+ return coerced <= 0 ? 1 : coerced;
+ },
+ });
+
+ constructor() {
+ classes(
+ () =>
+ 'relative w-full [&>*:first-child]:absolute [&>*:first-child]:h-full [&>*:first-child]:w-full [&>*:first-child]:object-cover',
+ );
+ }
+}
diff --git a/libs/ui/autocomplete/src/index.ts b/libs/ui/autocomplete/src/index.ts
new file mode 100644
index 00000000..8c136fb9
--- /dev/null
+++ b/libs/ui/autocomplete/src/index.ts
@@ -0,0 +1,40 @@
+import { HlmAutocomplete } from './lib/hlm-autocomplete';
+import { HlmAutocompleteContent } from './lib/hlm-autocomplete-content';
+import { HlmAutocompleteEmpty } from './lib/hlm-autocomplete-empty';
+import { HlmAutocompleteGroup } from './lib/hlm-autocomplete-group';
+import { HlmAutocompleteInput } from './lib/hlm-autocomplete-input';
+import { HlmAutocompleteItem } from './lib/hlm-autocomplete-item';
+import { HlmAutocompleteLabel } from './lib/hlm-autocomplete-label';
+import { HlmAutocompleteList } from './lib/hlm-autocomplete-list';
+import { HlmAutocompletePortal } from './lib/hlm-autocomplete-portal';
+import { HlmAutocompleteSearch } from './lib/hlm-autocomplete-search';
+import { HlmAutocompleteSeparator } from './lib/hlm-autocomplete-separator';
+import { HlmAutocompleteStatus } from './lib/hlm-autocomplete-status';
+
+export * from './lib/hlm-autocomplete';
+export * from './lib/hlm-autocomplete-content';
+export * from './lib/hlm-autocomplete-empty';
+export * from './lib/hlm-autocomplete-group';
+export * from './lib/hlm-autocomplete-input';
+export * from './lib/hlm-autocomplete-item';
+export * from './lib/hlm-autocomplete-label';
+export * from './lib/hlm-autocomplete-list';
+export * from './lib/hlm-autocomplete-portal';
+export * from './lib/hlm-autocomplete-search';
+export * from './lib/hlm-autocomplete-separator';
+export * from './lib/hlm-autocomplete-status';
+
+export const HlmAutocompleteImports = [
+ HlmAutocomplete,
+ HlmAutocompleteContent,
+ HlmAutocompleteEmpty,
+ HlmAutocompleteGroup,
+ HlmAutocompleteInput,
+ HlmAutocompleteItem,
+ HlmAutocompleteLabel,
+ HlmAutocompleteList,
+ HlmAutocompletePortal,
+ HlmAutocompleteSearch,
+ HlmAutocompleteSeparator,
+ HlmAutocompleteStatus,
+] as const;
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-content.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-content.ts
new file mode 100644
index 00000000..8a698736
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-content.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnAutocompleteContent } from '@spartan-ng/brain/autocomplete';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocompleteContent],hlm-autocomplete-content',
+ hostDirectives: [BrnAutocompleteContent],
+})
+export class HlmAutocompleteContent {
+ constructor() {
+ classes(
+ () =>
+ 'group/autocomplete-content bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex max-h-72 w-(--brn-autocomplete-width) min-w-36 flex-col overflow-hidden rounded-md p-0 shadow-md ring-1 duration-100',
+ );
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-empty.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-empty.ts
new file mode 100644
index 00000000..b7191708
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-empty.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { BrnAutocompleteEmpty } from '@spartan-ng/brain/autocomplete';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocompleteEmpty],hlm-autocomplete-empty',
+ hostDirectives: [BrnAutocompleteEmpty],
+ host: {
+ 'data-slot': 'autocomplete-empty',
+ },
+})
+export class HlmAutocompleteEmpty {
+ constructor() {
+ classes(
+ () =>
+ 'text-muted-foreground hidden w-full items-center justify-center gap-2 py-2 text-center text-sm group-data-empty/autocomplete-content:flex',
+ );
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-group.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-group.ts
new file mode 100644
index 00000000..478d9561
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-group.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnAutocompleteGroup } from '@spartan-ng/brain/autocomplete';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocompleteGroup]',
+ hostDirectives: [BrnAutocompleteGroup],
+ host: {
+ 'data-slot': 'autocomplete-group',
+ },
+})
+export class HlmAutocompleteGroup {
+ constructor() {
+ classes(() => 'data-hidden:hidden');
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-input.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-input.ts
new file mode 100644
index 00000000..7b5339c5
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-input.ts
@@ -0,0 +1,64 @@
+import { BooleanInput } from '@angular/cdk/coercion';
+import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideSearch, lucideX } from '@ng-icons/lucide';
+import {
+ BrnAutocompleteAnchor,
+ BrnAutocompleteClear,
+ BrnAutocompleteInput,
+ BrnAutocompleteInputWrapper,
+} from '@spartan-ng/brain/autocomplete';
+import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
+
+@Component({
+ selector: 'hlm-autocomplete-input',
+ imports: [HlmInputGroupImports, NgIcon, BrnAutocompleteAnchor, BrnAutocompleteClear, BrnAutocompleteInput],
+ providers: [provideIcons({ lucideSearch, lucideX })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [BrnAutocompleteInputWrapper],
+ template: `
+
+
+
+ @if (showSearch()) {
+
+
+
+ }
+
+ @if (showClear()) {
+
+
+
+ }
+
+
+ `,
+})
+export class HlmAutocompleteInput {
+ public readonly placeholder = input('');
+
+ public readonly showSearch = input(true, { transform: booleanAttribute });
+ public readonly showClear = input(false, { transform: booleanAttribute });
+
+ // TODO input and input-group styles need to support aria-invalid directly
+ public readonly ariaInvalid = input(false, {
+ transform: booleanAttribute,
+ alias: 'aria-invalid',
+ });
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-item.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-item.ts
new file mode 100644
index 00000000..131dcee4
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-item.ts
@@ -0,0 +1,38 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideCheck } from '@ng-icons/lucide';
+import { BrnAutocompleteItem } from '@spartan-ng/brain/autocomplete';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-autocomplete-item',
+ imports: [NgIcon],
+ providers: [provideIcons({ lucideCheck })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [{ directive: BrnAutocompleteItem, inputs: ['id', 'disabled', 'value'] }],
+ host: {
+ 'data-slot': 'autocomplete-item',
+ },
+ template: `
+
+ @if (_active()) {
+
+ }
+ `,
+})
+export class HlmAutocompleteItem {
+ private readonly _brnAutocompleteItem = inject(BrnAutocompleteItem);
+
+ protected readonly _active = this._brnAutocompleteItem.active;
+
+ constructor() {
+ classes(
+ () =>
+ `data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-hidden:hidden data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0 [&_ng-icon:not([class*='text-'])]:text-base`,
+ );
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-label.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-label.ts
new file mode 100644
index 00000000..b93cfafe
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-label.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnAutocompleteLabel } from '@spartan-ng/brain/autocomplete';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocompleteLabel]',
+ hostDirectives: [{ directive: BrnAutocompleteLabel, inputs: ['id'] }],
+ host: {
+ 'data-slot': 'autocomplete-label',
+ },
+})
+export class HlmAutocompleteLabel {
+ constructor() {
+ classes(() => 'text-muted-foreground px-2 py-1.5 text-xs');
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-list.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-list.ts
new file mode 100644
index 00000000..09291eb2
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-list.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { BrnAutocompleteList } from '@spartan-ng/brain/autocomplete';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocompleteList]',
+ hostDirectives: [{ directive: BrnAutocompleteList, inputs: ['id'] }],
+ host: {
+ 'data-slot': 'autocomplete-list',
+ },
+})
+export class HlmAutocompleteList {
+ constructor() {
+ classes(
+ () =>
+ 'no-scrollbar max-h-[calc(--spacing(72)---spacing(9))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0',
+ );
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-portal.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-portal.ts
new file mode 100644
index 00000000..e3643d0c
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-portal.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnPopoverContent } from '@spartan-ng/brain/popover';
+
+@Directive({
+ selector: '[hlmAutocompletePortal]',
+ hostDirectives: [{ directive: BrnPopoverContent, inputs: ['context', 'class'] }],
+})
+export class HlmAutocompletePortal {}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-search.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-search.ts
new file mode 100644
index 00000000..bcc84057
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-search.ts
@@ -0,0 +1,47 @@
+import { Directive } from '@angular/core';
+import { BrnAutocompleteSearch } from '@spartan-ng/brain/autocomplete';
+import { provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
+import { BrnPopover, provideBrnPopoverConfig } from '@spartan-ng/brain/popover';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocompleteSearch],hlm-autocomplete-search',
+ providers: [
+ provideBrnPopoverConfig({
+ align: 'start',
+ sideOffset: 6,
+ }),
+ provideBrnDialogDefaultOptions({
+ autoFocus: 'first-heading',
+ }),
+ ],
+ hostDirectives: [
+ {
+ directive: BrnAutocompleteSearch,
+ inputs: ['autoHighlight', 'disabled', 'value', 'search', 'itemToString'],
+ outputs: ['valueChange', 'searchChange'],
+ },
+ {
+ directive: BrnPopover,
+ inputs: [
+ 'align',
+ 'autoFocus',
+ 'closeDelay',
+ 'closeOnOutsidePointerEvents',
+ 'sideOffset',
+ 'state',
+ 'offsetX',
+ 'restoreFocus',
+ ],
+ outputs: ['stateChanged', 'closed'],
+ },
+ ],
+ host: {
+ 'data-slot': 'autocomplete',
+ },
+})
+export class HlmAutocompleteSearch {
+ constructor() {
+ classes(() => 'block');
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-separator.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-separator.ts
new file mode 100644
index 00000000..805e412b
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-separator.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnAutocompleteSeparator } from '@spartan-ng/brain/autocomplete';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocompleteSeparator]',
+ hostDirectives: [{ directive: BrnAutocompleteSeparator, inputs: ['orientation'] }],
+ host: {
+ 'data-slot': 'autocomplete-separator',
+ },
+})
+export class HlmAutocompleteSeparator {
+ constructor() {
+ classes(() => 'bg-border -mx-1 my-1 h-px');
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-status.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-status.ts
new file mode 100644
index 00000000..1e2415b3
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-status.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnAutocompleteStatus } from '@spartan-ng/brain/autocomplete';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocompleteStatus],hlm-autocomplete-status',
+ hostDirectives: [BrnAutocompleteStatus],
+ host: {
+ 'data-slot': 'autocomplete-status',
+ },
+})
+export class HlmAutocompleteStatus {
+ constructor() {
+ classes(() => 'text-muted-foreground flex w-full items-center justify-center gap-2 px-3 py-2 text-center text-sm');
+ }
+}
diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete.ts
new file mode 100644
index 00000000..4fdf5707
--- /dev/null
+++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete.ts
@@ -0,0 +1,47 @@
+import { Directive } from '@angular/core';
+import { BrnAutocomplete } from '@spartan-ng/brain/autocomplete';
+import { provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
+import { BrnPopover, provideBrnPopoverConfig } from '@spartan-ng/brain/popover';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAutocomplete],hlm-autocomplete',
+ providers: [
+ provideBrnPopoverConfig({
+ align: 'start',
+ sideOffset: 6,
+ }),
+ provideBrnDialogDefaultOptions({
+ autoFocus: 'first-heading',
+ }),
+ ],
+ hostDirectives: [
+ {
+ directive: BrnAutocomplete,
+ inputs: ['autoHighlight', 'disabled', 'value', 'search', 'itemToString', 'isItemEqualToValue'],
+ outputs: ['valueChange', 'searchChange'],
+ },
+ {
+ directive: BrnPopover,
+ inputs: [
+ 'align',
+ 'autoFocus',
+ 'closeDelay',
+ 'closeOnOutsidePointerEvents',
+ 'sideOffset',
+ 'state',
+ 'offsetX',
+ 'restoreFocus',
+ ],
+ outputs: ['stateChanged', 'closed'],
+ },
+ ],
+ host: {
+ 'data-slot': 'autocomplete',
+ },
+})
+export class HlmAutocomplete {
+ constructor() {
+ classes(() => 'block');
+ }
+}
diff --git a/libs/ui/avatar/src/index.ts b/libs/ui/avatar/src/index.ts
new file mode 100644
index 00000000..2ba99ee6
--- /dev/null
+++ b/libs/ui/avatar/src/index.ts
@@ -0,0 +1,22 @@
+import { HlmAvatar } from './lib/hlm-avatar';
+import { HlmAvatarBadge } from './lib/hlm-avatar-badge';
+import { HlmAvatarFallback } from './lib/hlm-avatar-fallback';
+import { HlmAvatarGroup } from './lib/hlm-avatar-group';
+import { HlmAvatarGroupCount } from './lib/hlm-avatar-group-count';
+import { HlmAvatarImage } from './lib/hlm-avatar-image';
+
+export * from './lib/hlm-avatar';
+export * from './lib/hlm-avatar-badge';
+export * from './lib/hlm-avatar-fallback';
+export * from './lib/hlm-avatar-group';
+export * from './lib/hlm-avatar-group-count';
+export * from './lib/hlm-avatar-image';
+
+export const HlmAvatarImports = [
+ HlmAvatar,
+ HlmAvatarBadge,
+ HlmAvatarFallback,
+ HlmAvatarGroup,
+ HlmAvatarGroupCount,
+ HlmAvatarImage,
+] as const;
diff --git a/libs/ui/avatar/src/lib/hlm-avatar-badge.ts b/libs/ui/avatar/src/lib/hlm-avatar-badge.ts
new file mode 100644
index 00000000..ebb6853e
--- /dev/null
+++ b/libs/ui/avatar/src/lib/hlm-avatar-badge.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAvatarBadge],hlm-avatar-badge',
+ host: {
+ 'data-slot': 'avatar-badge',
+ },
+})
+export class HlmAvatarBadge {
+ constructor() {
+ classes(() => [
+ 'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
+ 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>ng-icon]:hidden',
+ 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>ng-icon]:text-[0.5rem]',
+ 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>ng-icon]:text-[0.5rem]',
+ ]);
+ }
+}
diff --git a/libs/ui/avatar/src/lib/hlm-avatar-fallback.ts b/libs/ui/avatar/src/lib/hlm-avatar-fallback.ts
new file mode 100644
index 00000000..f9c8330d
--- /dev/null
+++ b/libs/ui/avatar/src/lib/hlm-avatar-fallback.ts
@@ -0,0 +1,20 @@
+import { Directive } from '@angular/core';
+import { BrnAvatarFallback } from '@spartan-ng/brain/avatar';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAvatarFallback]',
+ exportAs: 'avatarFallback',
+ hostDirectives: [BrnAvatarFallback],
+ host: {
+ 'data-slot': 'avatar-fallback',
+ },
+})
+export class HlmAvatarFallback {
+ constructor() {
+ classes(
+ () =>
+ 'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
+ );
+ }
+}
diff --git a/libs/ui/avatar/src/lib/hlm-avatar-group-count.ts b/libs/ui/avatar/src/lib/hlm-avatar-group-count.ts
new file mode 100644
index 00000000..b2f319ef
--- /dev/null
+++ b/libs/ui/avatar/src/lib/hlm-avatar-group-count.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAvatarGroupCount],hlm-avatar-group-count',
+ host: {
+ 'data-slot': 'avatar-group-count',
+ },
+})
+export class HlmAvatarGroupCount {
+ constructor() {
+ classes(
+ () =>
+ 'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>ng-icon]:text-base group-has-data-[size=lg]/avatar-group:[&>ng-icon]:text-xl group-has-data-[size=sm]/avatar-group:[&>ng-icon]:text-xs',
+ );
+ }
+}
diff --git a/libs/ui/avatar/src/lib/hlm-avatar-group.ts b/libs/ui/avatar/src/lib/hlm-avatar-group.ts
new file mode 100644
index 00000000..27a79f07
--- /dev/null
+++ b/libs/ui/avatar/src/lib/hlm-avatar-group.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmAvatarGroup],hlm-avatar-group',
+ host: {
+ 'data-slot': 'avatar-group',
+ },
+})
+export class HlmAvatarGroup {
+ constructor() {
+ classes(
+ () => '*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2',
+ );
+ }
+}
diff --git a/libs/ui/avatar/src/lib/hlm-avatar-image.ts b/libs/ui/avatar/src/lib/hlm-avatar-image.ts
new file mode 100644
index 00000000..9af226b0
--- /dev/null
+++ b/libs/ui/avatar/src/lib/hlm-avatar-image.ts
@@ -0,0 +1,19 @@
+import { Directive, inject } from '@angular/core';
+import { BrnAvatarImage } from '@spartan-ng/brain/avatar';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'img[hlmAvatarImage]',
+ exportAs: 'avatarImage',
+ hostDirectives: [BrnAvatarImage],
+ host: {
+ 'data-slot': 'avatar-image',
+ },
+})
+export class HlmAvatarImage {
+ public readonly canShow = inject(BrnAvatarImage).canShow;
+
+ constructor() {
+ classes(() => 'aspect-square size-full rounded-full object-cover');
+ }
+}
diff --git a/libs/ui/avatar/src/lib/hlm-avatar.ts b/libs/ui/avatar/src/lib/hlm-avatar.ts
new file mode 100644
index 00000000..58ba196f
--- /dev/null
+++ b/libs/ui/avatar/src/lib/hlm-avatar.ts
@@ -0,0 +1,31 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { BrnAvatar } from '@spartan-ng/brain/avatar';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-avatar',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-slot': 'avatar',
+ '[attr.data-size]': 'size()',
+ },
+ template: `
+ @if (_image()?.canShow()) {
+
+ } @else {
+
+ }
+
+ `,
+})
+export class HlmAvatar extends BrnAvatar {
+ public readonly size = input<'default' | 'sm' | 'lg'>('default');
+
+ constructor() {
+ super();
+ classes(
+ () =>
+ 'after:border-border group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
+ );
+ }
+}
diff --git a/libs/ui/badge/src/index.ts b/libs/ui/badge/src/index.ts
new file mode 100644
index 00000000..19293cf8
--- /dev/null
+++ b/libs/ui/badge/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmBadge } from './lib/hlm-badge';
+
+export * from './lib/hlm-badge';
+
+export const HlmBadgeImports = [HlmBadge] as const;
diff --git a/libs/ui/badge/src/lib/hlm-badge.ts b/libs/ui/badge/src/lib/hlm-badge.ts
new file mode 100644
index 00000000..f204e9e8
--- /dev/null
+++ b/libs/ui/badge/src/lib/hlm-badge.ts
@@ -0,0 +1,40 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+const badgeVariants = cva(
+ 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>ng-icon]:pointer-events-none [&>ng-icon]:text-xs',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
+ secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
+ destructive:
+ 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
+ outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
+ ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+export type BadgeVariants = VariantProps;
+
+@Directive({
+ selector: '[hlmBadge],hlm-badge',
+ host: {
+ 'data-slot': 'badge',
+ '[attr.data-variant]': 'variant()',
+ },
+})
+export class HlmBadge {
+ public readonly variant = input('default');
+
+ constructor() {
+ classes(() => badgeVariants({ variant: this.variant() }));
+ }
+}
diff --git a/libs/ui/breadcrumb/src/index.ts b/libs/ui/breadcrumb/src/index.ts
new file mode 100644
index 00000000..ac648fed
--- /dev/null
+++ b/libs/ui/breadcrumb/src/index.ts
@@ -0,0 +1,28 @@
+import { HlmBreadcrumb } from './lib/hlm-breadcrumb';
+import { HlmBreadcrumbButton } from './lib/hlm-breadcrumb-button';
+import { HlmBreadcrumbEllipsis } from './lib/hlm-breadcrumb-ellipsis';
+import { HlmBreadcrumbItem } from './lib/hlm-breadcrumb-item';
+import { HlmBreadcrumbLink } from './lib/hlm-breadcrumb-link';
+import { HlmBreadcrumbList } from './lib/hlm-breadcrumb-list';
+import { HlmBreadcrumbPage } from './lib/hlm-breadcrumb-page';
+import { HlmBreadcrumbSeparator } from './lib/hlm-breadcrumb-separator';
+
+export * from './lib/hlm-breadcrumb';
+export * from './lib/hlm-breadcrumb-button';
+export * from './lib/hlm-breadcrumb-ellipsis';
+export * from './lib/hlm-breadcrumb-item';
+export * from './lib/hlm-breadcrumb-link';
+export * from './lib/hlm-breadcrumb-list';
+export * from './lib/hlm-breadcrumb-page';
+export * from './lib/hlm-breadcrumb-separator';
+
+export const HlmBreadCrumbImports = [
+ HlmBreadcrumb,
+ HlmBreadcrumbButton,
+ HlmBreadcrumbEllipsis,
+ HlmBreadcrumbSeparator,
+ HlmBreadcrumbItem,
+ HlmBreadcrumbLink,
+ HlmBreadcrumbPage,
+ HlmBreadcrumbList,
+] as const;
diff --git a/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-button.ts b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-button.ts
new file mode 100644
index 00000000..2b40fb83
--- /dev/null
+++ b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-button.ts
@@ -0,0 +1,17 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+
+@Directive({
+ selector: '[hlmBreadcrumbButton]',
+ host: {
+ '[class]': '_computedClass()',
+ },
+})
+export class HlmBreadcrumbButton {
+ public readonly userClass = input('', { alias: 'class' });
+
+ protected readonly _computedClass = computed(() =>
+ hlm('hover:text-foreground transition-colors cursor-pointer inline-flex items-center', this.userClass()),
+ );
+}
diff --git a/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-ellipsis.ts b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-ellipsis.ts
new file mode 100644
index 00000000..13eef8e2
--- /dev/null
+++ b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-ellipsis.ts
@@ -0,0 +1,26 @@
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideEllipsis } from '@ng-icons/lucide';
+import { HlmIcon } from '@spartan-ng/helm/icon';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+
+@Component({
+ selector: 'hlm-breadcrumb-ellipsis',
+ imports: [NgIcon, HlmIcon],
+ providers: [provideIcons({ lucideEllipsis })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+ {{ srOnlyText() }}
+
+ `,
+})
+export class HlmBreadcrumbEllipsis {
+ public readonly userClass = input('', { alias: 'class' });
+ /** Screen reader only text for the ellipsis */
+ public readonly srOnlyText = input('More');
+
+ protected readonly _computedClass = computed(() => hlm('flex size-9 items-center justify-center', this.userClass()));
+}
diff --git a/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-item.ts b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-item.ts
new file mode 100644
index 00000000..61ef0256
--- /dev/null
+++ b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-item.ts
@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmBreadcrumbItem]',
+})
+export class HlmBreadcrumbItem {
+ constructor() {
+ classes(() => 'inline-flex items-center gap-1.5');
+ }
+}
diff --git a/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-link.ts b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-link.ts
new file mode 100644
index 00000000..fba8a354
--- /dev/null
+++ b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-link.ts
@@ -0,0 +1,33 @@
+import { Directive, input } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmBreadcrumbLink]',
+ hostDirectives: [
+ {
+ directive: RouterLink,
+ inputs: [
+ 'target',
+ 'queryParams',
+ 'fragment',
+ 'queryParamsHandling',
+ 'state',
+ 'info',
+ 'relativeTo',
+ 'preserveFragment',
+ 'skipLocationChange',
+ 'replaceUrl',
+ 'routerLink: link',
+ ],
+ },
+ ],
+})
+export class HlmBreadcrumbLink {
+ constructor() {
+ classes(() => 'hover:text-foreground transition-colors');
+ }
+
+ /** The link to navigate to the page. */
+ public readonly link = input();
+}
diff --git a/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-list.ts b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-list.ts
new file mode 100644
index 00000000..f8782b70
--- /dev/null
+++ b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-list.ts
@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmBreadcrumbList]',
+})
+export class HlmBreadcrumbList {
+ constructor() {
+ classes(() => 'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5');
+ }
+}
diff --git a/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-page.ts b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-page.ts
new file mode 100644
index 00000000..ff8bcd60
--- /dev/null
+++ b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-page.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmBreadcrumbPage]',
+ host: {
+ role: 'link',
+ 'aria-disabled': 'true',
+ 'aria-current': 'page',
+ },
+})
+export class HlmBreadcrumbPage {
+ constructor() {
+ classes(() => 'text-foreground font-normal');
+ }
+}
diff --git a/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-separator.ts b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-separator.ts
new file mode 100644
index 00000000..7db376b1
--- /dev/null
+++ b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb-separator.ts
@@ -0,0 +1,26 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronRight } from '@ng-icons/lucide';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ // eslint-disable-next-line @angular-eslint/component-selector
+ selector: '[hlmBreadcrumbSeparator]',
+ imports: [NgIcon],
+ providers: [provideIcons({ lucideChevronRight })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ role: 'presentation',
+ 'aria-hidden': 'true',
+ },
+ template: `
+
+
+
+ `,
+})
+export class HlmBreadcrumbSeparator {
+ constructor() {
+ classes(() => '[&_ng-icon]:block [&_ng-icon]:size-3.5');
+ }
+}
diff --git a/libs/ui/breadcrumb/src/lib/hlm-breadcrumb.ts b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb.ts
new file mode 100644
index 00000000..e0031eab
--- /dev/null
+++ b/libs/ui/breadcrumb/src/lib/hlm-breadcrumb.ts
@@ -0,0 +1,12 @@
+import { Directive, input } from '@angular/core';
+
+@Directive({
+ selector: '[hlmBreadcrumb]',
+ host: {
+ role: 'navigation',
+ '[attr.aria-label]': 'ariaLabel()',
+ },
+})
+export class HlmBreadcrumb {
+ public readonly ariaLabel = input('breadcrumb', { alias: 'aria-label' });
+}
diff --git a/libs/ui/button-group/src/index.ts b/libs/ui/button-group/src/index.ts
new file mode 100644
index 00000000..1ff2b8d8
--- /dev/null
+++ b/libs/ui/button-group/src/index.ts
@@ -0,0 +1,9 @@
+import { HlmButtonGroup } from './lib/hlm-button-group';
+import { HlmButtonGroupSeparator } from './lib/hlm-button-group-separator';
+import { HlmButtonGroupText } from './lib/hlm-button-group-text';
+
+export * from './lib/hlm-button-group';
+export * from './lib/hlm-button-group-separator';
+export * from './lib/hlm-button-group-text';
+
+export const HlmButtonGroupImports = [HlmButtonGroup, HlmButtonGroupText, HlmButtonGroupSeparator] as const;
diff --git a/libs/ui/button-group/src/lib/hlm-button-group-separator.ts b/libs/ui/button-group/src/lib/hlm-button-group-separator.ts
new file mode 100644
index 00000000..a7880eed
--- /dev/null
+++ b/libs/ui/button-group/src/lib/hlm-button-group-separator.ts
@@ -0,0 +1,20 @@
+import { Directive } from '@angular/core';
+import { BrnSeparator, provideBrnSeparatorConfig } from '@spartan-ng/brain/separator';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmButtonGroupSeparator],hlm-button-group-separator',
+ providers: [provideBrnSeparatorConfig({ orientation: 'vertical' })],
+ hostDirectives: [{ directive: BrnSeparator, inputs: ['orientation', 'decorative'] }],
+ host: {
+ 'data-slot': 'button-group-separator',
+ },
+})
+export class HlmButtonGroupSeparator {
+ constructor() {
+ classes(
+ () =>
+ 'bg-input relative inline-flex shrink-0 self-stretch data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-auto data-[orientation=vertical]:w-px',
+ );
+ }
+}
diff --git a/libs/ui/button-group/src/lib/hlm-button-group-text.ts b/libs/ui/button-group/src/lib/hlm-button-group-text.ts
new file mode 100644
index 00000000..93f7e8e8
--- /dev/null
+++ b/libs/ui/button-group/src/lib/hlm-button-group-text.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmButtonGroupText],hlm-button-group-text',
+})
+export class HlmButtonGroupText {
+ constructor() {
+ classes(
+ () =>
+ "bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_ng-icon]:pointer-events-none [&_ng-icon:not([class*='text-'])]:text-base",
+ );
+ }
+}
diff --git a/libs/ui/button-group/src/lib/hlm-button-group.ts b/libs/ui/button-group/src/lib/hlm-button-group.ts
new file mode 100644
index 00000000..2ebfe36b
--- /dev/null
+++ b/libs/ui/button-group/src/lib/hlm-button-group.ts
@@ -0,0 +1,36 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva } from 'class-variance-authority';
+
+export const buttonGroupVariants = cva(
+ "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
+ vertical:
+ 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ },
+);
+
+@Directive({
+ selector: '[hlmButtonGroup],hlm-button-group',
+ host: {
+ 'data-slot': 'button-group',
+ role: 'group',
+ '[attr.data-orientation]': 'orientation()',
+ },
+})
+export class HlmButtonGroup {
+ constructor() {
+ classes(() => buttonGroupVariants({ orientation: this.orientation() }));
+ }
+
+ public readonly orientation = input<'horizontal' | 'vertical'>('horizontal');
+}
diff --git a/libs/ui/button/src/index.ts b/libs/ui/button/src/index.ts
new file mode 100644
index 00000000..6d76e3f7
--- /dev/null
+++ b/libs/ui/button/src/index.ts
@@ -0,0 +1,6 @@
+import { HlmButton } from './lib/hlm-button';
+
+export * from './lib/hlm-button';
+export * from './lib/hlm-button.token';
+
+export const HlmButtonImports = [HlmButton] as const;
diff --git a/libs/ui/button/src/lib/hlm-button.token.ts b/libs/ui/button/src/lib/hlm-button.token.ts
new file mode 100644
index 00000000..7314ea27
--- /dev/null
+++ b/libs/ui/button/src/lib/hlm-button.token.ts
@@ -0,0 +1,22 @@
+import { InjectionToken, type ValueProvider, inject } from '@angular/core';
+import type { ButtonVariants } from './hlm-button';
+
+export interface BrnButtonConfig {
+ variant: ButtonVariants['variant'];
+ size: ButtonVariants['size'];
+}
+
+const defaultConfig: BrnButtonConfig = {
+ variant: 'default',
+ size: 'default',
+};
+
+const BrnButtonConfigToken = new InjectionToken('BrnButtonConfig');
+
+export function provideBrnButtonConfig(config: Partial): ValueProvider {
+ return { provide: BrnButtonConfigToken, useValue: { ...defaultConfig, ...config } };
+}
+
+export function injectBrnButtonConfig(): BrnButtonConfig {
+ return inject(BrnButtonConfigToken, { optional: true }) ?? defaultConfig;
+}
diff --git a/libs/ui/button/src/lib/hlm-button.ts b/libs/ui/button/src/lib/hlm-button.ts
new file mode 100644
index 00000000..7da72191
--- /dev/null
+++ b/libs/ui/button/src/lib/hlm-button.ts
@@ -0,0 +1,66 @@
+import { Directive, input, signal } from '@angular/core';
+import { BrnButton } from '@spartan-ng/brain/button';
+import { classes } from '@spartan-ng/helm/utils';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+import { injectBrnButtonConfig } from './hlm-button.token';
+
+export const buttonVariants = cva(
+ "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0 [&_ng-icon:not([class*='text-'])]:text-base",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
+ outline:
+ 'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>ng-icon]:px-3',
+ xs: `h-6 gap-1 rounded-md px-2 text-xs has-[>ng-icon]:px-1.5 [&_ng-icon:not([class*='text-'])]:text-xs`,
+ sm: 'h-8 gap-1.5 rounded-md px-3 has-[>ng-icon]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>ng-icon]:px-4',
+ icon: 'size-9',
+ 'icon-xs': `size-6 rounded-md [&_ng-icon:not([class*='text-'])]:text-xs`,
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+export type ButtonVariants = VariantProps;
+
+@Directive({
+ selector: 'button[hlmBtn], a[hlmBtn]',
+ exportAs: 'hlmBtn',
+ hostDirectives: [{ directive: BrnButton, inputs: ['disabled'] }],
+ host: {
+ 'data-slot': 'button',
+ },
+})
+export class HlmButton {
+ private readonly _config = injectBrnButtonConfig();
+
+ private readonly _additionalClasses = signal('');
+
+ public readonly variant = input(this._config.variant);
+
+ public readonly size = input(this._config.size);
+
+ constructor() {
+ classes(() => [buttonVariants({ variant: this.variant(), size: this.size() }), this._additionalClasses()]);
+ }
+
+ setClass(classes: string): void {
+ this._additionalClasses.set(classes);
+ }
+}
diff --git a/libs/ui/card/src/index.ts b/libs/ui/card/src/index.ts
new file mode 100644
index 00000000..e3732fe6
--- /dev/null
+++ b/libs/ui/card/src/index.ts
@@ -0,0 +1,25 @@
+import { HlmCard } from './lib/hlm-card';
+import { HlmCardAction } from './lib/hlm-card-action';
+import { HlmCardContent } from './lib/hlm-card-content';
+import { HlmCardDescription } from './lib/hlm-card-description';
+import { HlmCardFooter } from './lib/hlm-card-footer';
+import { HlmCardHeader } from './lib/hlm-card-header';
+import { HlmCardTitle } from './lib/hlm-card-title';
+
+export * from './lib/hlm-card';
+export * from './lib/hlm-card-action';
+export * from './lib/hlm-card-content';
+export * from './lib/hlm-card-description';
+export * from './lib/hlm-card-footer';
+export * from './lib/hlm-card-header';
+export * from './lib/hlm-card-title';
+
+export const HlmCardImports = [
+ HlmCard,
+ HlmCardHeader,
+ HlmCardFooter,
+ HlmCardTitle,
+ HlmCardDescription,
+ HlmCardContent,
+ HlmCardAction,
+] as const;
diff --git a/libs/ui/card/src/lib/hlm-card-action.ts b/libs/ui/card/src/lib/hlm-card-action.ts
new file mode 100644
index 00000000..ad9f8ffa
--- /dev/null
+++ b/libs/ui/card/src/lib/hlm-card-action.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCardAction]',
+ host: {
+ 'data-slot': 'card-action',
+ },
+})
+export class HlmCardAction {
+ constructor() {
+ classes(() => 'col-start-2 row-span-2 row-start-1 self-start justify-self-end');
+ }
+}
diff --git a/libs/ui/card/src/lib/hlm-card-content.ts b/libs/ui/card/src/lib/hlm-card-content.ts
new file mode 100644
index 00000000..c641871c
--- /dev/null
+++ b/libs/ui/card/src/lib/hlm-card-content.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCardContent]',
+ host: {
+ 'data-slot': 'card-content',
+ },
+})
+export class HlmCardContent {
+ constructor() {
+ classes(() => 'px-6 group-data-[size=sm]/card:px-4');
+ }
+}
diff --git a/libs/ui/card/src/lib/hlm-card-description.ts b/libs/ui/card/src/lib/hlm-card-description.ts
new file mode 100644
index 00000000..8fdc32d8
--- /dev/null
+++ b/libs/ui/card/src/lib/hlm-card-description.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCardDescription]',
+ host: {
+ 'data-slot': 'card-description',
+ },
+})
+export class HlmCardDescription {
+ constructor() {
+ classes(() => 'text-muted-foreground text-sm');
+ }
+}
diff --git a/libs/ui/card/src/lib/hlm-card-footer.ts b/libs/ui/card/src/lib/hlm-card-footer.ts
new file mode 100644
index 00000000..beae0457
--- /dev/null
+++ b/libs/ui/card/src/lib/hlm-card-footer.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCardFooter],hlm-card-footer',
+ host: {
+ 'data-slot': 'card-footer',
+ },
+})
+export class HlmCardFooter {
+ constructor() {
+ classes(
+ () =>
+ 'flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4',
+ );
+ }
+}
diff --git a/libs/ui/card/src/lib/hlm-card-header.ts b/libs/ui/card/src/lib/hlm-card-header.ts
new file mode 100644
index 00000000..84df635b
--- /dev/null
+++ b/libs/ui/card/src/lib/hlm-card-header.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCardHeader],hlm-card-header',
+ host: {
+ 'data-slot': 'card-header',
+ },
+})
+export class HlmCardHeader {
+ constructor() {
+ classes(
+ () =>
+ `group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4`,
+ );
+ }
+}
diff --git a/libs/ui/card/src/lib/hlm-card-title.ts b/libs/ui/card/src/lib/hlm-card-title.ts
new file mode 100644
index 00000000..91f94a0f
--- /dev/null
+++ b/libs/ui/card/src/lib/hlm-card-title.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCardTitle]',
+ host: {
+ 'data-slot': 'card-title',
+ },
+})
+export class HlmCardTitle {
+ constructor() {
+ classes(() => 'text-base leading-normal font-medium group-data-[size=sm]/card:text-sm');
+ }
+}
diff --git a/libs/ui/card/src/lib/hlm-card.ts b/libs/ui/card/src/lib/hlm-card.ts
new file mode 100644
index 00000000..0d6ac758
--- /dev/null
+++ b/libs/ui/card/src/lib/hlm-card.ts
@@ -0,0 +1,20 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCard],hlm-card',
+ host: {
+ 'data-slot': 'card',
+ '[attr.data-size]': 'size()',
+ },
+})
+export class HlmCard {
+ public readonly size = input<'sm' | 'default'>('default');
+
+ constructor() {
+ classes(
+ () =>
+ 'group/card ring-foreground/10 bg-card text-card-foreground flex flex-col gap-6 overflow-hidden rounded-xl py-6 text-sm shadow-xs ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
+ );
+ }
+}
diff --git a/libs/ui/checkbox/src/index.ts b/libs/ui/checkbox/src/index.ts
new file mode 100644
index 00000000..f2e924ca
--- /dev/null
+++ b/libs/ui/checkbox/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmCheckbox } from './lib/hlm-checkbox';
+
+export * from './lib/hlm-checkbox';
+
+export const HlmCheckboxImports = [HlmCheckbox] as const;
diff --git a/libs/ui/checkbox/src/lib/hlm-checkbox.ts b/libs/ui/checkbox/src/lib/hlm-checkbox.ts
new file mode 100644
index 00000000..f65d8791
--- /dev/null
+++ b/libs/ui/checkbox/src/lib/hlm-checkbox.ts
@@ -0,0 +1,138 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ forwardRef,
+ input,
+ linkedSignal,
+ model,
+ output,
+} from '@angular/core';
+import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideCheck } from '@ng-icons/lucide';
+import { BrnCheckbox } from '@spartan-ng/brain/checkbox';
+import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
+import { HlmIcon } from '@spartan-ng/helm/icon';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+
+export const HLM_CHECKBOX_VALUE_ACCESSOR = {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => HlmCheckbox),
+ multi: true,
+};
+
+@Component({
+ selector: 'hlm-checkbox',
+ imports: [BrnCheckbox, NgIcon, HlmIcon],
+ providers: [HLM_CHECKBOX_VALUE_ACCESSOR],
+ viewProviders: [provideIcons({ lucideCheck })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ class: 'contents peer',
+ 'data-slot': 'checkbox',
+ '[attr.id]': 'null',
+ '[attr.aria-label]': 'null',
+ '[attr.aria-labelledby]': 'null',
+ '[attr.aria-describedby]': 'null',
+ '[attr.data-disabled]': '_disabled() ? "" : null',
+ },
+ template: `
+
+ @if (checked() || indeterminate()) {
+
+
+
+ }
+
+ `,
+})
+export class HlmCheckbox implements ControlValueAccessor {
+ public readonly userClass = input('', { alias: 'class' });
+
+ protected readonly _computedClass = computed(() =>
+ hlm(
+ 'border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer size-4 shrink-0 cursor-default rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
+ this.userClass(),
+ this._disabled() ? 'cursor-not-allowed opacity-50' : '',
+ ),
+ );
+
+ /** Used to set the id on the underlying brn element. */
+ public readonly id = input(null);
+
+ /** Used to set the aria-label attribute on the underlying brn element. */
+ public readonly ariaLabel = input(null, { alias: 'aria-label' });
+
+ /** Used to set the aria-labelledby attribute on the underlying brn element. */
+ public readonly ariaLabelledby = input(null, { alias: 'aria-labelledby' });
+
+ /** Used to set the aria-describedby attribute on the underlying brn element. */
+ public readonly ariaDescribedby = input(null, { alias: 'aria-describedby' });
+
+ /** The checked state of the checkbox. */
+ public readonly checked = model(false);
+
+ /** Emits when checked state changes. */
+ public readonly checkedChange = output();
+
+ /**
+ * The indeterminate state of the checkbox.
+ * For example, a "select all/deselect all" checkbox may be in the indeterminate state when some but not all of its sub-controls are checked.
+ */
+ public readonly indeterminate = model(false);
+
+ /** The name attribute of the checkbox. */
+ public readonly name = input(null);
+
+ /** Whether the checkbox is required. */
+ public readonly required = input(false, { transform: booleanAttribute });
+
+ /** Whether the checkbox is disabled. */
+ public readonly disabled = input(false, { transform: booleanAttribute });
+
+ protected readonly _disabled = linkedSignal(this.disabled);
+
+ protected _onChange?: ChangeFn;
+ protected _onTouched?: TouchFn;
+
+ protected _handleChange(value: boolean): void {
+ if (this._disabled()) return;
+ this.checked.set(value);
+ this.checkedChange.emit(value);
+ this._onChange?.(value);
+ }
+
+ /** CONTROL VALUE ACCESSOR */
+ writeValue(value: boolean): void {
+ this.checked.set(value);
+ }
+
+ registerOnChange(fn: ChangeFn): void {
+ this._onChange = fn;
+ }
+
+ registerOnTouched(fn: TouchFn): void {
+ this._onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this._disabled.set(isDisabled);
+ }
+}
diff --git a/libs/ui/combobox/src/index.ts b/libs/ui/combobox/src/index.ts
new file mode 100644
index 00000000..0c26eeaa
--- /dev/null
+++ b/libs/ui/combobox/src/index.ts
@@ -0,0 +1,58 @@
+import { HlmCombobox } from './lib/hlm-combobox';
+import { HlmComboboxChip } from './lib/hlm-combobox-chip';
+import { HlmComboboxChipInput } from './lib/hlm-combobox-chip-input';
+import { HlmComboboxChips } from './lib/hlm-combobox-chips';
+import { HlmComboboxContent } from './lib/hlm-combobox-content';
+import { HlmComboboxEmpty } from './lib/hlm-combobox-empty';
+import { HlmComboboxGroup } from './lib/hlm-combobox-group';
+import { HlmComboboxInput } from './lib/hlm-combobox-input';
+import { HlmComboboxItem } from './lib/hlm-combobox-item';
+import { HlmComboboxLabel } from './lib/hlm-combobox-label';
+import { HlmComboboxList } from './lib/hlm-combobox-list';
+import { HlmComboboxMultiple } from './lib/hlm-combobox-multiple';
+import { HlmComboboxPortal } from './lib/hlm-combobox-portal';
+import { HlmComboboxSeparator } from './lib/hlm-combobox-separator';
+import { HlmComboboxStatus } from './lib/hlm-combobox-status';
+import { HlmComboboxTrigger } from './lib/hlm-combobox-trigger';
+import { HlmComboboxValue } from './lib/hlm-combobox-value';
+import { HlmComboboxValues } from './lib/hlm-combobox-values';
+
+export * from './lib/hlm-combobox';
+export * from './lib/hlm-combobox-chip';
+export * from './lib/hlm-combobox-chip-input';
+export * from './lib/hlm-combobox-chips';
+export * from './lib/hlm-combobox-content';
+export * from './lib/hlm-combobox-empty';
+export * from './lib/hlm-combobox-group';
+export * from './lib/hlm-combobox-input';
+export * from './lib/hlm-combobox-item';
+export * from './lib/hlm-combobox-label';
+export * from './lib/hlm-combobox-list';
+export * from './lib/hlm-combobox-multiple';
+export * from './lib/hlm-combobox-portal';
+export * from './lib/hlm-combobox-separator';
+export * from './lib/hlm-combobox-status';
+export * from './lib/hlm-combobox-trigger';
+export * from './lib/hlm-combobox-value';
+export * from './lib/hlm-combobox-values';
+
+export const HlmComboboxImports = [
+ HlmCombobox,
+ HlmComboboxChip,
+ HlmComboboxChipInput,
+ HlmComboboxChips,
+ HlmComboboxContent,
+ HlmComboboxEmpty,
+ HlmComboboxGroup,
+ HlmComboboxInput,
+ HlmComboboxItem,
+ HlmComboboxLabel,
+ HlmComboboxList,
+ HlmComboboxMultiple,
+ HlmComboboxPortal,
+ HlmComboboxSeparator,
+ HlmComboboxStatus,
+ HlmComboboxTrigger,
+ HlmComboboxValue,
+ HlmComboboxValues,
+] as const;
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-chip-input.ts b/libs/ui/combobox/src/lib/hlm-combobox-chip-input.ts
new file mode 100644
index 00000000..7b08be7d
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-chip-input.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxChipInput } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'input[hlmComboboxChipInput]',
+ hostDirectives: [{ directive: BrnComboboxChipInput, inputs: ['id'] }],
+ host: {
+ 'data-slott': 'combobox-chip-input',
+ },
+})
+export class HlmComboboxChipInput {
+ constructor() {
+ classes(() => 'placeholder:text-muted-foreground min-w-16 flex-1 outline-none');
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-chip-remove.ts b/libs/ui/combobox/src/lib/hlm-combobox-chip-remove.ts
new file mode 100644
index 00000000..a9e5a4fe
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-chip-remove.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxChipRemove } from '@spartan-ng/brain/combobox';
+import { buttonVariants } from '@spartan-ng/helm/button';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'button[hlmComboboxChipRemove]',
+ hostDirectives: [BrnComboboxChipRemove],
+ host: {
+ 'data-slot': 'combobox-chip-remove',
+ },
+})
+export class HlmComboboxChipRemove {
+ constructor() {
+ classes(() => ['-ml-1 opacity-50 hover:opacity-100', buttonVariants({ variant: 'ghost', size: 'icon-xs' })]);
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-chip.ts b/libs/ui/combobox/src/lib/hlm-combobox-chip.ts
new file mode 100644
index 00000000..cea4b67c
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-chip.ts
@@ -0,0 +1,37 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideX } from '@ng-icons/lucide';
+import { BrnComboboxChip } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+import { HlmComboboxChipRemove } from './hlm-combobox-chip-remove';
+
+@Component({
+ selector: 'hlm-combobox-chip',
+ imports: [NgIcon, HlmComboboxChipRemove],
+ providers: [provideIcons({ lucideX })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [{ directive: BrnComboboxChip, inputs: ['value'] }],
+ host: {
+ 'data-slot': 'combobox-chip',
+ },
+ template: `
+
+
+ @if (showRemove()) {
+
+ }
+ `,
+})
+export class HlmComboboxChip {
+ public readonly showRemove = input(true, { transform: booleanAttribute });
+
+ constructor() {
+ classes(
+ () =>
+ 'bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0',
+ );
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-chips.ts b/libs/ui/combobox/src/lib/hlm-combobox-chips.ts
new file mode 100644
index 00000000..595f897b
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-chips.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxAnchor, BrnComboboxInputWrapper, BrnComboboxPopoverTrigger } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxChips],hlm-combobox-chips',
+ hostDirectives: [BrnComboboxInputWrapper, BrnComboboxAnchor, BrnComboboxPopoverTrigger],
+ host: {
+ 'data-slott': 'combobox-chips',
+ },
+})
+export class HlmComboboxChips {
+ constructor() {
+ classes(
+ () =>
+ 'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 has-data-[slot=combobox-chip]:px-1.5; flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px]',
+ );
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-content.ts b/libs/ui/combobox/src/lib/hlm-combobox-content.ts
new file mode 100644
index 00000000..1b032678
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-content.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxContent } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxContent],hlm-combobox-content',
+ hostDirectives: [BrnComboboxContent],
+})
+export class HlmComboboxContent {
+ constructor() {
+ classes(() => [
+ 'group/combobox-content bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex max-h-72 w-(--brn-combobox-width) min-w-36 flex-col overflow-hidden rounded-md p-0 shadow-md ring-1 duration-100',
+ // change input group styles in the content
+ '**:data-[slot=input-group]:bg-input **:data-[slot=input-group]:border-input/30 **:has-[[data-slot=input-group-control]:focus-visible]:border-input **:has-[[data-slot=input-group-control]:focus-visible]:ring-0 **:data-[slot=input-group]:m-1 **:data-[slot=input-group]:mb-0 **:data-[slot=input-group]:h-8 **:data-[slot=input-group]:shadow-none',
+ ]);
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-empty.ts b/libs/ui/combobox/src/lib/hlm-combobox-empty.ts
new file mode 100644
index 00000000..fe9747fc
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-empty.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxEmpty } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxEmpty],hlm-combobox-empty',
+ hostDirectives: [BrnComboboxEmpty],
+ host: {
+ 'data-slot': 'combobox-empty',
+ },
+})
+export class HlmComboboxEmpty {
+ constructor() {
+ classes(
+ () =>
+ 'text-muted-foreground hidden w-full items-center justify-center gap-2 py-2 text-center text-sm group-data-empty/combobox-content:flex',
+ );
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-group.ts b/libs/ui/combobox/src/lib/hlm-combobox-group.ts
new file mode 100644
index 00000000..5fcab4e8
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-group.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxGroup } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxGroup]',
+ hostDirectives: [BrnComboboxGroup],
+ host: {
+ 'data-slot': 'combobox-group',
+ },
+})
+export class HlmComboboxGroup {
+ constructor() {
+ classes(() => 'data-hidden:hidden');
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-input.ts b/libs/ui/combobox/src/lib/hlm-combobox-input.ts
new file mode 100644
index 00000000..5ccf8d82
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-input.ts
@@ -0,0 +1,69 @@
+import { BooleanInput } from '@angular/cdk/coercion';
+import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronDown, lucideX } from '@ng-icons/lucide';
+import { BrnComboboxImports, BrnComboboxInputWrapper, BrnComboboxPopoverTrigger } from '@spartan-ng/brain/combobox';
+import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
+
+@Component({
+ selector: 'hlm-combobox-input',
+ imports: [HlmInputGroupImports, NgIcon, BrnComboboxImports, BrnComboboxPopoverTrigger],
+ providers: [provideIcons({ lucideChevronDown, lucideX })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [BrnComboboxInputWrapper],
+ template: `
+
+
+
+
+ @if (showTrigger()) {
+
+ }
+
+ @if (showClear()) {
+
+ }
+
+
+
+
+ `,
+})
+export class HlmComboboxInput {
+ public readonly placeholder = input('');
+
+ public readonly showTrigger = input(true, { transform: booleanAttribute });
+ public readonly showClear = input(false, { transform: booleanAttribute });
+
+ // TODO input and input-group styles need to support aria-invalid directly
+ public readonly ariaInvalid = input(false, {
+ transform: booleanAttribute,
+ alias: 'aria-invalid',
+ });
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-item.ts b/libs/ui/combobox/src/lib/hlm-combobox-item.ts
new file mode 100644
index 00000000..42edb3b4
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-item.ts
@@ -0,0 +1,38 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideCheck } from '@ng-icons/lucide';
+import { BrnComboboxItem } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-combobox-item',
+ imports: [NgIcon],
+ providers: [provideIcons({ lucideCheck })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [{ directive: BrnComboboxItem, inputs: ['id', 'disabled', 'value'] }],
+ host: {
+ 'data-slot': 'combobox-item',
+ },
+ template: `
+
+ @if (_active()) {
+
+ }
+ `,
+})
+export class HlmComboboxItem {
+ private readonly _brnComboboxItem = inject(BrnComboboxItem);
+
+ protected readonly _active = this._brnComboboxItem.active;
+
+ constructor() {
+ classes(
+ () =>
+ `data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-hidden:hidden data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0 [&_ng-icon:not([class*='text-'])]:text-base`,
+ );
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-label.ts b/libs/ui/combobox/src/lib/hlm-combobox-label.ts
new file mode 100644
index 00000000..ed9db28b
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-label.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxLabel } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxLabel]',
+ hostDirectives: [{ directive: BrnComboboxLabel, inputs: ['id'] }],
+ host: {
+ 'data-slot': 'combobox-label',
+ },
+})
+export class HlmComboboxLabel {
+ constructor() {
+ classes(() => 'text-muted-foreground px-2 py-1.5 text-xs');
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-list.ts b/libs/ui/combobox/src/lib/hlm-combobox-list.ts
new file mode 100644
index 00000000..ba3aaec1
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-list.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxList } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxList]',
+ hostDirectives: [{ directive: BrnComboboxList, inputs: ['id'] }],
+ host: {
+ 'data-slot': 'combobox-list',
+ },
+})
+export class HlmComboboxList {
+ constructor() {
+ classes(
+ () =>
+ 'no-scrollbar max-h-[calc(--spacing(72)---spacing(9))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0',
+ );
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-multiple.ts b/libs/ui/combobox/src/lib/hlm-combobox-multiple.ts
new file mode 100644
index 00000000..80f4aecd
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-multiple.ts
@@ -0,0 +1,56 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxMultiple } from '@spartan-ng/brain/combobox';
+import { provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
+import { BrnPopover, provideBrnPopoverConfig } from '@spartan-ng/brain/popover';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxMultiple],hlm-combobox-multiple',
+ providers: [
+ provideBrnPopoverConfig({
+ align: 'start',
+ sideOffset: 6,
+ }),
+ provideBrnDialogDefaultOptions({
+ autoFocus: 'first-heading',
+ }),
+ ],
+ hostDirectives: [
+ {
+ directive: BrnComboboxMultiple,
+ inputs: [
+ 'autoHighlight',
+ 'disabled',
+ 'filter',
+ 'search',
+ 'value',
+ 'itemToString',
+ 'filterOptions',
+ 'isItemEqualToValue',
+ ],
+ outputs: ['searchChange', 'valueChange'],
+ },
+ {
+ directive: BrnPopover,
+ inputs: [
+ 'align',
+ 'autoFocus',
+ 'closeDelay',
+ 'closeOnOutsidePointerEvents',
+ 'sideOffset',
+ 'state',
+ 'offsetX',
+ 'restoreFocus',
+ ],
+ outputs: ['stateChanged', 'closed'],
+ },
+ ],
+ host: {
+ 'data-slot': 'combobox',
+ },
+})
+export class HlmComboboxMultiple {
+ constructor() {
+ classes(() => 'block');
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-portal.ts b/libs/ui/combobox/src/lib/hlm-combobox-portal.ts
new file mode 100644
index 00000000..2162393c
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-portal.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnPopoverContent } from '@spartan-ng/brain/popover';
+
+@Directive({
+ selector: '[hlmComboboxPortal]',
+ hostDirectives: [{ directive: BrnPopoverContent, inputs: ['context', 'class'] }],
+})
+export class HlmComboboxPortal {}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-separator.ts b/libs/ui/combobox/src/lib/hlm-combobox-separator.ts
new file mode 100644
index 00000000..6f885977
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-separator.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxSeparator } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxSeparator]',
+ hostDirectives: [{ directive: BrnComboboxSeparator, inputs: ['orientation'] }],
+ host: {
+ 'data-slot': 'combobox-separator',
+ },
+})
+export class HlmComboboxSeparator {
+ constructor() {
+ classes(() => 'bg-border -mx-1 my-1 h-px');
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-status.ts b/libs/ui/combobox/src/lib/hlm-combobox-status.ts
new file mode 100644
index 00000000..5885eea7
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-status.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxStatus } from '@spartan-ng/brain/combobox';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmComboboxStatus],hlm-combobox-status',
+ hostDirectives: [BrnComboboxStatus],
+ host: {
+ 'data-slot': 'combobox-status',
+ },
+})
+export class HlmComboboxStatus {
+ constructor() {
+ classes(() => 'text-muted-foreground flex w-full items-center justify-center gap-2 py-2 text-center text-sm');
+ }
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-trigger.ts b/libs/ui/combobox/src/lib/hlm-combobox-trigger.ts
new file mode 100644
index 00000000..775f7a21
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-trigger.ts
@@ -0,0 +1,42 @@
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronDown } from '@ng-icons/lucide';
+import {
+ BrnComboboxAnchor,
+ BrnComboboxInputWrapper,
+ BrnComboboxPopoverTrigger,
+ BrnComboboxTrigger,
+} from '@spartan-ng/brain/combobox';
+import { ButtonVariants, HlmButton } from '@spartan-ng/helm/button';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+
+@Component({
+ selector: 'hlm-combobox-trigger',
+ imports: [NgIcon, HlmButton, BrnComboboxAnchor, BrnComboboxTrigger, BrnComboboxPopoverTrigger],
+ providers: [provideIcons({ lucideChevronDown })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [BrnComboboxInputWrapper],
+ template: `
+
+ `,
+})
+export class HlmComboboxTrigger {
+ public readonly userClass = input('', {
+ alias: 'class',
+ });
+ protected readonly _computedClass = computed(() => hlm(this.userClass()));
+
+ public readonly variant = input('outline');
+}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-value.ts b/libs/ui/combobox/src/lib/hlm-combobox-value.ts
new file mode 100644
index 00000000..1ba65c95
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-value.ts
@@ -0,0 +1,5 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxValue } from '@spartan-ng/brain/combobox';
+
+@Directive({ selector: '[hlmComboboxValue]', hostDirectives: [BrnComboboxValue] })
+export class HlmComboboxValue {}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox-values.ts b/libs/ui/combobox/src/lib/hlm-combobox-values.ts
new file mode 100644
index 00000000..e90d9df4
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox-values.ts
@@ -0,0 +1,5 @@
+import { Directive } from '@angular/core';
+import { BrnComboboxValues } from '@spartan-ng/brain/combobox';
+
+@Directive({ selector: '[hlmComboboxValues]', hostDirectives: [BrnComboboxValues] })
+export class HlmComboboxValues {}
diff --git a/libs/ui/combobox/src/lib/hlm-combobox.ts b/libs/ui/combobox/src/lib/hlm-combobox.ts
new file mode 100644
index 00000000..0d47394f
--- /dev/null
+++ b/libs/ui/combobox/src/lib/hlm-combobox.ts
@@ -0,0 +1,56 @@
+import { Directive } from '@angular/core';
+import { BrnCombobox } from '@spartan-ng/brain/combobox';
+import { provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
+import { BrnPopover, provideBrnPopoverConfig } from '@spartan-ng/brain/popover';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCombobox],hlm-combobox',
+ providers: [
+ provideBrnPopoverConfig({
+ align: 'start',
+ sideOffset: 6,
+ }),
+ provideBrnDialogDefaultOptions({
+ autoFocus: 'first-heading',
+ }),
+ ],
+ hostDirectives: [
+ {
+ directive: BrnCombobox,
+ inputs: [
+ 'autoHighlight',
+ 'disabled',
+ 'filter',
+ 'search',
+ 'value',
+ 'itemToString',
+ 'filterOptions',
+ 'isItemEqualToValue',
+ ],
+ outputs: ['searchChange', 'valueChange'],
+ },
+ {
+ directive: BrnPopover,
+ inputs: [
+ 'align',
+ 'autoFocus',
+ 'closeDelay',
+ 'closeOnOutsidePointerEvents',
+ 'sideOffset',
+ 'state',
+ 'offsetX',
+ 'restoreFocus',
+ ],
+ outputs: ['stateChanged', 'closed'],
+ },
+ ],
+ host: {
+ 'data-slot': 'combobox',
+ },
+})
+export class HlmCombobox {
+ constructor() {
+ classes(() => 'block');
+ }
+}
diff --git a/libs/ui/command/src/index.ts b/libs/ui/command/src/index.ts
new file mode 100644
index 00000000..a430c14a
--- /dev/null
+++ b/libs/ui/command/src/index.ts
@@ -0,0 +1,37 @@
+import { HlmCommand } from './lib/hlm-command';
+import { HlmCommandDialog } from './lib/hlm-command-dialog';
+import { HlmCommandEmpty } from './lib/hlm-command-empty';
+import { HlmCommandEmptyState } from './lib/hlm-command-empty-state';
+import { HlmCommandGroup } from './lib/hlm-command-group';
+import { HlmCommandGroupLabel } from './lib/hlm-command-group-label';
+import { HlmCommandInput } from './lib/hlm-command-input';
+import { HlmCommandItem } from './lib/hlm-command-item';
+import { HlmCommandList } from './lib/hlm-command-list';
+import { HlmCommandSeparator } from './lib/hlm-command-separator';
+import { HlmCommandShortcut } from './lib/hlm-command-shortcut';
+
+export * from './lib/hlm-command';
+export * from './lib/hlm-command-dialog';
+export * from './lib/hlm-command-empty';
+export * from './lib/hlm-command-empty-state';
+export * from './lib/hlm-command-group';
+export * from './lib/hlm-command-group-label';
+export * from './lib/hlm-command-input';
+export * from './lib/hlm-command-item';
+export * from './lib/hlm-command-list';
+export * from './lib/hlm-command-separator';
+export * from './lib/hlm-command-shortcut';
+
+export const HlmCommandImports = [
+ HlmCommand,
+ HlmCommandDialog,
+ HlmCommandEmpty,
+ HlmCommandEmptyState,
+ HlmCommandGroup,
+ HlmCommandGroupLabel,
+ HlmCommandInput,
+ HlmCommandItem,
+ HlmCommandList,
+ HlmCommandSeparator,
+ HlmCommandShortcut,
+] as const;
diff --git a/libs/ui/command/src/lib/hlm-command-dialog.ts b/libs/ui/command/src/lib/hlm-command-dialog.ts
new file mode 100644
index 00000000..251a5ba4
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-dialog.ts
@@ -0,0 +1,54 @@
+import { BooleanInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ linkedSignal,
+ output,
+} from '@angular/core';
+import { BrnDialogContent, BrnDialogState } from '@spartan-ng/brain/dialog';
+import { HlmDialogImports } from '@spartan-ng/helm/dialog';
+import { hlm } from '@spartan-ng/helm/utils';
+import { ClassValue } from 'clsx';
+
+@Component({
+ selector: 'hlm-command-dialog',
+ imports: [HlmDialogImports, BrnDialogContent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+ {{ title() }}
+ {{ description() }}
+
+
+
+
+ `,
+})
+export class HlmCommandDialog {
+ public readonly title = input('Command Palette');
+ public readonly description = input('Search for a command to run...');
+
+ public readonly state = input('closed');
+ protected readonly _state = linkedSignal(this.state);
+
+ public readonly showCloseButton = input(false, { transform: booleanAttribute });
+
+ public readonly dialogContentClass = input('');
+ protected readonly _computedDialogContentClass = computed(() => hlm('w-96 p-0', this.dialogContentClass()));
+
+ public readonly stateChange = output();
+
+ protected stateChanged(state: BrnDialogState) {
+ this.stateChange.emit(state);
+ this._state.set(state);
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command-empty-state.ts b/libs/ui/command/src/lib/hlm-command-empty-state.ts
new file mode 100644
index 00000000..03d384b1
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-empty-state.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnCommandEmpty } from '@spartan-ng/brain/command';
+
+@Directive({
+ selector: '[hlmCommandEmptyState]',
+ hostDirectives: [BrnCommandEmpty],
+})
+export class HlmCommandEmptyState {}
diff --git a/libs/ui/command/src/lib/hlm-command-empty.ts b/libs/ui/command/src/lib/hlm-command-empty.ts
new file mode 100644
index 00000000..37d7c831
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-empty.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCommandEmpty]',
+ host: {
+ 'data-slot': 'command-empty',
+ },
+})
+export class HlmCommandEmpty {
+ constructor() {
+ classes(() => 'py-6 text-center text-sm');
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command-group-label.ts b/libs/ui/command/src/lib/hlm-command-group-label.ts
new file mode 100644
index 00000000..77be39ae
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-group-label.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCommandGroupLabel],hlm-command-group-label',
+ host: {
+ 'data-slot': 'command-group-label',
+ role: 'presentation',
+ },
+})
+export class HlmCommandGroupLabel {
+ constructor() {
+ classes(() => 'text-muted-foreground block px-2 py-1.5 text-xs font-medium');
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command-group.ts b/libs/ui/command/src/lib/hlm-command-group.ts
new file mode 100644
index 00000000..61b095f8
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-group.ts
@@ -0,0 +1,21 @@
+import { Directive } from '@angular/core';
+import { BrnCommandGroup } from '@spartan-ng/brain/command';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCommandGroup],hlm-command-group',
+ hostDirectives: [
+ {
+ directive: BrnCommandGroup,
+ inputs: ['id'],
+ },
+ ],
+ host: {
+ 'data-slot': 'command-group',
+ },
+})
+export class HlmCommandGroup {
+ constructor() {
+ classes(() => 'text-foreground block overflow-hidden p-1 data-hidden:hidden');
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command-input.ts b/libs/ui/command/src/lib/hlm-command-input.ts
new file mode 100644
index 00000000..7e012af7
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-input.ts
@@ -0,0 +1,38 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideSearch } from '@ng-icons/lucide';
+import { BrnCommandInput } from '@spartan-ng/brain/command';
+import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-command-input',
+ imports: [HlmInputGroupImports, NgIcon, BrnCommandInput],
+ providers: [provideIcons({ lucideSearch })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+
+
+
+
+ `,
+})
+export class HlmCommandInput {
+ public readonly id = input();
+ public readonly placeholder = input('');
+
+ constructor() {
+ classes(() => 'p-1 pb-0');
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command-item.ts b/libs/ui/command/src/lib/hlm-command-item.ts
new file mode 100644
index 00000000..596ab1ce
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-item.ts
@@ -0,0 +1,25 @@
+import { Directive } from '@angular/core';
+import { BrnCommandItem } from '@spartan-ng/brain/command';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'button[hlmCommandItem],button[hlm-command-item]',
+ hostDirectives: [
+ {
+ directive: BrnCommandItem,
+ inputs: ['value', 'disabled', 'id'],
+ outputs: ['selected'],
+ },
+ ],
+ host: {
+ 'data-slot': 'command-item',
+ },
+})
+export class HlmCommandItem {
+ constructor() {
+ classes(
+ () =>
+ "data-[selected]:bg-accent data-[selected=true]:text-accent-foreground [&>ng-icon:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-hidden:hidden data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>ng-icon]:pointer-events-none [&>ng-icon]:shrink-0 [&>ng-icon:not([class*='text-'])]:text-base",
+ );
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command-list.ts b/libs/ui/command/src/lib/hlm-command-list.ts
new file mode 100644
index 00000000..55b9506a
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-list.ts
@@ -0,0 +1,21 @@
+import { Directive } from '@angular/core';
+import { BrnCommandList } from '@spartan-ng/brain/command';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCommandList],hlm-command-list',
+ hostDirectives: [
+ {
+ directive: BrnCommandList,
+ inputs: ['id'],
+ },
+ ],
+ host: {
+ 'data-slot': 'command-list',
+ },
+})
+export class HlmCommandList {
+ constructor() {
+ classes(() => 'no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none');
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command-separator.ts b/libs/ui/command/src/lib/hlm-command-separator.ts
new file mode 100644
index 00000000..8ac9d1ce
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-separator.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnCommandSeparator } from '@spartan-ng/brain/command';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCommandSeparator],hlm-command-separator',
+ hostDirectives: [BrnCommandSeparator],
+ host: {
+ 'data-slot': 'command-separator',
+ },
+})
+export class HlmCommandSeparator {
+ constructor() {
+ classes(() => 'bg-border -mx-1 block h-px w-auto data-hidden:hidden');
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command-shortcut.ts b/libs/ui/command/src/lib/hlm-command-shortcut.ts
new file mode 100644
index 00000000..1c472e60
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command-shortcut.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCommandShortcut],hlm-command-shortcut',
+ host: {
+ 'data-slot': 'command-shortcut',
+ },
+})
+export class HlmCommandShortcut {
+ constructor() {
+ classes(() => 'text-muted-foreground ml-auto text-xs tracking-widest');
+ }
+}
diff --git a/libs/ui/command/src/lib/hlm-command.ts b/libs/ui/command/src/lib/hlm-command.ts
new file mode 100644
index 00000000..d2053a5a
--- /dev/null
+++ b/libs/ui/command/src/lib/hlm-command.ts
@@ -0,0 +1,22 @@
+import { Directive } from '@angular/core';
+import { BrnCommand } from '@spartan-ng/brain/command';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmCommand],hlm-command',
+ hostDirectives: [
+ {
+ directive: BrnCommand,
+ inputs: ['id', 'filter', 'search', 'disabled'],
+ outputs: ['valueChange', 'searchChange'],
+ },
+ ],
+ host: {
+ 'data-slot': 'command',
+ },
+})
+export class HlmCommand {
+ constructor() {
+ classes(() => 'bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-xl p-1');
+ }
+}
diff --git a/libs/ui/dialog/src/index.ts b/libs/ui/dialog/src/index.ts
new file mode 100644
index 00000000..dd7d2e6b
--- /dev/null
+++ b/libs/ui/dialog/src/index.ts
@@ -0,0 +1,35 @@
+import { HlmDialog } from './lib/hlm-dialog';
+import { HlmDialogClose } from './lib/hlm-dialog-close';
+import { HlmDialogContent } from './lib/hlm-dialog-content';
+import { HlmDialogDescription } from './lib/hlm-dialog-description';
+import { HlmDialogFooter } from './lib/hlm-dialog-footer';
+import { HlmDialogHeader } from './lib/hlm-dialog-header';
+import { HlmDialogOverlay } from './lib/hlm-dialog-overlay';
+import { HlmDialogPortal } from './lib/hlm-dialog-portal';
+import { HlmDialogTitle } from './lib/hlm-dialog-title';
+import { HlmDialogTrigger } from './lib/hlm-dialog-trigger';
+
+export * from './lib/hlm-dialog';
+export * from './lib/hlm-dialog-close';
+export * from './lib/hlm-dialog-content';
+export * from './lib/hlm-dialog-description';
+export * from './lib/hlm-dialog-footer';
+export * from './lib/hlm-dialog-header';
+export * from './lib/hlm-dialog-overlay';
+export * from './lib/hlm-dialog-portal';
+export * from './lib/hlm-dialog-title';
+export * from './lib/hlm-dialog-trigger';
+export * from './lib/hlm-dialog.service';
+
+export const HlmDialogImports = [
+ HlmDialog,
+ HlmDialogContent,
+ HlmDialogDescription,
+ HlmDialogFooter,
+ HlmDialogHeader,
+ HlmDialogOverlay,
+ HlmDialogPortal,
+ HlmDialogTitle,
+ HlmDialogTrigger,
+ HlmDialogClose,
+] as const;
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-close.ts b/libs/ui/dialog/src/lib/hlm-dialog-close.ts
new file mode 100644
index 00000000..bb282c1c
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-close.ts
@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+import { BrnDialogClose } from '@spartan-ng/brain/dialog';
+
+@Directive({
+ selector: 'button[hlmDialogClose]',
+ hostDirectives: [{ directive: BrnDialogClose, inputs: ['delay'] }],
+ host: {
+ 'data-slot': 'dialog-close',
+ },
+})
+export class HlmDialogClose {}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-content.ts b/libs/ui/dialog/src/lib/hlm-dialog-content.ts
new file mode 100644
index 00000000..32344149
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-content.ts
@@ -0,0 +1,53 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import { NgComponentOutlet } from '@angular/common';
+import { booleanAttribute, ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
+import { provideIcons } from '@ng-icons/core';
+import { lucideX } from '@ng-icons/lucide';
+import { BrnDialogRef, injectBrnDialogContext } from '@spartan-ng/brain/dialog';
+import { HlmButton } from '@spartan-ng/helm/button';
+import { HlmIconImports } from '@spartan-ng/helm/icon';
+import { classes } from '@spartan-ng/helm/utils';
+import { HlmDialogClose } from './hlm-dialog-close';
+
+@Component({
+ selector: 'hlm-dialog-content',
+ imports: [NgComponentOutlet, HlmIconImports, HlmButton, HlmDialogClose],
+ providers: [provideIcons({ lucideX })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-slot': 'dialog-content',
+ '[attr.data-state]': 'state()',
+ },
+ template: `
+ @if (component) {
+
+ } @else {
+
+ }
+
+ @if (showCloseButton()) {
+
+ }
+ `,
+})
+export class HlmDialogContent {
+ private readonly _dialogRef = inject(BrnDialogRef);
+ private readonly _dialogContext = injectBrnDialogContext({ optional: true });
+
+ public readonly showCloseButton = input(true, { transform: booleanAttribute });
+
+ public readonly state = computed(() => this._dialogRef?.state() ?? 'closed');
+
+ public readonly component = this._dialogContext?.$component;
+ private readonly _dynamicComponentClass = this._dialogContext?.$dynamicComponentClass;
+
+ constructor() {
+ classes(() => [
+ 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-50 mx-auto grid w-full max-w-[calc(100%-2rem)] gap-4 rounded-lg border p-6 shadow-lg data-[state=closed]:duration-200 data-[state=open]:duration-200 sm:mx-0 sm:max-w-lg',
+ this._dynamicComponentClass,
+ ]);
+ }
+}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-description.ts b/libs/ui/dialog/src/lib/hlm-dialog-description.ts
new file mode 100644
index 00000000..575e92d0
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-description.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnDialogDescription } from '@spartan-ng/brain/dialog';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDialogDescription]',
+ hostDirectives: [BrnDialogDescription],
+ host: {
+ 'data-slot': 'dialog-description',
+ },
+})
+export class HlmDialogDescription {
+ constructor() {
+ classes(() => 'text-muted-foreground text-sm');
+ }
+}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-footer.ts b/libs/ui/dialog/src/lib/hlm-dialog-footer.ts
new file mode 100644
index 00000000..9f2f7722
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-footer.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDialogFooter],hlm-dialog-footer',
+ host: {
+ 'data-slot': 'dialog-footer',
+ },
+})
+export class HlmDialogFooter {
+ constructor() {
+ classes(() => 'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end');
+ }
+}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-header.ts b/libs/ui/dialog/src/lib/hlm-dialog-header.ts
new file mode 100644
index 00000000..f77cd140
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-header.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDialogHeader],hlm-dialog-header',
+ host: {
+ 'data-slot': 'dialog-header',
+ },
+})
+export class HlmDialogHeader {
+ constructor() {
+ classes(() => 'flex flex-col gap-2 text-center sm:text-start');
+ }
+}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-overlay.ts b/libs/ui/dialog/src/lib/hlm-dialog-overlay.ts
new file mode 100644
index 00000000..215cd51b
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-overlay.ts
@@ -0,0 +1,26 @@
+import { computed, Directive, effect, input, untracked } from '@angular/core';
+import { injectCustomClassSettable } from '@spartan-ng/brain/core';
+import { BrnDialogOverlay } from '@spartan-ng/brain/dialog';
+import { hlm } from '@spartan-ng/helm/utils';
+import { ClassValue } from 'clsx';
+
+export const hlmDialogOverlayClass =
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-black/50';
+
+@Directive({
+ selector: '[hlmDialogOverlay],hlm-dialog-overlay',
+ hostDirectives: [BrnDialogOverlay],
+})
+export class HlmDialogOverlay {
+ private readonly _classSettable = injectCustomClassSettable({ optional: true, host: true });
+
+ public readonly userClass = input('', { alias: 'class' });
+ protected readonly _computedClass = computed(() => hlm(hlmDialogOverlayClass, this.userClass()));
+
+ constructor() {
+ effect(() => {
+ const newClass = this._computedClass();
+ untracked(() => this._classSettable?.setClassToCustomElement(newClass));
+ });
+ }
+}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-portal.ts b/libs/ui/dialog/src/lib/hlm-dialog-portal.ts
new file mode 100644
index 00000000..597b5273
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-portal.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnDialogContent } from '@spartan-ng/brain/dialog';
+
+@Directive({
+ selector: '[hlmDialogPortal]',
+ hostDirectives: [{ directive: BrnDialogContent, inputs: ['context', 'class'] }],
+})
+export class HlmDialogPortal {}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-title.ts b/libs/ui/dialog/src/lib/hlm-dialog-title.ts
new file mode 100644
index 00000000..bf19fc0a
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-title.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnDialogTitle } from '@spartan-ng/brain/dialog';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDialogTitle]',
+ hostDirectives: [BrnDialogTitle],
+ host: {
+ 'data-slot': 'dialog-title',
+ },
+})
+export class HlmDialogTitle {
+ constructor() {
+ classes(() => 'text-lg leading-none font-semibold');
+ }
+}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog-trigger.ts b/libs/ui/dialog/src/lib/hlm-dialog-trigger.ts
new file mode 100644
index 00000000..f6756efc
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog-trigger.ts
@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+import { BrnDialogTrigger } from '@spartan-ng/brain/dialog';
+
+@Directive({
+ selector: 'button[hlmDialogTrigger],button[hlmDialogTriggerFor]',
+ hostDirectives: [{ directive: BrnDialogTrigger, inputs: ['id', 'brnDialogTriggerFor: hlmDialogTriggerFor', 'type'] }],
+ host: {
+ 'data-slot': 'dialog-trigger',
+ },
+})
+export class HlmDialogTrigger {}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog.service.ts b/libs/ui/dialog/src/lib/hlm-dialog.service.ts
new file mode 100644
index 00000000..82fbe446
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog.service.ts
@@ -0,0 +1,31 @@
+import type { ComponentType } from '@angular/cdk/portal';
+import { inject, Injectable, type TemplateRef } from '@angular/core';
+import { type BrnDialogOptions, BrnDialogService, cssClassesToArray } from '@spartan-ng/brain/dialog';
+import { HlmDialogContent } from './hlm-dialog-content';
+import { hlmDialogOverlayClass } from './hlm-dialog-overlay';
+
+export type HlmDialogOptions = BrnDialogOptions & {
+ contentClass?: string;
+ context?: DialogContext;
+};
+
+@Injectable({
+ providedIn: 'root',
+})
+export class HlmDialogService {
+ private readonly _brnDialogService = inject(BrnDialogService);
+
+ public open(component: ComponentType | TemplateRef, options?: Partial) {
+ const mergedOptions = {
+ ...(options ?? {}),
+ backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
+ context: {
+ ...(options?.context && typeof options.context === 'object' ? options.context : {}),
+ $component: component,
+ $dynamicComponentClass: options?.contentClass,
+ },
+ };
+
+ return this._brnDialogService.open(HlmDialogContent, undefined, mergedOptions.context, mergedOptions);
+ }
+}
diff --git a/libs/ui/dialog/src/lib/hlm-dialog.ts b/libs/ui/dialog/src/lib/hlm-dialog.ts
new file mode 100644
index 00000000..ecf23c0d
--- /dev/null
+++ b/libs/ui/dialog/src/lib/hlm-dialog.ts
@@ -0,0 +1,24 @@
+import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core';
+import { BrnDialog, provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
+import { HlmDialogOverlay } from './hlm-dialog-overlay';
+
+@Component({
+ selector: 'hlm-dialog',
+ exportAs: 'hlmDialog',
+ imports: [HlmDialogOverlay],
+ providers: [
+ {
+ provide: BrnDialog,
+ useExisting: forwardRef(() => HlmDialog),
+ },
+ provideBrnDialogDefaultOptions({
+ // add custom options here
+ }),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+ `,
+})
+export class HlmDialog extends BrnDialog {}
diff --git a/libs/ui/dropdown-menu/src/index.ts b/libs/ui/dropdown-menu/src/index.ts
new file mode 100644
index 00000000..6f111722
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/index.ts
@@ -0,0 +1,45 @@
+import { HlmDropdownMenu } from './lib/hlm-dropdown-menu';
+import { HlmDropdownMenuCheckbox } from './lib/hlm-dropdown-menu-checkbox';
+import { HlmDropdownMenuCheckboxIndicator } from './lib/hlm-dropdown-menu-checkbox-indicator';
+import { HlmDropdownMenuGroup } from './lib/hlm-dropdown-menu-group';
+import { HlmDropdownMenuItem } from './lib/hlm-dropdown-menu-item';
+import { HlmDropdownMenuItemSubIndicator } from './lib/hlm-dropdown-menu-item-sub-indicator';
+import { HlmDropdownMenuLabel } from './lib/hlm-dropdown-menu-label';
+import { HlmDropdownMenuRadio } from './lib/hlm-dropdown-menu-radio';
+import { HlmDropdownMenuRadioIndicator } from './lib/hlm-dropdown-menu-radio-indicator';
+import { HlmDropdownMenuSeparator } from './lib/hlm-dropdown-menu-separator';
+import { HlmDropdownMenuShortcut } from './lib/hlm-dropdown-menu-shortcut';
+import { HlmDropdownMenuSub } from './lib/hlm-dropdown-menu-sub';
+
+import { HlmDropdownMenuTrigger } from './lib/hlm-dropdown-menu-trigger';
+
+export * from './lib/hlm-dropdown-menu';
+export * from './lib/hlm-dropdown-menu-checkbox';
+export * from './lib/hlm-dropdown-menu-checkbox-indicator';
+export * from './lib/hlm-dropdown-menu-group';
+export * from './lib/hlm-dropdown-menu-item';
+export * from './lib/hlm-dropdown-menu-item-sub-indicator';
+export * from './lib/hlm-dropdown-menu-label';
+export * from './lib/hlm-dropdown-menu-radio';
+export * from './lib/hlm-dropdown-menu-radio-indicator';
+export * from './lib/hlm-dropdown-menu-separator';
+export * from './lib/hlm-dropdown-menu-shortcut';
+export * from './lib/hlm-dropdown-menu-sub';
+export * from './lib/hlm-dropdown-menu-token';
+export * from './lib/hlm-dropdown-menu-trigger';
+
+export const HlmDropdownMenuImports = [
+ HlmDropdownMenu,
+ HlmDropdownMenuCheckbox,
+ HlmDropdownMenuCheckboxIndicator,
+ HlmDropdownMenuGroup,
+ HlmDropdownMenuItem,
+ HlmDropdownMenuItemSubIndicator,
+ HlmDropdownMenuLabel,
+ HlmDropdownMenuRadio,
+ HlmDropdownMenuRadioIndicator,
+ HlmDropdownMenuSeparator,
+ HlmDropdownMenuShortcut,
+ HlmDropdownMenuSub,
+ HlmDropdownMenuTrigger,
+] as const;
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-checkbox-indicator.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-checkbox-indicator.ts
new file mode 100644
index 00000000..7fc28a1f
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-checkbox-indicator.ts
@@ -0,0 +1,22 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideCheck } from '@ng-icons/lucide';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-dropdown-menu-checkbox-indicator',
+ imports: [NgIcon],
+ providers: [provideIcons({ lucideCheck })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+})
+export class HlmDropdownMenuCheckboxIndicator {
+ constructor() {
+ classes(
+ () =>
+ 'pointer-events-none absolute left-2 flex size-3.5 items-center justify-center opacity-0 group-data-[checked]:opacity-100',
+ );
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-checkbox.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-checkbox.ts
new file mode 100644
index 00000000..89d1e39e
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-checkbox.ts
@@ -0,0 +1,32 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { CdkMenuItemCheckbox } from '@angular/cdk/menu';
+import { Directive, booleanAttribute, inject, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenuCheckbox]',
+ hostDirectives: [
+ {
+ directive: CdkMenuItemCheckbox,
+ inputs: ['cdkMenuItemDisabled: disabled', 'cdkMenuItemChecked: checked'],
+ outputs: ['cdkMenuItemTriggered: triggered'],
+ },
+ ],
+ host: {
+ 'data-slot': 'dropdown-menu-checkbox-item',
+ '[attr.data-disabled]': 'disabled() ? "" : null',
+ '[attr.data-checked]': 'checked() ? "" : null',
+ },
+})
+export class HlmDropdownMenuCheckbox {
+ private readonly _cdkMenuItem = inject(CdkMenuItemCheckbox);
+ public readonly checked = input(this._cdkMenuItem.checked, { transform: booleanAttribute });
+ public readonly disabled = input(this._cdkMenuItem.disabled, { transform: booleanAttribute });
+
+ constructor() {
+ classes(
+ () =>
+ 'hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground group relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none has-[>hlm-dropdown-menu-checkbox-indicator:last-child]:ps-2 has-[>hlm-dropdown-menu-checkbox-indicator:last-child]:pe-8 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 has-[>hlm-dropdown-menu-checkbox-indicator:last-child]:[&>hlm-dropdown-menu-checkbox-indicator]:start-auto has-[>hlm-dropdown-menu-checkbox-indicator:last-child]:[&>hlm-dropdown-menu-checkbox-indicator]:end-2',
+ );
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-group.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-group.ts
new file mode 100644
index 00000000..b24b0eca
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-group.ts
@@ -0,0 +1,16 @@
+import { CdkMenuGroup } from '@angular/cdk/menu';
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenuGroup],hlm-dropdown-menu-group',
+ hostDirectives: [CdkMenuGroup],
+ host: {
+ 'data-slot': 'dropdown-menu-group',
+ },
+})
+export class HlmDropdownMenuGroup {
+ constructor() {
+ classes(() => 'block');
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-item-sub-indicator.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-item-sub-indicator.ts
new file mode 100644
index 00000000..7c540fd9
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-item-sub-indicator.ts
@@ -0,0 +1,19 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronRight } from '@ng-icons/lucide';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-dropdown-menu-item-sub-indicator',
+ imports: [NgIcon],
+ providers: [provideIcons({ lucideChevronRight })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+})
+export class HlmDropdownMenuItemSubIndicator {
+ constructor() {
+ classes(() => 'ms-auto size-4');
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-item.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-item.ts
new file mode 100644
index 00000000..b4ca859a
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-item.ts
@@ -0,0 +1,40 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { CdkMenuItem } from '@angular/cdk/menu';
+import { booleanAttribute, Directive, HOST_TAG_NAME, inject, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenuItem]',
+ hostDirectives: [
+ {
+ directive: CdkMenuItem,
+ inputs: ['cdkMenuItemDisabled: disabled'],
+ outputs: ['cdkMenuItemTriggered: triggered'],
+ },
+ ],
+ host: {
+ 'data-slot': 'dropdown-menu-item',
+ '[attr.disabled]': '_isButton && disabled() ? "" : null',
+ '[attr.data-disabled]': 'disabled() ? "" : null',
+ '[attr.data-variant]': 'variant()',
+ '[attr.data-inset]': 'inset() ? "" : null',
+ },
+})
+export class HlmDropdownMenuItem {
+ protected readonly _isButton = inject(HOST_TAG_NAME) === 'button';
+
+ public readonly disabled = input(false, { transform: booleanAttribute });
+
+ public readonly variant = input<'default' | 'destructive'>('default');
+
+ public readonly inset = input(false, {
+ transform: booleanAttribute,
+ });
+
+ constructor() {
+ classes(
+ () =>
+ "hover:bg-accent focus-visible:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[ng-icon]:!text-destructive [&_ng-icon:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0 [&_svg:not([class*='text-'])]:text-base",
+ );
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-label.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-label.ts
new file mode 100644
index 00000000..ca953f4b
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-label.ts
@@ -0,0 +1,20 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { booleanAttribute, Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenuLabel],hlm-dropdown-menu-label',
+ host: {
+ 'data-slot': 'dropdown-menu-label',
+ '[attr.data-inset]': 'inset() ? "" : null',
+ },
+})
+export class HlmDropdownMenuLabel {
+ constructor() {
+ classes(() => 'block px-2 py-1.5 text-sm font-medium data-[inset]:pl-8');
+ }
+
+ public readonly inset = input(false, {
+ transform: booleanAttribute,
+ });
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-radio-indicator.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-radio-indicator.ts
new file mode 100644
index 00000000..2c211bf2
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-radio-indicator.ts
@@ -0,0 +1,22 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideCircle } from '@ng-icons/lucide';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-dropdown-menu-radio-indicator',
+ imports: [NgIcon],
+ providers: [provideIcons({ lucideCircle })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+})
+export class HlmDropdownMenuRadioIndicator {
+ constructor() {
+ classes(
+ () =>
+ 'pointer-events-none absolute left-2 flex size-3.5 items-center justify-center opacity-0 group-data-[checked]:opacity-100',
+ );
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-radio.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-radio.ts
new file mode 100644
index 00000000..9670d1cb
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-radio.ts
@@ -0,0 +1,32 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { CdkMenuItemRadio } from '@angular/cdk/menu';
+import { Directive, booleanAttribute, inject, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenuRadio]',
+ hostDirectives: [
+ {
+ directive: CdkMenuItemRadio,
+ inputs: ['cdkMenuItemDisabled: disabled', 'cdkMenuItemChecked: checked'],
+ outputs: ['cdkMenuItemTriggered: triggered'],
+ },
+ ],
+ host: {
+ 'data-slot': 'dropdown-menu-radio-item',
+ '[attr.data-disabled]': 'disabled() ? "" : null',
+ '[attr.data-checked]': 'checked() ? "" : null',
+ },
+})
+export class HlmDropdownMenuRadio {
+ private readonly _cdkMenuItem = inject(CdkMenuItemRadio);
+ public readonly checked = input(this._cdkMenuItem.checked, { transform: booleanAttribute });
+ public readonly disabled = input(this._cdkMenuItem.disabled, { transform: booleanAttribute });
+
+ constructor() {
+ classes(
+ () =>
+ 'hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground group relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
+ );
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-separator.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-separator.ts
new file mode 100644
index 00000000..0fd20bdc
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-separator.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenuSeparator],hlm-dropdown-menu-separator',
+ host: {
+ 'data-slot': 'dropdown-menu-separator',
+ },
+})
+export class HlmDropdownMenuSeparator {
+ constructor() {
+ classes(() => 'bg-border -mx-1 my-1 block h-px');
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-shortcut.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-shortcut.ts
new file mode 100644
index 00000000..4fa0d22c
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-shortcut.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenuShortcut],hlm-dropdown-menu-shortcut',
+ host: {
+ 'data-slot': 'dropdown-menu-shortcut',
+ },
+})
+export class HlmDropdownMenuShortcut {
+ constructor() {
+ classes(() => 'text-muted-foreground ml-auto text-xs tracking-widest');
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-sub.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-sub.ts
new file mode 100644
index 00000000..17a5b69f
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-sub.ts
@@ -0,0 +1,58 @@
+import { CdkMenu } from '@angular/cdk/menu';
+import { Directive, inject, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenuSub],hlm-dropdown-menu-sub',
+ hostDirectives: [CdkMenu],
+ host: {
+ 'data-slot': 'dropdown-menu-sub',
+ '[attr.data-state]': '_state()',
+ '[attr.data-side]': '_side()',
+ },
+})
+export class HlmDropdownMenuSub {
+ private readonly _host = inject(CdkMenu);
+
+ protected readonly _state = signal('open');
+ protected readonly _side = signal('top');
+
+ constructor() {
+ this.setSideWithDarkMagic();
+ // this is a best effort, but does not seem to work currently
+ // TODO: figure out a way for us to know the host is about to be closed. might not be possible with CDK
+ this._host.closed.pipe(takeUntilDestroyed()).subscribe(() => this._state.set('closed'));
+
+ classes(
+ () =>
+ 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-top overflow-hidden rounded-md border p-1 shadow-lg',
+ );
+ }
+
+ private setSideWithDarkMagic() {
+ /**
+ * This is an ugly workaround to at least figure out the correct side of where a submenu
+ * will appear and set the attribute to the host accordingly
+ *
+ * First of all we take advantage of the menu stack not being aware of the root
+ * object immediately after it is added. This code executes before the root element is added,
+ * which means the stack is still empty and the peek method returns undefined.
+ */
+ const isRoot = this._host.menuStack.peek() === undefined;
+ setTimeout(() => {
+ // our menu trigger directive leaves the last position used for use immediately after opening
+ // we can access it here and determine the correct side.
+ // eslint-disable-next-line
+ const ps = (this._host as any)._parentTrigger._spartanLastPosition;
+ if (!ps) {
+ // if we have no last position we default to the most likely option
+ // I hate that we have to do this and hope we can revisit soon and improve
+ this._side.set(isRoot ? 'top' : 'left');
+ return;
+ }
+ const side = isRoot ? ps.originY : ps.originX === 'end' ? 'right' : 'left';
+ this._side.set(side);
+ });
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-token.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-token.ts
new file mode 100644
index 00000000..cb346980
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-token.ts
@@ -0,0 +1,22 @@
+import { InjectionToken, type ValueProvider, inject } from '@angular/core';
+import { type MenuAlign, type MenuSide } from '@spartan-ng/brain/core';
+
+export interface HlmDropdownMenuConfig {
+ align: MenuAlign;
+ side: MenuSide;
+}
+
+const defaultConfig: HlmDropdownMenuConfig = {
+ align: 'start',
+ side: 'bottom',
+};
+
+const HlmDropdownMenuConfigToken = new InjectionToken('HlmDropdownMenuConfig');
+
+export function provideHlmDropdownMenuConfig(config: Partial): ValueProvider {
+ return { provide: HlmDropdownMenuConfigToken, useValue: { ...defaultConfig, ...config } };
+}
+
+export function injectHlmDropdownMenuConfig(): HlmDropdownMenuConfig {
+ return inject(HlmDropdownMenuConfigToken, { optional: true }) ?? defaultConfig;
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-trigger.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-trigger.ts
new file mode 100644
index 00000000..56a5eca3
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu-trigger.ts
@@ -0,0 +1,46 @@
+import { CdkMenuTrigger } from '@angular/cdk/menu';
+import { computed, Directive, effect, inject, input } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { createMenuPosition, type MenuAlign, type MenuSide } from '@spartan-ng/brain/core';
+import { injectHlmDropdownMenuConfig } from './hlm-dropdown-menu-token';
+
+@Directive({
+ selector: '[hlmDropdownMenuTrigger]',
+ hostDirectives: [
+ {
+ directive: CdkMenuTrigger,
+ inputs: ['cdkMenuTriggerFor: hlmDropdownMenuTrigger', 'cdkMenuTriggerData: hlmDropdownMenuTriggerData'],
+ outputs: ['cdkMenuOpened: hlmDropdownMenuOpened', 'cdkMenuClosed: hlmDropdownMenuClosed'],
+ },
+ ],
+ host: {
+ 'data-slot': 'dropdown-menu-trigger',
+ },
+})
+export class HlmDropdownMenuTrigger {
+ private readonly _cdkTrigger = inject(CdkMenuTrigger, { host: true });
+ private readonly _config = injectHlmDropdownMenuConfig();
+
+ public readonly align = input(this._config.align);
+ public readonly side = input(this._config.side);
+
+ private readonly _menuPosition = computed(() => createMenuPosition(this.align(), this.side()));
+
+ constructor() {
+ // once the trigger opens we wait until the next tick and then grab the last position
+ // used to position the menu. we store this in our trigger which the brnMenu directive has
+ // access to through DI
+ this._cdkTrigger.opened.pipe(takeUntilDestroyed()).subscribe(() =>
+ setTimeout(
+ () =>
+ // eslint-disable-next-line
+ ((this._cdkTrigger as any)._spartanLastPosition = // eslint-disable-next-line
+ (this._cdkTrigger as any).overlayRef._positionStrategy._lastPosition),
+ ),
+ );
+
+ effect(() => {
+ this._cdkTrigger.menuPosition = this._menuPosition();
+ });
+ }
+}
diff --git a/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu.ts b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu.ts
new file mode 100644
index 00000000..3d1e4ba3
--- /dev/null
+++ b/libs/ui/dropdown-menu/src/lib/hlm-dropdown-menu.ts
@@ -0,0 +1,62 @@
+import { type NumberInput } from '@angular/cdk/coercion';
+import { CdkMenu } from '@angular/cdk/menu';
+import { Directive, inject, input, numberAttribute, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmDropdownMenu],hlm-dropdown-menu',
+ hostDirectives: [CdkMenu],
+ host: {
+ 'data-slot': 'dropdown-menu',
+ '[attr.data-state]': '_state()',
+ '[attr.data-side]': '_side()',
+ '[style.--side-offset]': 'sideOffset()',
+ },
+})
+export class HlmDropdownMenu {
+ private readonly _host = inject(CdkMenu);
+
+ protected readonly _state = signal('open');
+ protected readonly _side = signal('top');
+
+ public readonly sideOffset = input(1, { transform: numberAttribute });
+
+ constructor() {
+ classes(
+ () =>
+ 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 my-[--spacing(var(--side-offset))] min-w-[8rem] origin-top overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
+ );
+
+ this.setSideWithDarkMagic();
+ // this is a best effort, but does not seem to work currently
+ // TODO: figure out a way for us to know the host is about to be closed. might not be possible with CDK
+ this._host.closed.pipe(takeUntilDestroyed()).subscribe(() => this._state.set('closed'));
+ }
+
+ private setSideWithDarkMagic() {
+ /**
+ * This is an ugly workaround to at least figure out the correct side of where a submenu
+ * will appear and set the attribute to the host accordingly
+ *
+ * First of all we take advantage of the menu stack not being aware of the root
+ * object immediately after it is added. This code executes before the root element is added,
+ * which means the stack is still empty and the peek method returns undefined.
+ */
+ const isRoot = this._host.menuStack.peek() === undefined;
+ setTimeout(() => {
+ // our menu trigger directive leaves the last position used for use immediately after opening
+ // we can access it here and determine the correct side.
+ // eslint-disable-next-line
+ const ps = (this._host as any)._parentTrigger._spartanLastPosition;
+ if (!ps) {
+ // if we have no last position we default to the most likely option
+ // I hate that we have to do this and hope we can revisit soon and improve
+ this._side.set(isRoot ? 'top' : 'left');
+ return;
+ }
+ const side = isRoot ? ps.originY : ps.originX === 'end' ? 'right' : 'left';
+ this._side.set(side);
+ });
+ }
+}
diff --git a/libs/ui/field/src/index.ts b/libs/ui/field/src/index.ts
new file mode 100644
index 00000000..9e9d0611
--- /dev/null
+++ b/libs/ui/field/src/index.ts
@@ -0,0 +1,34 @@
+import { HlmField } from './lib/hlm-field';
+import { HlmFieldContent } from './lib/hlm-field-content';
+import { HlmFieldDescription } from './lib/hlm-field-description';
+import { HlmFieldError } from './lib/hlm-field-error';
+import { HlmFieldGroup } from './lib/hlm-field-group';
+import { HlmFieldLabel } from './lib/hlm-field-label';
+import { HlmFieldLegend } from './lib/hlm-field-legend';
+import { HlmFieldSeparator } from './lib/hlm-field-separator';
+import { HlmFieldSet } from './lib/hlm-field-set';
+import { HlmFieldTitle } from './lib/hlm-field-title';
+
+export * from './lib/hlm-field';
+export * from './lib/hlm-field-content';
+export * from './lib/hlm-field-description';
+export * from './lib/hlm-field-error';
+export * from './lib/hlm-field-group';
+export * from './lib/hlm-field-label';
+export * from './lib/hlm-field-legend';
+export * from './lib/hlm-field-separator';
+export * from './lib/hlm-field-set';
+export * from './lib/hlm-field-title';
+
+export const HlmFieldImports = [
+ HlmField,
+ HlmFieldTitle,
+ HlmFieldContent,
+ HlmFieldDescription,
+ HlmFieldError,
+ HlmFieldLabel,
+ HlmFieldSeparator,
+ HlmFieldGroup,
+ HlmFieldLegend,
+ HlmFieldSet,
+] as const;
diff --git a/libs/ui/field/src/lib/hlm-field-content.ts b/libs/ui/field/src/lib/hlm-field-content.ts
new file mode 100644
index 00000000..9299e058
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-content.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmFieldContent],hlm-field-content',
+ host: {
+ 'data-slot': 'field-content',
+ },
+})
+export class HlmFieldContent {
+ constructor() {
+ classes(() => 'group/field-content flex flex-1 flex-col gap-1.5 leading-snug');
+ }
+}
diff --git a/libs/ui/field/src/lib/hlm-field-description.ts b/libs/ui/field/src/lib/hlm-field-description.ts
new file mode 100644
index 00000000..b67787cd
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-description.ts
@@ -0,0 +1,18 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmFieldDescription],hlm-field-description',
+ host: {
+ 'data-slot': 'field-description',
+ },
+})
+export class HlmFieldDescription {
+ constructor() {
+ classes(() => [
+ 'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
+ 'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
+ '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
+ ]);
+ }
+}
diff --git a/libs/ui/field/src/lib/hlm-field-error.ts b/libs/ui/field/src/lib/hlm-field-error.ts
new file mode 100644
index 00000000..a263d7d8
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-error.ts
@@ -0,0 +1,40 @@
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+
+@Component({
+ selector: 'hlm-field-error',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+ @if (_uniqueErrors().length === 1) {
+ {{ _uniqueErrors()[0]?.message }}
+ } @else if (_uniqueErrors().length > 1) {
+
+ @for (error of _uniqueErrors(); track $index) {
+ @if (error?.message) {
+ - {{ error?.message }}
+ }
+ }
+
+ }
+
+
+ `,
+})
+export class HlmFieldError {
+ public readonly userClass = input('', { alias: 'class' });
+ public readonly error = input>();
+
+ protected readonly _uniqueErrors = computed(() => {
+ const errors = this.error();
+ if (!errors?.length) {
+ return [];
+ }
+
+ return [...new Map(errors.map((err) => [err?.message, err])).values()];
+ });
+
+ protected readonly _computedClass = computed(() => hlm('text-destructive text-sm font-normal', this.userClass()));
+}
diff --git a/libs/ui/field/src/lib/hlm-field-group.ts b/libs/ui/field/src/lib/hlm-field-group.ts
new file mode 100644
index 00000000..02578c37
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-group.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmFieldGroup],hlm-field-group',
+ host: {
+ 'data-slot': 'field-group',
+ },
+})
+export class HlmFieldGroup {
+ constructor() {
+ classes(
+ () =>
+ 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
+ );
+ }
+}
diff --git a/libs/ui/field/src/lib/hlm-field-label.ts b/libs/ui/field/src/lib/hlm-field-label.ts
new file mode 100644
index 00000000..dd3a6e04
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-label.ts
@@ -0,0 +1,21 @@
+import { Directive } from '@angular/core';
+import { HlmLabel } from '@spartan-ng/helm/label';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmFieldLabel],hlm-field-label',
+ hostDirectives: [HlmLabel],
+ host: {
+ 'data-slot': 'field-label',
+ },
+})
+export class HlmFieldLabel {
+ constructor() {
+ classes(() => [
+ 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
+ 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
+ 'has-data-[checked=true]:bg-primary/5 has-data-[checked=true]:border-primary dark:has-data-[checked=true]:bg-primary/10',
+ 'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
+ ]);
+ }
+}
diff --git a/libs/ui/field/src/lib/hlm-field-legend.ts b/libs/ui/field/src/lib/hlm-field-legend.ts
new file mode 100644
index 00000000..c81aee62
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-legend.ts
@@ -0,0 +1,17 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'legend[hlmFieldLegend]',
+ host: {
+ 'data-slot': 'field-legend',
+ '[attr.data-variant]': 'variant()',
+ },
+})
+export class HlmFieldLegend {
+ constructor() {
+ classes(() => 'mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base');
+ }
+
+ public readonly variant = input<'label' | 'legend'>('legend');
+}
diff --git a/libs/ui/field/src/lib/hlm-field-separator.ts b/libs/ui/field/src/lib/hlm-field-separator.ts
new file mode 100644
index 00000000..c38b8b7b
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-separator.ts
@@ -0,0 +1,26 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { HlmSeparator } from '@spartan-ng/helm/separator';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-field-separator',
+ imports: [HlmSeparator],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-slot': 'field-separator',
+ },
+ template: `
+
+
+
+
+ `,
+})
+export class HlmFieldSeparator {
+ constructor() {
+ classes(() => 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2');
+ }
+}
diff --git a/libs/ui/field/src/lib/hlm-field-set.ts b/libs/ui/field/src/lib/hlm-field-set.ts
new file mode 100644
index 00000000..9aa87ea9
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-set.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'fieldset[hlmFieldSet]',
+ host: {
+ 'data-slot': 'field-set',
+ },
+})
+export class HlmFieldSet {
+ constructor() {
+ classes(() => [
+ 'flex flex-col gap-6',
+ 'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
+ ]);
+ }
+}
diff --git a/libs/ui/field/src/lib/hlm-field-title.ts b/libs/ui/field/src/lib/hlm-field-title.ts
new file mode 100644
index 00000000..c7727ec5
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field-title.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmFieldTitle],hlm-field-title',
+ host: {
+ 'data-slot': 'field-label',
+ },
+})
+export class HlmFieldTitle {
+ constructor() {
+ classes(
+ () =>
+ 'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
+ );
+ }
+}
diff --git a/libs/ui/field/src/lib/hlm-field.ts b/libs/ui/field/src/lib/hlm-field.ts
new file mode 100644
index 00000000..678d0650
--- /dev/null
+++ b/libs/ui/field/src/lib/hlm-field.ts
@@ -0,0 +1,41 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+const fieldVariants = cva('group/field data-[invalid=true]:text-destructive flex w-full gap-3', {
+ variants: {
+ orientation: {
+ vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
+ horizontal: [
+ 'flex-row items-center',
+ '[&>[data-slot=field-label]]:flex-auto',
+ 'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
+ ],
+ responsive: [
+ 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto',
+ '@md/field-group:[&>[data-slot=field-label]]:flex-auto',
+ '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
+ ],
+ },
+ },
+ defaultVariants: {
+ orientation: 'vertical',
+ },
+});
+
+export type FieldVariants = VariantProps;
+
+@Directive({
+ selector: '[hlmField],hlm-field',
+ host: {
+ role: 'group',
+ 'data-slot': 'field',
+ '[attr.data-orientation]': 'orientation()',
+ },
+})
+export class HlmField {
+ public readonly orientation = input('vertical');
+ constructor() {
+ classes(() => fieldVariants({ orientation: this.orientation() }));
+ }
+}
diff --git a/libs/ui/form-field/src/index.ts b/libs/ui/form-field/src/index.ts
new file mode 100644
index 00000000..4412a808
--- /dev/null
+++ b/libs/ui/form-field/src/index.ts
@@ -0,0 +1,9 @@
+import { HlmError } from './lib/hlm-error';
+import { HlmFormField } from './lib/hlm-form-field';
+import { HlmHint } from './lib/hlm-hint';
+
+export * from './lib/hlm-error';
+export * from './lib/hlm-form-field';
+export * from './lib/hlm-hint';
+
+export const HlmFormFieldImports = [HlmFormField, HlmError, HlmHint] as const;
diff --git a/libs/ui/form-field/src/lib/hlm-error.ts b/libs/ui/form-field/src/lib/hlm-error.ts
new file mode 100644
index 00000000..46832ce7
--- /dev/null
+++ b/libs/ui/form-field/src/lib/hlm-error.ts
@@ -0,0 +1,12 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ // eslint-disable-next-line @angular-eslint/directive-selector
+ selector: 'hlm-error',
+})
+export class HlmError {
+ constructor() {
+ classes(() => 'text-destructive block text-sm font-medium');
+ }
+}
diff --git a/libs/ui/form-field/src/lib/hlm-form-field.ts b/libs/ui/form-field/src/lib/hlm-form-field.ts
new file mode 100644
index 00000000..a755f45b
--- /dev/null
+++ b/libs/ui/form-field/src/lib/hlm-form-field.ts
@@ -0,0 +1,39 @@
+import { ChangeDetectionStrategy, Component, computed, contentChild, contentChildren, effect } from '@angular/core';
+import { BrnFormFieldControl } from '@spartan-ng/brain/form-field';
+import { classes } from '@spartan-ng/helm/utils';
+import { HlmError } from './hlm-error';
+
+@Component({
+ selector: 'hlm-form-field',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+ @switch (_hasDisplayedMessage()) {
+ @case ('error') {
+
+ }
+ @default {
+
+ }
+ }
+ `,
+})
+export class HlmFormField {
+ public readonly control = contentChild(BrnFormFieldControl);
+
+ public readonly errorChildren = contentChildren(HlmError);
+
+ protected readonly _hasDisplayedMessage = computed<'error' | 'hint'>(() =>
+ this.errorChildren() && this.errorChildren().length > 0 && this.control()?.errorState() ? 'error' : 'hint',
+ );
+
+ constructor() {
+ classes(() => 'block space-y-2');
+ effect(() => {
+ if (!this.control()) {
+ throw new Error('hlm-form-field must contain a BrnFormFieldControl.');
+ }
+ });
+ }
+}
diff --git a/libs/ui/form-field/src/lib/hlm-hint.ts b/libs/ui/form-field/src/lib/hlm-hint.ts
new file mode 100644
index 00000000..73803511
--- /dev/null
+++ b/libs/ui/form-field/src/lib/hlm-hint.ts
@@ -0,0 +1,12 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ // eslint-disable-next-line @angular-eslint/directive-selector
+ selector: 'hlm-hint',
+})
+export class HlmHint {
+ constructor() {
+ classes(() => 'text-muted-foreground block text-sm');
+ }
+}
diff --git a/libs/ui/icon/src/index.ts b/libs/ui/icon/src/index.ts
new file mode 100644
index 00000000..fe12af8c
--- /dev/null
+++ b/libs/ui/icon/src/index.ts
@@ -0,0 +1,7 @@
+import { NgIcon } from '@ng-icons/core';
+import { HlmIcon } from './lib/hlm-icon';
+
+export * from './lib/hlm-icon';
+export * from './lib/hlm-icon.token';
+
+export const HlmIconImports = [HlmIcon, NgIcon] as const;
diff --git a/libs/ui/icon/src/lib/hlm-icon.token.ts b/libs/ui/icon/src/lib/hlm-icon.token.ts
new file mode 100644
index 00000000..a0660142
--- /dev/null
+++ b/libs/ui/icon/src/lib/hlm-icon.token.ts
@@ -0,0 +1,20 @@
+import { InjectionToken, type ValueProvider, inject } from '@angular/core';
+import type { IconSize } from './hlm-icon';
+
+export interface HlmIconConfig {
+ size: IconSize;
+}
+
+const defaultConfig: HlmIconConfig = {
+ size: 'base',
+};
+
+const HlmIconConfigToken = new InjectionToken('HlmIconConfig');
+
+export function provideHlmIconConfig(config: Partial): ValueProvider {
+ return { provide: HlmIconConfigToken, useValue: { ...defaultConfig, ...config } };
+}
+
+export function injectHlmIconConfig(): HlmIconConfig {
+ return inject(HlmIconConfigToken, { optional: true }) ?? defaultConfig;
+}
diff --git a/libs/ui/icon/src/lib/hlm-icon.ts b/libs/ui/icon/src/lib/hlm-icon.ts
new file mode 100644
index 00000000..e43a20be
--- /dev/null
+++ b/libs/ui/icon/src/lib/hlm-icon.ts
@@ -0,0 +1,35 @@
+import { Directive, computed, input } from '@angular/core';
+import { injectHlmIconConfig } from './hlm-icon.token';
+
+export type IconSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | 'none' | (Record & string);
+
+@Directive({
+ selector: 'ng-icon[hlmIcon], ng-icon[hlm]',
+ host: {
+ '[style.--ng-icon__size]': '_computedSize()',
+ },
+})
+export class HlmIcon {
+ private readonly _config = injectHlmIconConfig();
+ public readonly size = input(this._config.size);
+
+ protected readonly _computedSize = computed(() => {
+ const size = this.size();
+
+ switch (size) {
+ case 'xs':
+ return '12px';
+ case 'sm':
+ return '16px';
+ case 'base':
+ return '24px';
+ case 'lg':
+ return '32px';
+ case 'xl':
+ return '48px';
+ default: {
+ return size;
+ }
+ }
+ });
+}
diff --git a/libs/ui/input-group/src/index.ts b/libs/ui/input-group/src/index.ts
new file mode 100644
index 00000000..6a2bb8f9
--- /dev/null
+++ b/libs/ui/input-group/src/index.ts
@@ -0,0 +1,22 @@
+import { HlmInputGroup } from './lib/hlm-input-group';
+import { HlmInputGroupAddon } from './lib/hlm-input-group-addon';
+import { HlmInputGroupButton } from './lib/hlm-input-group-button';
+import { HlmInputGroupInput } from './lib/hlm-input-group-input';
+import { HlmInputGroupText } from './lib/hlm-input-group-text';
+import { HlmInputGroupTextarea } from './lib/hlm-input-group-textarea';
+
+export * from './lib/hlm-input-group';
+export * from './lib/hlm-input-group-addon';
+export * from './lib/hlm-input-group-button';
+export * from './lib/hlm-input-group-input';
+export * from './lib/hlm-input-group-text';
+export * from './lib/hlm-input-group-textarea';
+
+export const HlmInputGroupImports = [
+ HlmInputGroup,
+ HlmInputGroupAddon,
+ HlmInputGroupButton,
+ HlmInputGroupInput,
+ HlmInputGroupText,
+ HlmInputGroupTextarea,
+] as const;
diff --git a/libs/ui/input-group/src/lib/hlm-input-group-addon.ts b/libs/ui/input-group/src/lib/hlm-input-group-addon.ts
new file mode 100644
index 00000000..6916936d
--- /dev/null
+++ b/libs/ui/input-group/src/lib/hlm-input-group-addon.ts
@@ -0,0 +1,39 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+const inputGroupAddonVariants = cva(
+ "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>ng-icon:not([class*='text-'])]:text-base",
+ {
+ variants: {
+ align: {
+ 'inline-start': 'order-first ps-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
+ 'inline-end': 'order-last pe-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
+ 'block-start':
+ 'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
+ 'block-end': 'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3',
+ },
+ },
+ defaultVariants: {
+ align: 'inline-start',
+ },
+ },
+);
+
+type InputGroupAddonVariants = VariantProps;
+
+@Directive({
+ selector: 'hlm-input-group-addon,[hlmInputGroupAddon]',
+ host: {
+ role: 'group',
+ 'data-slot': 'input-group-addon',
+ '[attr.data-align]': 'align()',
+ },
+})
+export class HlmInputGroupAddon {
+ public readonly align = input('inline-start');
+
+ constructor() {
+ classes(() => inputGroupAddonVariants({ align: this.align() }));
+ }
+}
diff --git a/libs/ui/input-group/src/lib/hlm-input-group-button.ts b/libs/ui/input-group/src/lib/hlm-input-group-button.ts
new file mode 100644
index 00000000..99b58643
--- /dev/null
+++ b/libs/ui/input-group/src/lib/hlm-input-group-button.ts
@@ -0,0 +1,47 @@
+import { Directive, input } from '@angular/core';
+import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+const inputGroupAddonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>ng-icon]:px-2 [&>ng-icon:not([class*='text-'])]:text-sm",
+ sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>ng-icon]:px-2.5',
+ 'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>ng-icon]:p-0',
+ 'icon-sm': 'size-8 p-0 has-[>ng-icon]:p-0',
+ },
+ },
+ defaultVariants: {
+ size: 'xs',
+ },
+});
+
+type InputGroupAddonVariants = VariantProps;
+
+@Directive({
+ selector: 'button[hlmInputGroupButton]',
+ providers: [
+ provideBrnButtonConfig({
+ variant: 'ghost',
+ }),
+ ],
+ hostDirectives: [
+ {
+ directive: HlmButton,
+ inputs: ['variant'],
+ },
+ ],
+ host: {
+ '[attr.data-size]': 'size()',
+ '[type]': 'type()',
+ },
+})
+export class HlmInputGroupButton {
+ public readonly size = input('xs');
+ public readonly type = input<'button' | 'submit' | 'reset'>('button');
+
+ constructor() {
+ classes(() => inputGroupAddonVariants({ size: this.size() }));
+ }
+}
diff --git a/libs/ui/input-group/src/lib/hlm-input-group-input.ts b/libs/ui/input-group/src/lib/hlm-input-group-input.ts
new file mode 100644
index 00000000..be374769
--- /dev/null
+++ b/libs/ui/input-group/src/lib/hlm-input-group-input.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { HlmInput } from '@spartan-ng/helm/input';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'input[hlmInputGroupInput]',
+ hostDirectives: [HlmInput],
+ host: {
+ 'data-slot': 'input-group-control',
+ },
+})
+export class HlmInputGroupInput {
+ constructor() {
+ classes(() => `flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent`);
+ }
+}
diff --git a/libs/ui/input-group/src/lib/hlm-input-group-text.ts b/libs/ui/input-group/src/lib/hlm-input-group-text.ts
new file mode 100644
index 00000000..55ef3de4
--- /dev/null
+++ b/libs/ui/input-group/src/lib/hlm-input-group-text.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'span[hlmInputGroupText]',
+})
+export class HlmInputGroupText {
+ constructor() {
+ classes(
+ () =>
+ `text-muted-foreground flex items-center gap-2 text-sm [&_ng-icon]:pointer-events-none [&_ng-icon:not([class*='text-'])]:text-base`,
+ );
+ }
+}
diff --git a/libs/ui/input-group/src/lib/hlm-input-group-textarea.ts b/libs/ui/input-group/src/lib/hlm-input-group-textarea.ts
new file mode 100644
index 00000000..a943c235
--- /dev/null
+++ b/libs/ui/input-group/src/lib/hlm-input-group-textarea.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { HlmTextarea } from '@spartan-ng/helm/textarea';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'textarea[hlmInputGroupTextarea]',
+ hostDirectives: [HlmTextarea],
+ host: {
+ 'data-slot': 'input-group-control',
+ },
+})
+export class HlmInputGroupTextarea {
+ constructor() {
+ classes(
+ () =>
+ 'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
+ );
+ }
+}
diff --git a/libs/ui/input-group/src/lib/hlm-input-group.ts b/libs/ui/input-group/src/lib/hlm-input-group.ts
new file mode 100644
index 00000000..6b543fa4
--- /dev/null
+++ b/libs/ui/input-group/src/lib/hlm-input-group.ts
@@ -0,0 +1,27 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmInputGroup],hlm-input-group',
+ host: {
+ 'data-slot': 'input-group',
+ role: 'group',
+ },
+})
+export class HlmInputGroup {
+ constructor() {
+ classes(() => [
+ 'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
+ 'h-9 min-w-0 has-[>textarea]:h-auto',
+ // Variants based on alignment.
+ 'has-[>[data-align=inline-start]]:[&>input]:ps-2',
+ 'has-[>[data-align=inline-end]]:[&>input]:pe-2',
+ 'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
+ 'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
+ // Focus state.
+ 'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
+ // Error state.
+ 'has-[>.ng-invalid.ng-touched]:ring-destructive/20 has-[>.ng-invalid.ng-touched]:border-destructive dark:has-[>.ng-invalid.ng-touched]:ring-destructive/40',
+ ]);
+ }
+}
diff --git a/libs/ui/input/src/index.ts b/libs/ui/input/src/index.ts
new file mode 100644
index 00000000..377b4c6c
--- /dev/null
+++ b/libs/ui/input/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmInput } from './lib/hlm-input';
+
+export * from './lib/hlm-input';
+
+export const HlmInputImports = [HlmInput] as const;
diff --git a/libs/ui/input/src/lib/hlm-input.ts b/libs/ui/input/src/lib/hlm-input.ts
new file mode 100644
index 00000000..a327d85b
--- /dev/null
+++ b/libs/ui/input/src/lib/hlm-input.ts
@@ -0,0 +1,97 @@
+import {
+ computed,
+ Directive,
+ effect,
+ forwardRef,
+ inject,
+ Injector,
+ input,
+ linkedSignal,
+ signal,
+ untracked,
+ type DoCheck,
+} from '@angular/core';
+import { FormGroupDirective, NgControl, NgForm } from '@angular/forms';
+import { BrnFormFieldControl } from '@spartan-ng/brain/form-field';
+import { ErrorStateMatcher, ErrorStateTracker } from '@spartan-ng/brain/forms';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva, type VariantProps } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const inputVariants = cva(
+ 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
+ {
+ variants: {
+ error: {
+ auto: '[&.ng-invalid.ng-touched]:border-destructive [&.ng-invalid.ng-touched]:ring-destructive/20 dark:[&.ng-invalid.ng-touched]:ring-destructive/40',
+ true: 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
+ },
+ },
+ defaultVariants: {
+ error: 'auto',
+ },
+ },
+);
+type InputVariants = VariantProps;
+
+@Directive({
+ selector: '[hlmInput]',
+ providers: [
+ {
+ provide: BrnFormFieldControl,
+ useExisting: forwardRef(() => HlmInput),
+ },
+ ],
+})
+export class HlmInput implements BrnFormFieldControl, DoCheck {
+ private readonly _injector = inject(Injector);
+ private readonly _additionalClasses = signal('');
+
+ private readonly _errorStateTracker: ErrorStateTracker;
+
+ private readonly _defaultErrorStateMatcher = inject(ErrorStateMatcher);
+ private readonly _parentForm = inject(NgForm, { optional: true });
+ private readonly _parentFormGroup = inject(FormGroupDirective, { optional: true });
+
+ public readonly error = input('auto');
+
+ protected readonly _state = linkedSignal(() => ({ error: this.error() }));
+
+ public readonly ngControl: NgControl | null = this._injector.get(NgControl, null);
+
+ public readonly errorState = computed(() => this._errorStateTracker.errorState());
+
+ constructor() {
+ this._errorStateTracker = new ErrorStateTracker(
+ this._defaultErrorStateMatcher,
+ this.ngControl,
+ this._parentFormGroup,
+ this._parentForm,
+ );
+
+ classes(() => [inputVariants({ error: this._state().error }), this._additionalClasses()]);
+
+ effect(() => {
+ const error = this._errorStateTracker.errorState();
+ untracked(() => {
+ if (this.ngControl) {
+ const shouldShowError = error && this.ngControl.invalid && (this.ngControl.touched || this.ngControl.dirty);
+ this._errorStateTracker.errorState.set(shouldShowError ? true : false);
+ this.setError(shouldShowError ? true : 'auto');
+ }
+ });
+ });
+ }
+
+ ngDoCheck() {
+ this._errorStateTracker.updateErrorState();
+ }
+
+ setError(error: InputVariants['error']) {
+ this._state.set({ error });
+ }
+
+ setClass(classes: string): void {
+ this._additionalClasses.set(classes);
+ }
+}
diff --git a/libs/ui/item/src/index.ts b/libs/ui/item/src/index.ts
new file mode 100644
index 00000000..158e79b9
--- /dev/null
+++ b/libs/ui/item/src/index.ts
@@ -0,0 +1,35 @@
+import { HlmItem } from './lib/hlm-item';
+import { HlmItemActions } from './lib/hlm-item-actions';
+import { HlmItemContent } from './lib/hlm-item-content';
+import { HlmItemDescription } from './lib/hlm-item-description';
+import { HlmItemFooter } from './lib/hlm-item-footer';
+import { HlmItemGroup } from './lib/hlm-item-group';
+import { HlmItemHeader } from './lib/hlm-item-header';
+import { HlmItemMedia } from './lib/hlm-item-media';
+import { HlmItemSeparator } from './lib/hlm-item-separator';
+import { HlmItemTitle } from './lib/hlm-item-title';
+
+export * from './lib/hlm-item';
+export * from './lib/hlm-item-actions';
+export * from './lib/hlm-item-content';
+export * from './lib/hlm-item-description';
+export * from './lib/hlm-item-footer';
+export * from './lib/hlm-item-group';
+export * from './lib/hlm-item-header';
+export * from './lib/hlm-item-media';
+export * from './lib/hlm-item-separator';
+export * from './lib/hlm-item-title';
+export * from './lib/hlm-item-token';
+
+export const HlmItemImports = [
+ HlmItem,
+ HlmItemActions,
+ HlmItemContent,
+ HlmItemDescription,
+ HlmItemFooter,
+ HlmItemGroup,
+ HlmItemHeader,
+ HlmItemMedia,
+ HlmItemSeparator,
+ HlmItemTitle,
+] as const;
diff --git a/libs/ui/item/src/lib/hlm-item-actions.ts b/libs/ui/item/src/lib/hlm-item-actions.ts
new file mode 100644
index 00000000..6645368e
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-actions.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmItemActions],hlm-item-actions',
+ host: {
+ 'data-slot': 'item-actions',
+ },
+})
+export class HlmItemActions {
+ constructor() {
+ classes(() => 'flex items-center gap-2');
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-content.ts b/libs/ui/item/src/lib/hlm-item-content.ts
new file mode 100644
index 00000000..e29c7a92
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-content.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmItemContent],hlm-item-content',
+ host: {
+ 'data-slot': 'item-content',
+ },
+})
+export class HlmItemContent {
+ constructor() {
+ classes(() => 'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none');
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-description.ts b/libs/ui/item/src/lib/hlm-item-description.ts
new file mode 100644
index 00000000..48bf1a14
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-description.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'p[hlmItemDescription]',
+ host: {
+ 'data-slot': 'item-description',
+ },
+})
+export class HlmItemDescription {
+ constructor() {
+ classes(() => [
+ 'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
+ '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
+ ]);
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-footer.ts b/libs/ui/item/src/lib/hlm-item-footer.ts
new file mode 100644
index 00000000..c85a1111
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-footer.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmItemFooter],hlm-item-footer',
+ host: {
+ 'data-slot': 'item-footer',
+ },
+})
+export class HlmItemFooter {
+ constructor() {
+ classes(() => 'flex basis-full items-center justify-between gap-2');
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-group.ts b/libs/ui/item/src/lib/hlm-item-group.ts
new file mode 100644
index 00000000..2cba7be9
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-group.ts
@@ -0,0 +1,12 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmItemGroup],hlm-item-group',
+ host: { 'data-slot': 'item-group' },
+})
+export class HlmItemGroup {
+ constructor() {
+ classes(() => 'group/item-group flex flex-col');
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-header.ts b/libs/ui/item/src/lib/hlm-item-header.ts
new file mode 100644
index 00000000..66ef29c0
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-header.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmItemHeader],hlm-item-header',
+ host: {
+ 'data-slot': 'item-header',
+ },
+})
+export class HlmItemHeader {
+ constructor() {
+ classes(() => 'flex basis-full items-center justify-between gap-2');
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-media.ts b/libs/ui/item/src/lib/hlm-item-media.ts
new file mode 100644
index 00000000..264c688c
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-media.ts
@@ -0,0 +1,37 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { injectHlmItemMediaConfig } from './hlm-item-token';
+
+const itemMediaVariants = cva(
+ 'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_ng-icon]:pointer-events-none',
+ {
+ variants: {
+ variant: {
+ default: 'bg-transparent',
+ icon: "bg-muted size-8 rounded-sm border [&_ng-icon:not([class*='text-'])]:text-base",
+ image: 'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+export type ItemMediaVariants = VariantProps;
+
+@Directive({
+ selector: '[hlmItemMedia],hlm-item-media',
+ host: {
+ 'data-slot': 'item-media',
+ '[attr.data-variant]': 'variant()',
+ },
+})
+export class HlmItemMedia {
+ private readonly _config = injectHlmItemMediaConfig();
+ public readonly variant = input(this._config.variant);
+
+ constructor() {
+ classes(() => itemMediaVariants({ variant: this.variant() }));
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-separator.ts b/libs/ui/item/src/lib/hlm-item-separator.ts
new file mode 100644
index 00000000..280d3790
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-separator.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { BrnSeparator } from '@spartan-ng/brain/separator';
+import { hlmSeparatorClass } from '@spartan-ng/helm/separator';
+import { classes } from '@spartan-ng/helm/utils';
+@Directive({
+ selector: 'div[hlmItemSeparator]',
+ hostDirectives: [{ directive: BrnSeparator, inputs: ['orientation'] }],
+ host: { 'data-slot': 'item-separator' },
+})
+export class HlmItemSeparator {
+ constructor() {
+ classes(() => [hlmSeparatorClass, 'my-0']);
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-title.ts b/libs/ui/item/src/lib/hlm-item-title.ts
new file mode 100644
index 00000000..9dd6a285
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-title.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmItemTitle],hlm-item-title',
+ host: {
+ 'data-slot': 'item-title',
+ },
+})
+export class HlmItemTitle {
+ constructor() {
+ classes(() => 'flex w-fit items-center gap-2 text-sm leading-snug font-medium');
+ }
+}
diff --git a/libs/ui/item/src/lib/hlm-item-token.ts b/libs/ui/item/src/lib/hlm-item-token.ts
new file mode 100644
index 00000000..6386dacd
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item-token.ts
@@ -0,0 +1,41 @@
+import { InjectionToken, type ValueProvider, inject } from '@angular/core';
+import type { ItemVariants } from './hlm-item';
+import type { ItemMediaVariants } from './hlm-item-media';
+
+export interface HlmItemConfig {
+ variant: ItemVariants['variant'];
+ size: ItemVariants['size'];
+}
+
+const defaultConfig: HlmItemConfig = {
+ variant: 'default',
+ size: 'default',
+};
+
+const HlmItemConfigToken = new InjectionToken('HlmItemConfig');
+
+export function provideHlmItemConfig(config: Partial): ValueProvider {
+ return { provide: HlmItemConfigToken, useValue: { ...defaultConfig, ...config } };
+}
+
+export function injectHlmItemConfig(): HlmItemConfig {
+ return inject(HlmItemConfigToken, { optional: true }) ?? defaultConfig;
+}
+
+export interface HlmItemMediaConfig {
+ variant: ItemMediaVariants['variant'];
+}
+
+const defaultMediaConfig: HlmItemMediaConfig = {
+ variant: 'default',
+};
+
+const HlmItemMediaConfigToken = new InjectionToken('HlmItemMediaConfig');
+
+export function provideHlmItemMediaConfig(config: Partial): ValueProvider {
+ return { provide: HlmItemMediaConfigToken, useValue: { ...defaultMediaConfig, ...config } };
+}
+
+export function injectHlmItemMediaConfig(): HlmItemMediaConfig {
+ return inject(HlmItemMediaConfigToken, { optional: true }) ?? defaultMediaConfig;
+}
diff --git a/libs/ui/item/src/lib/hlm-item.ts b/libs/ui/item/src/lib/hlm-item.ts
new file mode 100644
index 00000000..0f603d11
--- /dev/null
+++ b/libs/ui/item/src/lib/hlm-item.ts
@@ -0,0 +1,45 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { injectHlmItemConfig } from './hlm-item-token';
+
+const itemVariants = cva(
+ 'group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors',
+ {
+ variants: {
+ variant: {
+ default: 'bg-transparent',
+ outline: 'border-border',
+ muted: 'bg-muted/50',
+ },
+ size: {
+ default: 'gap-4 p-4',
+ sm: 'gap-2.5 px-4 py-3',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+export type ItemVariants = VariantProps;
+
+@Directive({
+ selector: 'div[hlmItem], a[hlmItem]',
+ host: {
+ 'data-slot': 'item',
+ '[attr.data-variant]': 'variant()',
+ '[attr.data-size]': 'size()',
+ },
+})
+export class HlmItem {
+ private readonly _config = injectHlmItemConfig();
+ public readonly variant = input(this._config.variant);
+ public readonly size = input(this._config.size);
+
+ constructor() {
+ classes(() => itemVariants({ variant: this.variant(), size: this.size() }));
+ }
+}
diff --git a/libs/ui/kbd/src/index.ts b/libs/ui/kbd/src/index.ts
new file mode 100644
index 00000000..ee2c8c2d
--- /dev/null
+++ b/libs/ui/kbd/src/index.ts
@@ -0,0 +1,7 @@
+import { HlmKbd } from './lib/hlm-kbd';
+import { HlmKbdGroup } from './lib/hlm-kbd-group';
+
+export * from './lib/hlm-kbd';
+export * from './lib/hlm-kbd-group';
+
+export const HlmKbdImports = [HlmKbd, HlmKbdGroup] as const;
diff --git a/libs/ui/kbd/src/lib/hlm-kbd-group.ts b/libs/ui/kbd/src/lib/hlm-kbd-group.ts
new file mode 100644
index 00000000..1b2f3d98
--- /dev/null
+++ b/libs/ui/kbd/src/lib/hlm-kbd-group.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'kbd[hlmKbdGroup]',
+ host: {
+ 'data-slot': 'kbd-group',
+ },
+})
+export class HlmKbdGroup {
+ constructor() {
+ classes(() => 'inline-flex items-center gap-1');
+ }
+}
diff --git a/libs/ui/kbd/src/lib/hlm-kbd.ts b/libs/ui/kbd/src/lib/hlm-kbd.ts
new file mode 100644
index 00000000..5f583381
--- /dev/null
+++ b/libs/ui/kbd/src/lib/hlm-kbd.ts
@@ -0,0 +1,18 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'kbd[hlmKbd]',
+ host: {
+ 'data-slot': 'kbd',
+ },
+})
+export class HlmKbd {
+ constructor() {
+ classes(() => [
+ 'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
+ "[&_ng-icon:not([class*='text-'])]:text-xs",
+ '[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
+ ]);
+ }
+}
diff --git a/libs/ui/label/src/index.ts b/libs/ui/label/src/index.ts
new file mode 100644
index 00000000..a230b755
--- /dev/null
+++ b/libs/ui/label/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmLabel } from './lib/hlm-label';
+
+export * from './lib/hlm-label';
+
+export const HlmLabelImports = [HlmLabel] as const;
diff --git a/libs/ui/label/src/lib/hlm-label.ts b/libs/ui/label/src/lib/hlm-label.ts
new file mode 100644
index 00000000..8f61c453
--- /dev/null
+++ b/libs/ui/label/src/lib/hlm-label.ts
@@ -0,0 +1,21 @@
+import { Directive } from '@angular/core';
+import { BrnLabel } from '@spartan-ng/brain/label';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmLabel]',
+ hostDirectives: [
+ {
+ directive: BrnLabel,
+ inputs: ['id'],
+ },
+ ],
+})
+export class HlmLabel {
+ constructor() {
+ classes(
+ () =>
+ 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 peer-data-[disabled]:cursor-not-allowed peer-data-[disabled]:opacity-50 has-[[disabled]]:cursor-not-allowed has-[[disabled]]:opacity-50',
+ );
+ }
+}
diff --git a/libs/ui/popover/src/index.ts b/libs/ui/popover/src/index.ts
new file mode 100644
index 00000000..3eea495a
--- /dev/null
+++ b/libs/ui/popover/src/index.ts
@@ -0,0 +1,11 @@
+import { HlmPopover } from './lib/hlm-popover';
+import { HlmPopoverContent } from './lib/hlm-popover-content';
+import { HlmPopoverPortal } from './lib/hlm-popover-portal';
+import { HlmPopoverTrigger } from './lib/hlm-popover-trigger';
+
+export * from './lib/hlm-popover';
+export * from './lib/hlm-popover-content';
+export * from './lib/hlm-popover-portal';
+export * from './lib/hlm-popover-trigger';
+
+export const HlmPopoverImports = [HlmPopover, HlmPopoverContent, HlmPopoverPortal, HlmPopoverTrigger] as const;
diff --git a/libs/ui/popover/src/lib/hlm-popover-content.ts b/libs/ui/popover/src/lib/hlm-popover-content.ts
new file mode 100644
index 00000000..3fd45eb5
--- /dev/null
+++ b/libs/ui/popover/src/lib/hlm-popover-content.ts
@@ -0,0 +1,24 @@
+import { Directive, ElementRef, Renderer2, effect, inject, signal } from '@angular/core';
+import { injectExposesStateProvider } from '@spartan-ng/brain/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmPopoverContent],hlm-popover-content',
+})
+export class HlmPopoverContent {
+ private readonly _stateProvider = injectExposesStateProvider({ host: true });
+ public state = this._stateProvider.state ?? signal('closed');
+ private readonly _renderer = inject(Renderer2);
+ private readonly _element = inject(ElementRef);
+
+ constructor() {
+ effect(() => {
+ this._renderer.setAttribute(this._element.nativeElement, 'data-state', this.state());
+ });
+
+ classes(
+ () =>
+ 'border-border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative flex w-72 flex-col rounded-md border p-4 shadow-md outline-none',
+ );
+ }
+}
diff --git a/libs/ui/popover/src/lib/hlm-popover-portal.ts b/libs/ui/popover/src/lib/hlm-popover-portal.ts
new file mode 100644
index 00000000..b0ccaa79
--- /dev/null
+++ b/libs/ui/popover/src/lib/hlm-popover-portal.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnPopoverContent } from '@spartan-ng/brain/popover';
+
+@Directive({
+ selector: '[hlmPopoverPortal]',
+ hostDirectives: [{ directive: BrnPopoverContent, inputs: ['context', 'class'] }],
+})
+export class HlmPopoverPortal {}
diff --git a/libs/ui/popover/src/lib/hlm-popover-trigger.ts b/libs/ui/popover/src/lib/hlm-popover-trigger.ts
new file mode 100644
index 00000000..19450d73
--- /dev/null
+++ b/libs/ui/popover/src/lib/hlm-popover-trigger.ts
@@ -0,0 +1,13 @@
+import { Directive } from '@angular/core';
+import { BrnPopoverTrigger } from '@spartan-ng/brain/popover';
+
+@Directive({
+ selector: 'button[hlmPopoverTrigger],button[hlmPopoverTriggerFor]',
+ hostDirectives: [
+ { directive: BrnPopoverTrigger, inputs: ['id', 'brnPopoverTriggerFor: hlmPopoverTriggerFor', 'type'] },
+ ],
+ host: {
+ 'data-slot': 'popover-trigger',
+ },
+})
+export class HlmPopoverTrigger {}
diff --git a/libs/ui/popover/src/lib/hlm-popover.ts b/libs/ui/popover/src/lib/hlm-popover.ts
new file mode 100644
index 00000000..4a7914b7
--- /dev/null
+++ b/libs/ui/popover/src/lib/hlm-popover.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { BrnPopover } from '@spartan-ng/brain/popover';
+
+@Directive({
+ selector: '[hlmPopover],hlm-popover',
+ hostDirectives: [
+ {
+ directive: BrnPopover,
+ inputs: ['align', 'autoFocus', 'closeDelay', 'closeOnOutsidePointerEvents', 'sideOffset', 'state', 'offsetX'],
+ outputs: ['stateChanged', 'closed'],
+ },
+ ],
+})
+export class HlmPopover {}
diff --git a/libs/ui/progress/src/index.ts b/libs/ui/progress/src/index.ts
new file mode 100644
index 00000000..88ce2d84
--- /dev/null
+++ b/libs/ui/progress/src/index.ts
@@ -0,0 +1,7 @@
+import { HlmProgress } from './lib/hlm-progress';
+import { HlmProgressIndicator } from './lib/hlm-progress-indicator';
+
+export * from './lib/hlm-progress';
+export * from './lib/hlm-progress-indicator';
+
+export const HlmProgressImports = [HlmProgress, HlmProgressIndicator] as const;
diff --git a/libs/ui/progress/src/lib/hlm-progress-indicator.ts b/libs/ui/progress/src/lib/hlm-progress-indicator.ts
new file mode 100644
index 00000000..a42401c0
--- /dev/null
+++ b/libs/ui/progress/src/lib/hlm-progress-indicator.ts
@@ -0,0 +1,20 @@
+import { Directive, computed } from '@angular/core';
+import { BrnProgressIndicator, injectBrnProgress } from '@spartan-ng/brain/progress';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmProgressIndicator],hlm-progress-indicator',
+ hostDirectives: [BrnProgressIndicator],
+ host: { '[class.animate-indeterminate]': '_indeterminate()', '[style.transform]': '_transform()' },
+})
+export class HlmProgressIndicator {
+ private readonly _progress = injectBrnProgress();
+ protected readonly _transform = computed(() => `translateX(-${100 - (this._progress.value() ?? 100)}%)`);
+ protected readonly _indeterminate = computed(
+ () => this._progress.value() === null || this._progress.value() === undefined,
+ );
+
+ constructor() {
+ classes(() => 'bg-primary h-full w-full flex-1 transition-all');
+ }
+}
diff --git a/libs/ui/progress/src/lib/hlm-progress.ts b/libs/ui/progress/src/lib/hlm-progress.ts
new file mode 100644
index 00000000..a0bf9b24
--- /dev/null
+++ b/libs/ui/progress/src/lib/hlm-progress.ts
@@ -0,0 +1,13 @@
+import { Directive } from '@angular/core';
+import { BrnProgress } from '@spartan-ng/brain/progress';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'hlm-progress,[hlmProgress]',
+ hostDirectives: [{ directive: BrnProgress, inputs: ['value', 'max', 'getValueLabel'] }],
+})
+export class HlmProgress {
+ constructor() {
+ classes(() => 'bg-primary/20 relative inline-flex h-2 w-full overflow-hidden rounded-full');
+ }
+}
diff --git a/libs/ui/radio-group/src/index.ts b/libs/ui/radio-group/src/index.ts
new file mode 100644
index 00000000..e2f6efeb
--- /dev/null
+++ b/libs/ui/radio-group/src/index.ts
@@ -0,0 +1,9 @@
+import { HlmRadio } from './lib/hlm-radio';
+import { HlmRadioGroup } from './lib/hlm-radio-group';
+import { HlmRadioIndicator } from './lib/hlm-radio-indicator';
+
+export * from './lib/hlm-radio';
+export * from './lib/hlm-radio-group';
+export * from './lib/hlm-radio-indicator';
+
+export const HlmRadioGroupImports = [HlmRadioGroup, HlmRadio, HlmRadioIndicator] as const;
diff --git a/libs/ui/radio-group/src/lib/hlm-radio-group.ts b/libs/ui/radio-group/src/lib/hlm-radio-group.ts
new file mode 100644
index 00000000..fd59b029
--- /dev/null
+++ b/libs/ui/radio-group/src/lib/hlm-radio-group.ts
@@ -0,0 +1,22 @@
+import { Directive } from '@angular/core';
+import { BrnRadioGroup } from '@spartan-ng/brain/radio-group';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmRadioGroup],hlm-radio-group',
+ hostDirectives: [
+ {
+ directive: BrnRadioGroup,
+ inputs: ['name', 'value', 'disabled', 'required'],
+ outputs: ['valueChange'],
+ },
+ ],
+ host: {
+ 'data-slot': 'radio-group',
+ },
+})
+export class HlmRadioGroup {
+ constructor() {
+ classes(() => 'grid gap-3');
+ }
+}
diff --git a/libs/ui/radio-group/src/lib/hlm-radio-indicator.ts b/libs/ui/radio-group/src/lib/hlm-radio-indicator.ts
new file mode 100644
index 00000000..ab037051
--- /dev/null
+++ b/libs/ui/radio-group/src/lib/hlm-radio-indicator.ts
@@ -0,0 +1,21 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-radio-indicator',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-slot': 'radio-group-indicator',
+ },
+ template: `
+
+ `,
+})
+export class HlmRadioIndicator {
+ constructor() {
+ classes(
+ () =>
+ 'border-input text-primary group-has-[:focus-visible]:border-ring group-has-[:focus-visible]:ring-ring/50 dark:bg-input/30 group-data=[disabled=true]:cursor-not-allowed group-data=[disabled=true]:opacity-50 relative flex aspect-square size-4 shrink-0 items-center justify-center rounded-full border shadow-xs transition-[color,box-shadow] outline-none group-has-[:focus-visible]:ring-[3px]',
+ );
+ }
+}
diff --git a/libs/ui/radio-group/src/lib/hlm-radio.ts b/libs/ui/radio-group/src/lib/hlm-radio.ts
new file mode 100644
index 00000000..cd3ff71f
--- /dev/null
+++ b/libs/ui/radio-group/src/lib/hlm-radio.ts
@@ -0,0 +1,107 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { isPlatformBrowser } from '@angular/common';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ DOCUMENT,
+ effect,
+ ElementRef,
+ inject,
+ input,
+ output,
+ PLATFORM_ID,
+ Renderer2,
+} from '@angular/core';
+import { BrnRadio, type BrnRadioChange } from '@spartan-ng/brain/radio-group';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+
+@Component({
+ selector: 'hlm-radio',
+ imports: [BrnRadio],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ '[attr.id]': 'null',
+ '[attr.aria-label]': 'null',
+ '[attr.aria-labelledby]': 'null',
+ '[attr.aria-describedby]': 'null',
+ '[attr.data-disabled]': 'disabled() ? "" : null',
+ 'data-slot': 'radio-group-item',
+ },
+ template: `
+
+
+
+
+ `,
+})
+export class HlmRadio {
+ private readonly _document = inject(DOCUMENT);
+ private readonly _renderer = inject(Renderer2);
+ private readonly _elementRef = inject(ElementRef);
+ private readonly _isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
+
+ public readonly userClass = input('', { alias: 'class' });
+ protected readonly _computedClass = computed(() =>
+ hlm(
+ 'group relative flex items-center gap-x-3',
+ 'data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50',
+ this.userClass(),
+ ),
+ );
+
+ /** Used to set the id on the underlying brn element. */
+ public readonly id = input(undefined);
+
+ /** Used to set the aria-label attribute on the underlying brn element. */
+ public readonly ariaLabel = input(undefined, { alias: 'aria-label' });
+
+ /** Used to set the aria-labelledby attribute on the underlying brn element. */
+ public readonly ariaLabelledby = input(undefined, { alias: 'aria-labelledby' });
+
+ /** Used to set the aria-describedby attribute on the underlying brn element. */
+ public readonly ariaDescribedby = input(undefined, { alias: 'aria-describedby' });
+
+ /**
+ * The value this radio button represents.
+ */
+ public readonly value = input.required();
+
+ /** Whether the checkbox is required. */
+ public readonly required = input(false, { transform: booleanAttribute });
+
+ /** Whether the checkbox is disabled. */
+ public readonly disabled = input(false, { transform: booleanAttribute });
+
+ /**
+ * Event emitted when the checked state of this radio button changes.
+ */
+ // eslint-disable-next-line @angular-eslint/no-output-native
+ public readonly change = output>();
+
+ constructor() {
+ effect(() => {
+ const isDisabled = this.disabled();
+
+ if (!this._elementRef.nativeElement || !this._isBrowser) return;
+
+ const labelElement =
+ this._elementRef.nativeElement.closest('label') ?? this._document.querySelector(`label[for="${this.id()}"]`);
+
+ if (!labelElement) return;
+ this._renderer.setAttribute(labelElement, 'data-disabled', isDisabled ? 'true' : 'false');
+ });
+ }
+}
diff --git a/libs/ui/resizable/src/index.ts b/libs/ui/resizable/src/index.ts
new file mode 100644
index 00000000..5064d015
--- /dev/null
+++ b/libs/ui/resizable/src/index.ts
@@ -0,0 +1,9 @@
+import { HlmResizableGroup } from './lib/hlm-resizable-group';
+import { HlmResizableHandle } from './lib/hlm-resizable-handle';
+import { HlmResizablePanel } from './lib/hlm-resizable-panel';
+
+export * from './lib/hlm-resizable-group';
+export * from './lib/hlm-resizable-handle';
+export * from './lib/hlm-resizable-panel';
+
+export const HlmResizableImports = [HlmResizableGroup, HlmResizablePanel, HlmResizableHandle] as const;
diff --git a/libs/ui/resizable/src/lib/hlm-resizable-group.ts b/libs/ui/resizable/src/lib/hlm-resizable-group.ts
new file mode 100644
index 00000000..e8cd29af
--- /dev/null
+++ b/libs/ui/resizable/src/lib/hlm-resizable-group.ts
@@ -0,0 +1,22 @@
+import { Directive } from '@angular/core';
+import { BrnResizableGroup } from '@spartan-ng/brain/resizable';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmResizableGroup],hlm-resizable-group',
+ hostDirectives: [
+ {
+ directive: BrnResizableGroup,
+ inputs: ['direction', 'layout'],
+ outputs: ['dragEnd', 'dragStart', 'layoutChange'],
+ },
+ ],
+ host: {
+ 'data-slot': 'resizable-group',
+ },
+})
+export class HlmResizableGroup {
+ constructor() {
+ classes(() => 'group flex h-full w-full data-[panel-group-direction=vertical]:flex-col');
+ }
+}
diff --git a/libs/ui/resizable/src/lib/hlm-resizable-handle.ts b/libs/ui/resizable/src/lib/hlm-resizable-handle.ts
new file mode 100644
index 00000000..fe2cf976
--- /dev/null
+++ b/libs/ui/resizable/src/lib/hlm-resizable-handle.ts
@@ -0,0 +1,28 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { BrnResizableHandle } from '@spartan-ng/brain/resizable';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-resizable-handle',
+ exportAs: 'hlmResizableHandle',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [{ directive: BrnResizableHandle, inputs: ['withHandle', 'disabled'] }],
+ host: {
+ 'data-slot': 'resizable-handle',
+ },
+ template: `
+ @if (_brnResizableHandle.withHandle()) {
+
+ }
+ `,
+})
+export class HlmResizableHandle {
+ protected readonly _brnResizableHandle = inject(BrnResizableHandle);
+
+ constructor() {
+ classes(() => [
+ 'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
+ 'data-[panel-group-direction=horizontal]:hover:cursor-ew-resize data-[panel-group-direction=vertical]:hover:cursor-ns-resize',
+ ]);
+ }
+}
diff --git a/libs/ui/resizable/src/lib/hlm-resizable-panel.ts b/libs/ui/resizable/src/lib/hlm-resizable-panel.ts
new file mode 100644
index 00000000..8b173152
--- /dev/null
+++ b/libs/ui/resizable/src/lib/hlm-resizable-panel.ts
@@ -0,0 +1,23 @@
+import { Directive, inject } from '@angular/core';
+import { BrnResizablePanel } from '@spartan-ng/brain/resizable';
+
+@Directive({
+ selector: '[hlmResizablePanel],hlm-resizable-panel',
+ exportAs: 'hlmResizablePanel',
+ hostDirectives: [
+ {
+ directive: BrnResizablePanel,
+ inputs: ['defaultSize', 'id', 'collapsible', 'maxSize', 'minSize'],
+ },
+ ],
+ host: {
+ 'data-slot': 'resizable-panel',
+ },
+})
+export class HlmResizablePanel {
+ private readonly _resizablePanel = inject(BrnResizablePanel);
+
+ public setSize(size: number) {
+ this._resizablePanel.setSize(size);
+ }
+}
diff --git a/libs/ui/scroll-area/src/index.ts b/libs/ui/scroll-area/src/index.ts
new file mode 100644
index 00000000..7176ff42
--- /dev/null
+++ b/libs/ui/scroll-area/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmScrollArea } from './lib/hlm-scroll-area';
+
+export * from './lib/hlm-scroll-area';
+
+export const HlmScrollAreaImports = [HlmScrollArea] as const;
diff --git a/libs/ui/scroll-area/src/lib/hlm-scroll-area.ts b/libs/ui/scroll-area/src/lib/hlm-scroll-area.ts
new file mode 100644
index 00000000..dde8a747
--- /dev/null
+++ b/libs/ui/scroll-area/src/lib/hlm-scroll-area.ts
@@ -0,0 +1,19 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'ng-scrollbar[hlm],ng-scrollbar[hlmScrollbar]',
+ host: {
+ 'data-slot': 'scroll-area',
+ '[style.--scrollbar-border-radius]': '100 + "px"',
+ '[style.--scrollbar-offset]': '3',
+ '[style.--scrollbar-thumb-color]': '"var(--border)"',
+ '[style.--scrollbar-thumb-hover-color]': '"var(--border)"',
+ '[style.--scrollbar-thickness]': '7',
+ },
+})
+export class HlmScrollArea {
+ constructor() {
+ classes(() => 'block');
+ }
+}
diff --git a/libs/ui/select/src/index.ts b/libs/ui/select/src/index.ts
new file mode 100644
index 00000000..5b3935cc
--- /dev/null
+++ b/libs/ui/select/src/index.ts
@@ -0,0 +1,31 @@
+import { HlmSelect } from './lib/hlm-select';
+import { HlmSelectContent } from './lib/hlm-select-content';
+import { HlmSelectGroup } from './lib/hlm-select-group';
+import { HlmSelectLabel } from './lib/hlm-select-label';
+import { HlmSelectOption } from './lib/hlm-select-option';
+import { HlmSelectScrollDown } from './lib/hlm-select-scroll-down';
+import { HlmSelectScrollUp } from './lib/hlm-select-scroll-up';
+import { HlmSelectTrigger } from './lib/hlm-select-trigger';
+import { HlmSelectValue } from './lib/hlm-select-value';
+
+export * from './lib/hlm-select';
+export * from './lib/hlm-select-content';
+export * from './lib/hlm-select-group';
+export * from './lib/hlm-select-label';
+export * from './lib/hlm-select-option';
+export * from './lib/hlm-select-scroll-down';
+export * from './lib/hlm-select-scroll-up';
+export * from './lib/hlm-select-trigger';
+export * from './lib/hlm-select-value';
+
+export const HlmSelectImports = [
+ HlmSelectContent,
+ HlmSelectTrigger,
+ HlmSelectOption,
+ HlmSelectValue,
+ HlmSelect,
+ HlmSelectScrollUp,
+ HlmSelectScrollDown,
+ HlmSelectLabel,
+ HlmSelectGroup,
+] as const;
diff --git a/libs/ui/select/src/lib/hlm-select-content.ts b/libs/ui/select/src/lib/hlm-select-content.ts
new file mode 100644
index 00000000..f1aefa37
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select-content.ts
@@ -0,0 +1,26 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import { Directive, booleanAttribute, input } from '@angular/core';
+import { injectExposedSideProvider, injectExposesStateProvider } from '@spartan-ng/brain/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSelectContent], hlm-select-content',
+ host: {
+ '[attr.data-state]': '_stateProvider?.state() ?? "open"',
+ '[attr.data-side]': '_sideProvider?.side() ?? "bottom"',
+ },
+})
+export class HlmSelectContent {
+ public readonly stickyLabels = input(false, {
+ transform: booleanAttribute,
+ });
+ protected readonly _stateProvider = injectExposesStateProvider({ optional: true });
+ protected readonly _sideProvider = injectExposedSideProvider({ optional: true });
+
+ constructor() {
+ classes(
+ () =>
+ 'border-border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 w-full min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:top-[2px] data-[side=top]:bottom-[2px]',
+ );
+ }
+}
diff --git a/libs/ui/select/src/lib/hlm-select-group.ts b/libs/ui/select/src/lib/hlm-select-group.ts
new file mode 100644
index 00000000..74ca6a21
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select-group.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnSelectGroup } from '@spartan-ng/brain/select';
+
+@Directive({
+ selector: '[hlmSelectGroup], hlm-select-group',
+ hostDirectives: [BrnSelectGroup],
+})
+export class HlmSelectGroup {}
diff --git a/libs/ui/select/src/lib/hlm-select-label.ts b/libs/ui/select/src/lib/hlm-select-label.ts
new file mode 100644
index 00000000..c78e1a60
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select-label.ts
@@ -0,0 +1,20 @@
+import { computed, Directive, inject } from '@angular/core';
+import { BrnSelectLabel } from '@spartan-ng/brain/select';
+import { classes } from '@spartan-ng/helm/utils';
+import { HlmSelectContent } from './hlm-select-content';
+
+@Directive({
+ selector: '[hlmSelectLabel], hlm-select-label',
+ hostDirectives: [BrnSelectLabel],
+})
+export class HlmSelectLabel {
+ private readonly _selectContent = inject(HlmSelectContent);
+ private readonly _stickyLabels = computed(() => this._selectContent.stickyLabels());
+
+ constructor() {
+ classes(() => [
+ 'text-muted-foreground px-2 py-1.5 text-xs',
+ this._stickyLabels() ? 'bg-popover sticky top-0 z-[2] block' : '',
+ ]);
+ }
+}
diff --git a/libs/ui/select/src/lib/hlm-select-option.ts b/libs/ui/select/src/lib/hlm-select-option.ts
new file mode 100644
index 00000000..ea1f8c48
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select-option.ts
@@ -0,0 +1,32 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideCheck } from '@ng-icons/lucide';
+import { BrnSelectOption } from '@spartan-ng/brain/select';
+import { HlmIcon } from '@spartan-ng/helm/icon';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-option',
+ imports: [NgIcon, HlmIcon],
+ providers: [provideIcons({ lucideCheck })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [{ directive: BrnSelectOption, inputs: ['disabled', 'value'] }],
+ template: `
+
+ @if (this._brnSelectOption.selected()) {
+
+ }
+
+
+
+ `,
+})
+export class HlmSelectOption {
+ protected readonly _brnSelectOption = inject(BrnSelectOption, { host: true });
+ constructor() {
+ classes(
+ () =>
+ `data-[active]:bg-accent data-[active]:text-accent-foreground [&>ng-icon:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 [&>ng-icon]:pointer-events-none [&>ng-icon]:size-4 [&>ng-icon]:shrink-0`,
+ );
+ }
+}
diff --git a/libs/ui/select/src/lib/hlm-select-scroll-down.ts b/libs/ui/select/src/lib/hlm-select-scroll-down.ts
new file mode 100644
index 00000000..e22eddf2
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select-scroll-down.ts
@@ -0,0 +1,20 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronDown } from '@ng-icons/lucide';
+import { HlmIcon } from '@spartan-ng/helm/icon';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-select-scroll-down',
+ imports: [NgIcon, HlmIcon],
+ providers: [provideIcons({ lucideChevronDown })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+})
+export class HlmSelectScrollDown {
+ constructor() {
+ classes(() => 'flex cursor-default items-center justify-center py-1');
+ }
+}
diff --git a/libs/ui/select/src/lib/hlm-select-scroll-up.ts b/libs/ui/select/src/lib/hlm-select-scroll-up.ts
new file mode 100644
index 00000000..505d36d4
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select-scroll-up.ts
@@ -0,0 +1,20 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronUp } from '@ng-icons/lucide';
+import { HlmIcon } from '@spartan-ng/helm/icon';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-select-scroll-up',
+ imports: [NgIcon, HlmIcon],
+ providers: [provideIcons({ lucideChevronUp })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+})
+export class HlmSelectScrollUp {
+ constructor() {
+ classes(() => 'flex cursor-default items-center justify-center py-1');
+ }
+}
diff --git a/libs/ui/select/src/lib/hlm-select-trigger.ts b/libs/ui/select/src/lib/hlm-select-trigger.ts
new file mode 100644
index 00000000..3d03b858
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select-trigger.ts
@@ -0,0 +1,53 @@
+import { ChangeDetectionStrategy, Component, computed, contentChild, inject, input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronDown } from '@ng-icons/lucide';
+import { BrnSelect, BrnSelectTrigger } from '@spartan-ng/brain/select';
+import { HlmIcon } from '@spartan-ng/helm/icon';
+import { hlm } from '@spartan-ng/helm/utils';
+import { cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const selectTriggerVariants = cva(
+ `border-input [&>ng-icon:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 [&>ng-icon]:pointer-events-none [&>ng-icon]:size-4 [&>ng-icon]:shrink-0`,
+ {
+ variants: {
+ error: {
+ auto: '[&.ng-invalid.ng-touched]:text-destructive [&.ng-invalid.ng-touched]:border-destructive [&.ng-invalid.ng-touched]:focus-visible:ring-destructive/20 dark:[&.ng-invalid.ng-touched]:focus-visible:ring-destructive/40',
+ true: 'text-destructive border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
+ },
+ },
+ defaultVariants: {
+ error: 'auto',
+ },
+ },
+);
+
+@Component({
+ selector: 'hlm-select-trigger',
+ imports: [BrnSelectTrigger, NgIcon, HlmIcon],
+ providers: [provideIcons({ lucideChevronDown })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+})
+export class HlmSelectTrigger {
+ protected readonly _icon = contentChild(HlmIcon);
+
+ protected readonly _brnSelect = inject(BrnSelect, { optional: true });
+
+ public readonly userClass = input('', { alias: 'class' });
+
+ public readonly size = input<'default' | 'sm'>('default');
+
+ protected readonly _computedClass = computed(() =>
+ hlm(selectTriggerVariants({ error: this._brnSelect?.errorState() }), this.userClass()),
+ );
+}
diff --git a/libs/ui/select/src/lib/hlm-select-value.ts b/libs/ui/select/src/lib/hlm-select-value.ts
new file mode 100644
index 00000000..bc06835f
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select-value.ts
@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'hlm-select-value,[hlmSelectValue], brn-select-value[hlm]',
+})
+export class HlmSelectValue {
+ constructor() {
+ classes(() => 'data-[placeholder]:text-muted-foreground line-clamp-1 flex items-center gap-2 truncate');
+ }
+}
diff --git a/libs/ui/select/src/lib/hlm-select.ts b/libs/ui/select/src/lib/hlm-select.ts
new file mode 100644
index 00000000..e3e2947f
--- /dev/null
+++ b/libs/ui/select/src/lib/hlm-select.ts
@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'hlm-select, brn-select [hlm]',
+})
+export class HlmSelect {
+ constructor() {
+ classes(() => 'space-y-2');
+ }
+}
diff --git a/libs/ui/separator/src/index.ts b/libs/ui/separator/src/index.ts
new file mode 100644
index 00000000..34d2b87e
--- /dev/null
+++ b/libs/ui/separator/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmSeparator } from './lib/hlm-separator';
+
+export * from './lib/hlm-separator';
+
+export const HlmSeparatorImports = [HlmSeparator] as const;
diff --git a/libs/ui/separator/src/lib/hlm-separator.ts b/libs/ui/separator/src/lib/hlm-separator.ts
new file mode 100644
index 00000000..8a140d3a
--- /dev/null
+++ b/libs/ui/separator/src/lib/hlm-separator.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnSeparator } from '@spartan-ng/brain/separator';
+import { classes } from '@spartan-ng/helm/utils';
+
+export const hlmSeparatorClass =
+ 'bg-border inline-flex shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px';
+
+@Directive({
+ selector: '[hlmSeparator],hlm-separator',
+ hostDirectives: [{ directive: BrnSeparator, inputs: ['orientation', 'decorative'] }],
+})
+export class HlmSeparator {
+ constructor() {
+ classes(() => hlmSeparatorClass);
+ }
+}
diff --git a/libs/ui/sheet/src/index.ts b/libs/ui/sheet/src/index.ts
new file mode 100644
index 00000000..91f95f4c
--- /dev/null
+++ b/libs/ui/sheet/src/index.ts
@@ -0,0 +1,34 @@
+import { HlmSheet } from './lib/hlm-sheet';
+import { HlmSheetClose } from './lib/hlm-sheet-close';
+import { HlmSheetContent } from './lib/hlm-sheet-content';
+import { HlmSheetDescription } from './lib/hlm-sheet-description';
+import { HlmSheetFooter } from './lib/hlm-sheet-footer';
+import { HlmSheetHeader } from './lib/hlm-sheet-header';
+import { HlmSheetOverlay } from './lib/hlm-sheet-overlay';
+import { HlmSheetPortal } from './lib/hlm-sheet-portal';
+import { HlmSheetTitle } from './lib/hlm-sheet-title';
+import { HlmSheetTrigger } from './lib/hlm-sheet-trigger';
+
+export * from './lib/hlm-sheet';
+export * from './lib/hlm-sheet-close';
+export * from './lib/hlm-sheet-content';
+export * from './lib/hlm-sheet-description';
+export * from './lib/hlm-sheet-footer';
+export * from './lib/hlm-sheet-header';
+export * from './lib/hlm-sheet-overlay';
+export * from './lib/hlm-sheet-portal';
+export * from './lib/hlm-sheet-title';
+export * from './lib/hlm-sheet-trigger';
+
+export const HlmSheetImports = [
+ HlmSheet,
+ HlmSheetClose,
+ HlmSheetContent,
+ HlmSheetDescription,
+ HlmSheetFooter,
+ HlmSheetHeader,
+ HlmSheetOverlay,
+ HlmSheetPortal,
+ HlmSheetTitle,
+ HlmSheetTrigger,
+] as const;
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-close.ts b/libs/ui/sheet/src/lib/hlm-sheet-close.ts
new file mode 100644
index 00000000..7f781318
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-close.ts
@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+import { BrnSheetClose } from '@spartan-ng/brain/sheet';
+
+@Directive({
+ selector: 'button[hlmSheetClose]',
+ hostDirectives: [{ directive: BrnSheetClose, inputs: ['delay'] }],
+ host: {
+ 'data-slot': 'sheet-close',
+ },
+})
+export class HlmSheetClose {}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-content.ts b/libs/ui/sheet/src/lib/hlm-sheet-content.ts
new file mode 100644
index 00000000..886fc554
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-content.ts
@@ -0,0 +1,76 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ effect,
+ ElementRef,
+ inject,
+ input,
+ Renderer2,
+ signal,
+} from '@angular/core';
+import { provideIcons } from '@ng-icons/core';
+import { lucideX } from '@ng-icons/lucide';
+import { injectExposedSideProvider, injectExposesStateProvider } from '@spartan-ng/brain/core';
+import { HlmButton } from '@spartan-ng/helm/button';
+import { HlmIconImports } from '@spartan-ng/helm/icon';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva } from 'class-variance-authority';
+import { HlmSheetClose } from './hlm-sheet-close';
+
+export const sheetVariants = cva(
+ 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
+ {
+ variants: {
+ side: {
+ top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
+ bottom:
+ 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
+ left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
+ right:
+ 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
+ },
+ },
+ defaultVariants: {
+ side: 'right',
+ },
+ },
+);
+
+@Component({
+ selector: 'hlm-sheet-content',
+ imports: [HlmIconImports, HlmButton, HlmSheetClose],
+ providers: [provideIcons({ lucideX })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-slot': 'sheet-content',
+ '[attr.data-state]': 'state()',
+ },
+ template: `
+
+
+ @if (showCloseButton()) {
+
+ }
+ `,
+})
+export class HlmSheetContent {
+ private readonly _stateProvider = injectExposesStateProvider({ host: true });
+ private readonly _sideProvider = injectExposedSideProvider({ host: true });
+ public readonly state = this._stateProvider.state ?? signal('closed');
+ private readonly _renderer = inject(Renderer2);
+ private readonly _element = inject(ElementRef);
+
+ public readonly showCloseButton = input(true, { transform: booleanAttribute });
+
+ constructor() {
+ classes(() => sheetVariants({ side: this._sideProvider.side() }));
+ effect(() => {
+ this._renderer.setAttribute(this._element.nativeElement, 'data-state', this.state());
+ });
+ }
+}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-description.ts b/libs/ui/sheet/src/lib/hlm-sheet-description.ts
new file mode 100644
index 00000000..3aa2c771
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-description.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnSheetDescription } from '@spartan-ng/brain/sheet';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSheetDescription]',
+ hostDirectives: [BrnSheetDescription],
+ host: {
+ 'data-slot': 'sheet-description',
+ },
+})
+export class HlmSheetDescription {
+ constructor() {
+ classes(() => 'text-muted-foreground text-sm');
+ }
+}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-footer.ts b/libs/ui/sheet/src/lib/hlm-sheet-footer.ts
new file mode 100644
index 00000000..ccd6e86b
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-footer.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSheetFooter],hlm-sheet-footer',
+ host: {
+ 'data-slot': 'sheet-footer',
+ },
+})
+export class HlmSheetFooter {
+ constructor() {
+ classes(() => 'mt-auto flex flex-col gap-2 p-4');
+ }
+}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-header.ts b/libs/ui/sheet/src/lib/hlm-sheet-header.ts
new file mode 100644
index 00000000..1ce8c7cb
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-header.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSheetHeader],hlm-sheet-header',
+ host: {
+ 'data-slot': 'sheet-header',
+ },
+})
+export class HlmSheetHeader {
+ constructor() {
+ classes(() => 'flex flex-col gap-1.5 p-4');
+ }
+}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-overlay.ts b/libs/ui/sheet/src/lib/hlm-sheet-overlay.ts
new file mode 100644
index 00000000..24dd97db
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-overlay.ts
@@ -0,0 +1,30 @@
+import { Directive, computed, effect, input, untracked } from '@angular/core';
+import { injectCustomClassSettable } from '@spartan-ng/brain/core';
+import { BrnSheetOverlay } from '@spartan-ng/brain/sheet';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+
+@Directive({
+ selector: '[hlmSheetOverlay],hlm-sheet-overlay',
+ hostDirectives: [BrnSheetOverlay],
+ host: {
+ '[class]': '_computedClass()',
+ },
+})
+export class HlmSheetOverlay {
+ private readonly _classSettable = injectCustomClassSettable({ optional: true, host: true });
+ public readonly userClass = input('', { alias: 'class' });
+ protected readonly _computedClass = computed(() =>
+ hlm(
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-black/50',
+ this.userClass(),
+ ),
+ );
+
+ constructor() {
+ effect(() => {
+ const classValue = this._computedClass();
+ untracked(() => this._classSettable?.setClassToCustomElement(classValue));
+ });
+ }
+}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-portal.ts b/libs/ui/sheet/src/lib/hlm-sheet-portal.ts
new file mode 100644
index 00000000..2fefad33
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-portal.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnSheetContent } from '@spartan-ng/brain/sheet';
+
+@Directive({
+ selector: '[hlmSheetPortal]',
+ hostDirectives: [{ directive: BrnSheetContent, inputs: ['context', 'class'] }],
+})
+export class HlmSheetPortal {}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-title.ts b/libs/ui/sheet/src/lib/hlm-sheet-title.ts
new file mode 100644
index 00000000..b0dc935f
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-title.ts
@@ -0,0 +1,16 @@
+import { Directive } from '@angular/core';
+import { BrnSheetTitle } from '@spartan-ng/brain/sheet';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSheetTitle]',
+ hostDirectives: [BrnSheetTitle],
+ host: {
+ 'data-slot': 'sheet-title',
+ },
+})
+export class HlmSheetTitle {
+ constructor() {
+ classes(() => 'text-foreground font-semibold');
+ }
+}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet-trigger.ts b/libs/ui/sheet/src/lib/hlm-sheet-trigger.ts
new file mode 100644
index 00000000..fe9736d8
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet-trigger.ts
@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+import { BrnSheetTrigger } from '@spartan-ng/brain/sheet';
+
+@Directive({
+ selector: 'button[hlmSheetTrigger]',
+ hostDirectives: [{ directive: BrnSheetTrigger, inputs: ['id', 'side', 'type'] }],
+ host: {
+ 'data-slot': 'sheet-trigger',
+ },
+})
+export class HlmSheetTrigger {}
diff --git a/libs/ui/sheet/src/lib/hlm-sheet.ts b/libs/ui/sheet/src/lib/hlm-sheet.ts
new file mode 100644
index 00000000..bf85c140
--- /dev/null
+++ b/libs/ui/sheet/src/lib/hlm-sheet.ts
@@ -0,0 +1,29 @@
+import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core';
+import { BrnDialog, provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
+import { BrnSheet } from '@spartan-ng/brain/sheet';
+import { HlmSheetOverlay } from './hlm-sheet-overlay';
+
+@Component({
+ selector: 'hlm-sheet',
+ exportAs: 'hlmSheet',
+ imports: [HlmSheetOverlay],
+ providers: [
+ {
+ provide: BrnDialog,
+ useExisting: forwardRef(() => BrnSheet),
+ },
+ {
+ provide: BrnSheet,
+ useExisting: forwardRef(() => HlmSheet),
+ },
+ provideBrnDialogDefaultOptions({
+ // add custom options here
+ }),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+ `,
+})
+export class HlmSheet extends BrnSheet {}
diff --git a/libs/ui/sidebar/src/index.ts b/libs/ui/sidebar/src/index.ts
new file mode 100644
index 00000000..c5a16dc6
--- /dev/null
+++ b/libs/ui/sidebar/src/index.ts
@@ -0,0 +1,75 @@
+import { HlmSidebar } from './lib/hlm-sidebar';
+import { HlmSidebarContent } from './lib/hlm-sidebar-content';
+import { HlmSidebarFooter } from './lib/hlm-sidebar-footer';
+import { HlmSidebarGroup } from './lib/hlm-sidebar-group';
+import { HlmSidebarGroupAction } from './lib/hlm-sidebar-group-action';
+import { HlmSidebarGroupContent } from './lib/hlm-sidebar-group-content';
+import { HlmSidebarGroupLabel } from './lib/hlm-sidebar-group-label';
+import { HlmSidebarHeader } from './lib/hlm-sidebar-header';
+import { HlmSidebarInput } from './lib/hlm-sidebar-input';
+import { HlmSidebarInset } from './lib/hlm-sidebar-inset';
+import { HlmSidebarMenu } from './lib/hlm-sidebar-menu';
+import { HlmSidebarMenuAction } from './lib/hlm-sidebar-menu-action';
+import { HlmSidebarMenuBadge } from './lib/hlm-sidebar-menu-badge';
+import { HlmSidebarMenuButton } from './lib/hlm-sidebar-menu-button';
+import { HlmSidebarMenuItem } from './lib/hlm-sidebar-menu-item';
+import { HlmSidebarMenuSkeleton } from './lib/hlm-sidebar-menu-skeleton';
+import { HlmSidebarMenuSub } from './lib/hlm-sidebar-menu-sub';
+import { HlmSidebarMenuSubButton } from './lib/hlm-sidebar-menu-sub-button';
+import { HlmSidebarMenuSubItem } from './lib/hlm-sidebar-menu-sub-item';
+import { HlmSidebarRail } from './lib/hlm-sidebar-rail';
+import { HlmSidebarSeparator } from './lib/hlm-sidebar-separator';
+import { HlmSidebarTrigger } from './lib/hlm-sidebar-trigger';
+import { HlmSidebarWrapper } from './lib/hlm-sidebar-wrapper';
+
+export * from './lib/hlm-sidebar';
+export * from './lib/hlm-sidebar-content';
+export * from './lib/hlm-sidebar-footer';
+export * from './lib/hlm-sidebar-group';
+export * from './lib/hlm-sidebar-group-action';
+export * from './lib/hlm-sidebar-group-content';
+export * from './lib/hlm-sidebar-group-label';
+export * from './lib/hlm-sidebar-header';
+export * from './lib/hlm-sidebar-input';
+export * from './lib/hlm-sidebar-inset';
+export * from './lib/hlm-sidebar-menu';
+export * from './lib/hlm-sidebar-menu-action';
+export * from './lib/hlm-sidebar-menu-badge';
+export * from './lib/hlm-sidebar-menu-button';
+export * from './lib/hlm-sidebar-menu-item';
+export * from './lib/hlm-sidebar-menu-skeleton';
+export * from './lib/hlm-sidebar-menu-sub';
+export * from './lib/hlm-sidebar-menu-sub-button';
+export * from './lib/hlm-sidebar-menu-sub-item';
+export * from './lib/hlm-sidebar-rail';
+export * from './lib/hlm-sidebar-separator';
+export * from './lib/hlm-sidebar-trigger';
+export * from './lib/hlm-sidebar-wrapper';
+export * from './lib/hlm-sidebar.service';
+export * from './lib/hlm-sidebar.token';
+
+export const HlmSidebarImports = [
+ HlmSidebar,
+ HlmSidebarContent,
+ HlmSidebarFooter,
+ HlmSidebarGroup,
+ HlmSidebarGroupAction,
+ HlmSidebarGroupContent,
+ HlmSidebarGroupLabel,
+ HlmSidebarHeader,
+ HlmSidebarInput,
+ HlmSidebarInset,
+ HlmSidebarMenu,
+ HlmSidebarMenuSkeleton,
+ HlmSidebarMenuAction,
+ HlmSidebarMenuBadge,
+ HlmSidebarMenuButton,
+ HlmSidebarMenuItem,
+ HlmSidebarMenuSub,
+ HlmSidebarMenuSubButton,
+ HlmSidebarRail,
+ HlmSidebarSeparator,
+ HlmSidebarTrigger,
+ HlmSidebarWrapper,
+ HlmSidebarMenuSubItem,
+] as const;
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-content.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-content.ts
new file mode 100644
index 00000000..da6ca8c9
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-content.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSidebarContent],hlm-sidebar-content',
+ host: {
+ 'data-slot': 'sidebar-content',
+ 'data-sidebar': 'content',
+ },
+})
+export class HlmSidebarContent {
+ constructor() {
+ classes(() => 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-footer.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-footer.ts
new file mode 100644
index 00000000..107438de
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-footer.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSidebarFooter],hlm-sidebar-footer',
+ host: {
+ 'data-slot': 'sidebar-footer',
+ 'data-sidebar': 'footer',
+ },
+})
+export class HlmSidebarFooter {
+ constructor() {
+ classes(() => 'flex flex-col gap-2 p-2');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-group-action.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-group-action.ts
new file mode 100644
index 00000000..f5deb97b
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-group-action.ts
@@ -0,0 +1,20 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'button[hlmSidebarGroupAction]',
+ host: {
+ 'data-slot': 'sidebar-group-action',
+ 'data-sidebar': 'group-action',
+ },
+})
+export class HlmSidebarGroupAction {
+ constructor() {
+ classes(() => [
+ 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform outline-none hover:cursor-pointer focus-visible:ring-2 disabled:hover:cursor-default [&>_ng-icon]:size-4 [&>_ng-icon]:shrink-0',
+ // Increases the hit area of the button on mobile.
+ 'after:absolute after:-inset-2 after:md:hidden',
+ 'group-data-[collapsible=icon]:hidden',
+ ]);
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-group-content.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-group-content.ts
new file mode 100644
index 00000000..aac22739
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-group-content.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'div[hlmSidebarGroupContent]',
+ host: {
+ 'data-slot': 'sidebar-group-content',
+ 'data-sidebar': 'group-content',
+ },
+})
+export class HlmSidebarGroupContent {
+ constructor() {
+ classes(() => 'w-full text-sm');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-group-label.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-group-label.ts
new file mode 100644
index 00000000..1f7fff95
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-group-label.ts
@@ -0,0 +1,18 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'div[hlmSidebarGroupLabel], button[hlmSidebarGroupLabel]',
+ host: {
+ 'data-slot': 'sidebar-group-label',
+ 'data-sidebar': 'group-label',
+ },
+})
+export class HlmSidebarGroupLabel {
+ constructor() {
+ classes(() => [
+ 'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opa] duration-200 ease-linear outline-none focus-visible:ring-2 [&>_ng-icon]:size-4 [&>_ng-icon]:shrink-0',
+ 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
+ ]);
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-group.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-group.ts
new file mode 100644
index 00000000..250331f2
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-group.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSidebarGroup],hlm-sidebar-group',
+ host: {
+ 'data-slot': 'sidebar-group',
+ 'data-sidebar': 'group',
+ },
+})
+export class HlmSidebarGroup {
+ constructor() {
+ classes(() => 'relative flex w-full min-w-0 flex-col p-2');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-header.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-header.ts
new file mode 100644
index 00000000..c51ca326
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-header.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSidebarHeader],hlm-sidebar-header',
+ host: {
+ 'data-slot': 'sidebar-header',
+ 'data-sidebar': 'header',
+ },
+})
+export class HlmSidebarHeader {
+ constructor() {
+ classes(() => 'flex flex-col gap-2 p-2');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-input.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-input.ts
new file mode 100644
index 00000000..adaba978
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-input.ts
@@ -0,0 +1,20 @@
+import { Directive } from '@angular/core';
+import { HlmInput, inputVariants } from '@spartan-ng/helm/input';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'input[hlmSidebarInput]',
+ host: {
+ 'data-slot': 'sidebar-input',
+ 'data-sidebar': 'input',
+ },
+})
+export class HlmSidebarInput extends HlmInput {
+ constructor() {
+ super();
+ classes(() => [
+ inputVariants({ error: this._state().error }),
+ 'bg-background focus-visible:ring-sidebar-ring h-8 w-full shadow-none focus-visible:ring-2',
+ ]);
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-inset.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-inset.ts
new file mode 100644
index 00000000..3636c305
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-inset.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'main[hlmSidebarInset]',
+ host: {
+ 'data-slot': 'sidebar-inset',
+ },
+})
+export class HlmSidebarInset {
+ constructor() {
+ classes(() => [
+ 'bg-background relative flex w-full flex-1 flex-col',
+ 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
+ ]);
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu-action.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-action.ts
new file mode 100644
index 00000000..afa0cff7
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-action.ts
@@ -0,0 +1,28 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { booleanAttribute, Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'button[hlmSidebarMenuAction]',
+ host: {
+ 'data-slot': 'sidebar-menu-action',
+ 'data-sidebar': 'menu-action',
+ },
+})
+export class HlmSidebarMenuAction {
+ public readonly showOnHover = input(false, { transform: booleanAttribute });
+
+ constructor() {
+ classes(() => [
+ 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform outline-none hover:cursor-pointer focus-visible:ring-2 disabled:hover:cursor-default [&>_ng-icon]:size-4 [&>_ng-icon]:shrink-0',
+ // Increases the hit area of the button on mobile.
+ 'after:absolute after:-inset-2 after:md:hidden',
+ 'peer-data-[size=sm]/menu-button:top-1',
+ 'peer-data-[size=default]/menu-button:top-1.5',
+ 'peer-data-[size=lg]/menu-button:top-2.5',
+ 'group-data-[collapsible=icon]:hidden',
+ this.showOnHover() &&
+ 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
+ ]);
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu-badge.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-badge.ts
new file mode 100644
index 00000000..7c5cd6fc
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-badge.ts
@@ -0,0 +1,22 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSidebarMenuBadge],hlm-sidebar-menu-badge',
+ host: {
+ 'data-slot': 'sidebar-menu-badge',
+ 'data-sidebar': 'menu-badge',
+ },
+})
+export class HlmSidebarMenuBadge {
+ constructor() {
+ classes(() => [
+ 'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
+ 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
+ 'peer-data-[size=sm]/menu-button:top-1',
+ 'peer-data-[size=default]/menu-button:top-1.5',
+ 'peer-data-[size=lg]/menu-button:top-2.5',
+ 'group-data-[collapsible=icon]:hidden',
+ ]);
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu-button.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-button.ts
new file mode 100644
index 00000000..478badf3
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-button.ts
@@ -0,0 +1,68 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { booleanAttribute, computed, Directive, inject, input } from '@angular/core';
+import { BrnTooltip, provideBrnTooltipDefaultOptions } from '@spartan-ng/brain/tooltip';
+import { DEFAULT_TOOLTIP_CONTENT_CLASSES } from '@spartan-ng/helm/tooltip';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva } from 'class-variance-authority';
+import { HlmSidebarService } from './hlm-sidebar.service';
+
+const sidebarMenuButtonVariants = cva(
+ 'peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center justify-start gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] outline-none group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 hover:cursor-pointer focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 disabled:hover:cursor-default aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>_ng-icon]:size-4 [&>_ng-icon]:shrink-0 group-data-[collapsible=icon]:[&>span]:hidden [&>span:last-child]:truncate',
+ {
+ variants: {
+ variant: {
+ default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
+ outline:
+ 'bg-background shadow-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-sidebar-accent',
+ },
+ size: {
+ default: 'h-8 text-sm',
+ sm: 'h-7 text-xs',
+ lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+@Directive({
+ selector: 'button[hlmSidebarMenuButton], a[hlmSidebarMenuButton]',
+ providers: [
+ provideBrnTooltipDefaultOptions({
+ showDelay: 150,
+ hideDelay: 0,
+ tooltipContentClasses: DEFAULT_TOOLTIP_CONTENT_CLASSES,
+ position: 'left',
+ }),
+ ],
+ hostDirectives: [
+ {
+ directive: BrnTooltip,
+ inputs: ['brnTooltip: tooltip'],
+ },
+ ],
+ host: {
+ 'data-slot': 'sidebar-menu-button',
+ 'data-sidebar': 'menu-button',
+ '[attr.data-size]': 'size()',
+ '[attr.data-active]': 'isActive()',
+ },
+})
+export class HlmSidebarMenuButton {
+ private readonly _sidebarService = inject(HlmSidebarService);
+
+ public readonly variant = input<'default' | 'outline'>('default');
+ public readonly size = input<'default' | 'sm' | 'lg'>('default');
+ public readonly isActive = input(false, { transform: booleanAttribute });
+
+ protected readonly _isTooltipHidden = computed(
+ () => this._sidebarService.state() !== 'collapsed' || this._sidebarService.isMobile(),
+ );
+
+ constructor() {
+ classes(() => sidebarMenuButtonVariants({ variant: this.variant(), size: this.size() }));
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu-item.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-item.ts
new file mode 100644
index 00000000..b48b99b4
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-item.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'li[hlmSidebarMenuItem]',
+ host: {
+ 'data-slot': 'sidebar-menu-item',
+ 'data-sidebar': 'menu-item',
+ },
+})
+export class HlmSidebarMenuItem {
+ constructor() {
+ classes(() => 'group/menu-item relative');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu-skeleton.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-skeleton.ts
new file mode 100644
index 00000000..47f40d37
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-skeleton.ts
@@ -0,0 +1,30 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { HlmSkeletonImports } from '@spartan-ng/helm/skeleton';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-sidebar-menu-skeleton,div[hlmSidebarMenuSkeleton]',
+ imports: [HlmSkeletonImports],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-slot': 'sidebar-menu-skeleton',
+ 'data-sidebar': 'menu-skeleton',
+ '[style.--skeleton-width]': '_width',
+ },
+ template: `
+ @if (showIcon()) {
+
+ } @else {
+
+ }
+ `,
+})
+export class HlmSidebarMenuSkeleton {
+ public readonly showIcon = input(false, { transform: booleanAttribute });
+ protected readonly _width = `${Math.floor(Math.random() * 40) + 50}%`;
+
+ constructor() {
+ classes(() => 'flex h-8 items-center gap-2 rounded-md px-2');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub-button.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub-button.ts
new file mode 100644
index 00000000..92fce656
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub-button.ts
@@ -0,0 +1,25 @@
+import { type BooleanInput } from '@angular/cdk/coercion';
+import { booleanAttribute, Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'a[hlmSidebarMenuSubButton], button[hlmSidebarMenuSubButton]',
+ host: {
+ 'data-slot': 'sidebar-menu-sub-button',
+ 'data-sidebar': 'menu-sub-button',
+ '[attr.data-active]': 'isActive()',
+ '[attr.data-size]': 'size()',
+ },
+})
+export class HlmSidebarMenuSubButton {
+ public readonly size = input<'sm' | 'md'>('md');
+ public readonly isActive = input(false, { transform: booleanAttribute });
+ constructor() {
+ classes(() => [
+ `text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>_ng-icon:not([class*='text-'])]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none hover:cursor-pointer focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 disabled:hover:cursor-default aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>_ng-icon]:size-4 [&>_ng-icon]:shrink-0 [&>span:last-child]:truncate`,
+ 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
+ 'data-[size=md]:text-sm data-[size=sm]:text-xs',
+ 'group-data-[collapsible=icon]:hidden',
+ ]);
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub-item.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub-item.ts
new file mode 100644
index 00000000..88810cf2
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub-item.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'li[hlmSidebarMenuSubItem]',
+ host: {
+ 'data-slot': 'sidebar-menu-sub-item',
+ 'data-sidebar': 'menu-sub-item',
+ },
+})
+export class HlmSidebarMenuSubItem {
+ constructor() {
+ classes(() => 'group/menu-sub-item relative');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub.ts
new file mode 100644
index 00000000..9055af0d
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu-sub.ts
@@ -0,0 +1,18 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'ul[hlmSidebarMenuSub]',
+ host: {
+ 'data-slot': 'sidebar-menu-sub',
+ 'data-sidebar': 'menu-sub',
+ },
+})
+export class HlmSidebarMenuSub {
+ constructor() {
+ classes(() => [
+ 'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
+ 'group-data-[collapsible=icon]:hidden',
+ ]);
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-menu.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-menu.ts
new file mode 100644
index 00000000..7c347e43
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-menu.ts
@@ -0,0 +1,15 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'ul[hlmSidebarMenu]',
+ host: {
+ 'data-slot': 'sidebar-menu',
+ 'data-sidebar': 'menu',
+ },
+})
+export class HlmSidebarMenu {
+ constructor() {
+ classes(() => 'flex w-full min-w-0 flex-col gap-1');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-rail.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-rail.ts
new file mode 100644
index 00000000..ea999755
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-rail.ts
@@ -0,0 +1,34 @@
+import { Directive, inject, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+import { HlmSidebarService } from './hlm-sidebar.service';
+
+@Directive({
+ selector: 'button[hlmSidebarRail]',
+ host: {
+ 'data-sidebar': 'rail',
+ 'data-slot': 'sidebar-rail',
+ '[attr.aria-label]': 'ariaLabel()',
+ tabindex: '-1',
+ '(click)': 'onClick()',
+ },
+})
+export class HlmSidebarRail {
+ private readonly _sidebarService = inject(HlmSidebarService);
+
+ public readonly ariaLabel = input('Toggle Sidebar', { alias: 'aria-label' });
+
+ constructor() {
+ classes(() => [
+ 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
+ 'group-data-[side=left]:cursor-w-resize group-data-[side=right]:cursor-e-resize',
+ '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
+ 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
+ '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
+ '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
+ ]);
+ }
+
+ protected onClick(): void {
+ this._sidebarService.toggleSidebar();
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-separator.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-separator.ts
new file mode 100644
index 00000000..f1125866
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-separator.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { HlmSeparator } from '@spartan-ng/helm/separator';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSidebarSeparator],hlm-sidebar-separator',
+ hostDirectives: [{ directive: HlmSeparator }],
+ host: {
+ 'data-slot': 'sidebar-separator',
+ 'data-sidebar': 'separator',
+ },
+})
+export class HlmSidebarSeparator {
+ constructor() {
+ classes(() => 'bg-sidebar-border mx-2 w-auto');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-trigger.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-trigger.ts
new file mode 100644
index 00000000..1d8110d0
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-trigger.ts
@@ -0,0 +1,39 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { provideIcons } from '@ng-icons/core';
+import { lucidePanelLeft } from '@ng-icons/lucide';
+import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
+import { HlmIconImports } from '@spartan-ng/helm/icon';
+import { HlmSidebarService } from './hlm-sidebar.service';
+
+@Component({
+ // eslint-disable-next-line @angular-eslint/component-selector
+ selector: 'button[hlmSidebarTrigger]',
+ imports: [HlmIconImports],
+ providers: [provideIcons({ lucidePanelLeft }), provideBrnButtonConfig({ variant: 'ghost', size: 'icon' })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [
+ {
+ directive: HlmButton,
+ },
+ ],
+ host: {
+ 'data-slot': 'sidebar-trigger',
+ 'data-sidebar': 'trigger',
+ '(click)': '_onClick()',
+ },
+ template: `
+
+ `,
+})
+export class HlmSidebarTrigger {
+ private readonly _hlmBtn = inject(HlmButton);
+ private readonly _sidebarService = inject(HlmSidebarService);
+
+ constructor() {
+ this._hlmBtn.setClass('size-7');
+ }
+
+ protected _onClick(): void {
+ this._sidebarService.toggleSidebar();
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar-wrapper.ts b/libs/ui/sidebar/src/lib/hlm-sidebar-wrapper.ts
new file mode 100644
index 00000000..a3863b38
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar-wrapper.ts
@@ -0,0 +1,22 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+import { injectHlmSidebarConfig } from './hlm-sidebar.token';
+
+@Directive({
+ selector: '[hlmSidebarWrapper],hlm-sidebar-wrapper',
+ host: {
+ 'data-slot': 'sidebar-wrapper',
+ '[style.--sidebar-width]': 'sidebarWidth()',
+ '[style.--sidebar-width-icon]': 'sidebarWidthIcon()',
+ },
+})
+export class HlmSidebarWrapper {
+ private readonly _config = injectHlmSidebarConfig();
+
+ public readonly sidebarWidth = input(this._config.sidebarWidth);
+ public readonly sidebarWidthIcon = input(this._config.sidebarWidthIcon);
+
+ constructor() {
+ classes(() => 'group/sidebar-wrapper has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full');
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar.service.ts b/libs/ui/sidebar/src/lib/hlm-sidebar.service.ts
new file mode 100644
index 00000000..5d384292
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar.service.ts
@@ -0,0 +1,114 @@
+import {
+ afterNextRender,
+ computed,
+ DestroyRef,
+ DOCUMENT,
+ inject,
+ Injectable,
+ type Signal,
+ signal,
+} from '@angular/core';
+import { injectHlmSidebarConfig } from './hlm-sidebar.token';
+
+export type SidebarVariant = 'sidebar' | 'floating' | 'inset';
+
+@Injectable({ providedIn: 'root' })
+export class HlmSidebarService {
+ private readonly _config = injectHlmSidebarConfig();
+ private readonly _document = inject(DOCUMENT);
+ private readonly _window = this._document.defaultView;
+ private readonly _open = signal(true);
+ private readonly _openMobile = signal(false);
+ private readonly _isMobile = signal(false);
+ private readonly _variant = signal('sidebar');
+ private _mediaQuery: MediaQueryList | null = null;
+
+ public readonly open: Signal = this._open.asReadonly();
+ public readonly openMobile: Signal = this._openMobile.asReadonly();
+ public readonly isMobile: Signal = this._isMobile.asReadonly();
+ public readonly variant: Signal = this._variant.asReadonly();
+
+ public readonly state = computed<'expanded' | 'collapsed'>(() => (this._open() ? 'expanded' : 'collapsed'));
+
+ constructor() {
+ const destroyRef = inject(DestroyRef);
+ afterNextRender(() => {
+ if (!this._window) return;
+ // Initialize from cookie
+ const cookie = this._document.cookie
+ .split('; ')
+ .find((row) => row.startsWith(`${this._config.sidebarCookieName}=`));
+
+ if (cookie) {
+ const value = cookie.split('=')[1];
+ this._open.set(value === 'true');
+ }
+
+ // Initialize MediaQueryList
+ this._mediaQuery = this._window.matchMedia(`(max-width: ${this._config.mobileBreakpoint})`);
+ this._isMobile.set(this._mediaQuery.matches);
+
+ // Add media query listener
+ const mediaQueryHandler = (e: MediaQueryListEvent) => {
+ this._isMobile.set(e.matches);
+ // If switching from mobile to desktop, close mobile sidebar
+ if (!e.matches) this._openMobile.set(false);
+ };
+ this._mediaQuery.addEventListener('change', mediaQueryHandler);
+
+ // Add keyboard shortcut listener
+ const keydownHandler = (event: KeyboardEvent) => {
+ if (event.key === this._config.sidebarKeyboardShortcut && (event.ctrlKey || event.metaKey)) {
+ event.preventDefault();
+ this.toggleSidebar();
+ }
+ };
+ this._window.addEventListener('keydown', keydownHandler);
+
+ // Add resize listener with debounce
+ let resizeTimeout: number;
+ const resizeHandler = () => {
+ if (!this._window) return;
+
+ if (resizeTimeout) this._window.clearTimeout(resizeTimeout);
+ resizeTimeout = this._window.setTimeout(() => {
+ if (this._mediaQuery) this._isMobile.set(this._mediaQuery.matches);
+ }, 100);
+ };
+ this._window.addEventListener('resize', resizeHandler);
+
+ // Cleanup listeners on destroy
+ destroyRef.onDestroy(() => {
+ if (!this._window) return;
+
+ if (this._mediaQuery) this._mediaQuery.removeEventListener('change', mediaQueryHandler);
+ this._window.removeEventListener('keydown', keydownHandler);
+ this._window.removeEventListener('resize', resizeHandler);
+ if (resizeTimeout) this._window.clearTimeout(resizeTimeout);
+ });
+ });
+ }
+
+ public setOpen(open: boolean): void {
+ this._open.set(open);
+ this._document.cookie = `${this._config.sidebarCookieName}=${open}; path=/; max-age=${this._config.sidebarCookieMaxAge}`;
+ }
+
+ public setOpenMobile(open: boolean): void {
+ if (this._isMobile()) {
+ this._openMobile.set(open);
+ }
+ }
+
+ public setVariant(variant: SidebarVariant): void {
+ this._variant.set(variant);
+ }
+
+ public toggleSidebar(): void {
+ if (this._isMobile()) {
+ this._openMobile.update((value) => !value);
+ } else {
+ this.setOpen(!this._open());
+ }
+ }
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar.token.ts b/libs/ui/sidebar/src/lib/hlm-sidebar.token.ts
new file mode 100644
index 00000000..6113916a
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar.token.ts
@@ -0,0 +1,31 @@
+import { inject, InjectionToken, type ValueProvider } from '@angular/core';
+
+export interface HlmSidebarConfig {
+ sidebarWidth: string;
+ sidebarWidthMobile: string;
+ sidebarWidthIcon: string;
+ sidebarCookieName: string;
+ sidebarCookieMaxAge: number;
+ sidebarKeyboardShortcut: string;
+ mobileBreakpoint: string;
+}
+
+const defaultConfig: HlmSidebarConfig = {
+ sidebarWidth: '16rem',
+ sidebarWidthMobile: '18rem',
+ sidebarWidthIcon: '3rem',
+ sidebarCookieName: 'sidebar_state',
+ sidebarCookieMaxAge: 60 * 60 * 24 * 7, // 7 days in seconds
+ sidebarKeyboardShortcut: 'b',
+ mobileBreakpoint: '768px',
+};
+
+const HlmSidebarConfigToken = new InjectionToken('HlmSidebarConfig');
+
+export function provideHlmSidebarConfig(config: Partial): ValueProvider {
+ return { provide: HlmSidebarConfigToken, useValue: { ...defaultConfig, ...config } };
+}
+
+export function injectHlmSidebarConfig(): HlmSidebarConfig {
+ return inject(HlmSidebarConfigToken, { optional: true }) ?? defaultConfig;
+}
diff --git a/libs/ui/sidebar/src/lib/hlm-sidebar.ts b/libs/ui/sidebar/src/lib/hlm-sidebar.ts
new file mode 100644
index 00000000..e7db0391
--- /dev/null
+++ b/libs/ui/sidebar/src/lib/hlm-sidebar.ts
@@ -0,0 +1,138 @@
+import { NgTemplateOutlet } from '@angular/common';
+import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core';
+import { HlmSheetImports } from '@spartan-ng/helm/sheet';
+import { classes, hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+import { HlmSidebarService, type SidebarVariant } from './hlm-sidebar.service';
+import { injectHlmSidebarConfig } from './hlm-sidebar.token';
+
+@Component({
+ selector: 'hlm-sidebar',
+ imports: [NgTemplateOutlet, HlmSheetImports],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ '[attr.data-slot]': '_dataSlot()',
+ '[attr.data-state]': '_dataState()',
+ '[attr.data-collapsible]': '_dataCollapsible()',
+ '[attr.data-variant]': '_dataVariant()',
+ '[attr.data-side]': '_dataSide()',
+ },
+ template: `
+
+
+
+
+ @if (collapsible() === 'none') {
+
+ } @else if (_sidebarService.isMobile()) {
+
+
+
+ } @else {
+
+
+
+ }
+ `,
+})
+export class HlmSidebar {
+ protected readonly _sidebarService = inject(HlmSidebarService);
+ private readonly _config = injectHlmSidebarConfig();
+ public readonly sidebarWidthMobile = input(this._config.sidebarWidthMobile);
+
+ public readonly side = input<'left' | 'right'>('left');
+ public readonly variant = input(this._sidebarService.variant());
+ public readonly collapsible = input<'offcanvas' | 'icon' | 'none'>('offcanvas');
+
+ protected readonly _sidebarGapComputedClass = computed(() =>
+ hlm(
+ 'relative w-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear',
+ 'group-data-[collapsible=offcanvas]:w-0',
+ 'group-data-[side=right]:rotate-180',
+ this.variant() === 'floating' || this.variant() === 'inset'
+ ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
+ : 'group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]',
+ ),
+ );
+
+ public readonly sidebarContainerClass = input('');
+ protected readonly _sidebarContainerComputedClass = computed(() =>
+ hlm(
+ 'fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex',
+ this.side() === 'left'
+ ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
+ : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
+ this.variant() === 'floating' || this.variant() === 'inset'
+ ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
+ : 'group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l',
+ this.sidebarContainerClass(),
+ ),
+ );
+
+ protected readonly _dataSlot = computed(() => {
+ return !this._sidebarService.isMobile() ? 'sidebar' : undefined;
+ });
+
+ private readonly _collapsibleAndNonMobile = computed(() => {
+ return this.collapsible() !== 'none' && !this._sidebarService.isMobile();
+ });
+
+ protected readonly _dataState = computed(() => {
+ return this._collapsibleAndNonMobile() ? this._sidebarService.state() : undefined;
+ });
+
+ protected readonly _dataCollapsible = computed(() => {
+ if (this._collapsibleAndNonMobile()) {
+ return this._sidebarService.state() === 'collapsed' ? this.collapsible() : '';
+ }
+ return undefined;
+ });
+
+ protected readonly _dataVariant = computed(() => {
+ return this._collapsibleAndNonMobile() ? this.variant() : undefined;
+ });
+
+ protected readonly _dataSide = computed(() => {
+ return this._collapsibleAndNonMobile() ? this.side() : undefined;
+ });
+
+ constructor() {
+ // Sync variant input with service
+ effect(() => {
+ this._sidebarService.setVariant(this.variant());
+ });
+
+ classes(() => {
+ if (this.collapsible() === 'none') {
+ return hlm('bg-sidebar text-sidebar-foreground flex h-svh w-[var(--sidebar-width)] flex-col');
+ } else if (this._sidebarService.isMobile()) {
+ return '';
+ } else {
+ return hlm('text-sidebar-foreground group peer hidden md:block');
+ }
+ });
+ }
+}
diff --git a/libs/ui/skeleton/src/index.ts b/libs/ui/skeleton/src/index.ts
new file mode 100644
index 00000000..1c7c3172
--- /dev/null
+++ b/libs/ui/skeleton/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmSkeleton } from './lib/hlm-skeleton';
+
+export * from './lib/hlm-skeleton';
+
+export const HlmSkeletonImports = [HlmSkeleton] as const;
diff --git a/libs/ui/skeleton/src/lib/hlm-skeleton.ts b/libs/ui/skeleton/src/lib/hlm-skeleton.ts
new file mode 100644
index 00000000..64772431
--- /dev/null
+++ b/libs/ui/skeleton/src/lib/hlm-skeleton.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmSkeleton],hlm-skeleton',
+ host: {
+ 'data-slot': 'skeleton',
+ },
+})
+export class HlmSkeleton {
+ constructor() {
+ classes(() => 'bg-accent block rounded-md motion-safe:animate-pulse');
+ }
+}
diff --git a/libs/ui/sonner/src/index.ts b/libs/ui/sonner/src/index.ts
new file mode 100644
index 00000000..00f06ae5
--- /dev/null
+++ b/libs/ui/sonner/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmToaster } from './lib/hlm-toaster';
+
+export * from './lib/hlm-toaster';
+
+export const HlmToasterImports = [HlmToaster] as const;
diff --git a/libs/ui/sonner/src/lib/hlm-toaster.ts b/libs/ui/sonner/src/lib/hlm-toaster.ts
new file mode 100644
index 00000000..3a6d8647
--- /dev/null
+++ b/libs/ui/sonner/src/lib/hlm-toaster.ts
@@ -0,0 +1,66 @@
+import { ChangeDetectionStrategy, Component, booleanAttribute, computed, input, numberAttribute } from '@angular/core';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+import { NgxSonnerToaster, type ToasterProps } from 'ngx-sonner';
+
+@Component({
+ selector: 'hlm-toaster',
+ imports: [NgxSonnerToaster],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+})
+export class HlmToaster {
+ public readonly invert = input(false, {
+ transform: booleanAttribute,
+ });
+ public readonly theme = input('light');
+ public readonly position = input('bottom-right');
+ public readonly hotKey = input(['altKey', 'KeyT']);
+ public readonly richColors = input(false, {
+ transform: booleanAttribute,
+ });
+ public readonly expand = input(false, {
+ transform: booleanAttribute,
+ });
+ public readonly duration = input(4000, {
+ transform: numberAttribute,
+ });
+ public readonly visibleToasts = input(3, {
+ transform: numberAttribute,
+ });
+ public readonly closeButton = input(false, {
+ transform: booleanAttribute,
+ });
+ public readonly toastOptions = input({});
+ public readonly offset = input(null);
+ public readonly dir = input('auto');
+ public readonly userClass = input('', { alias: 'class' });
+ public readonly userStyle = input>(
+ {
+ '--normal-bg': 'var(--popover)',
+ '--normal-text': 'var(--popover-foreground)',
+ '--normal-border': 'var(--border)',
+ '--border-radius': 'var(--radius)',
+ },
+ { alias: 'style' },
+ );
+
+ protected readonly _computedClass = computed(() => hlm('toaster group', this.userClass()));
+}
diff --git a/libs/ui/spinner/src/index.ts b/libs/ui/spinner/src/index.ts
new file mode 100644
index 00000000..9e822f25
--- /dev/null
+++ b/libs/ui/spinner/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmSpinner } from './lib/hlm-spinner';
+
+export * from './lib/hlm-spinner';
+
+export const HlmSpinnerImports = [HlmSpinner] as const;
diff --git a/libs/ui/spinner/src/lib/hlm-spinner.ts b/libs/ui/spinner/src/lib/hlm-spinner.ts
new file mode 100644
index 00000000..ac73f785
--- /dev/null
+++ b/libs/ui/spinner/src/lib/hlm-spinner.ts
@@ -0,0 +1,32 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideLoader } from '@ng-icons/lucide';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Component({
+ selector: 'hlm-spinner',
+ imports: [NgIcon],
+ providers: [provideIcons({ lucideLoader })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ role: 'status',
+ '[attr.aria-label]': 'ariaLabel()',
+ },
+ template: `
+
+ `,
+})
+export class HlmSpinner {
+ /**
+ * The name of the icon to be used as the spinner.
+ * Use provideIcons({ ... }) to register custom icons.
+ */
+ public readonly icon = input('lucideLoader');
+
+ /** Aria label for the spinner for accessibility. */
+ public readonly ariaLabel = input('Loading', { alias: 'aria-label' });
+
+ constructor() {
+ classes(() => 'inline-flex size-fit text-base motion-safe:animate-spin');
+ }
+}
diff --git a/libs/ui/switch/src/index.ts b/libs/ui/switch/src/index.ts
new file mode 100644
index 00000000..d7c8f4ac
--- /dev/null
+++ b/libs/ui/switch/src/index.ts
@@ -0,0 +1,7 @@
+import { HlmSwitch } from './lib/hlm-switch';
+import { HlmSwitchThumb } from './lib/hlm-switch-thumb';
+
+export * from './lib/hlm-switch';
+export * from './lib/hlm-switch-thumb';
+
+export const HlmSwitchImports = [HlmSwitch, HlmSwitchThumb] as const;
diff --git a/libs/ui/switch/src/lib/hlm-switch-thumb.ts b/libs/ui/switch/src/lib/hlm-switch-thumb.ts
new file mode 100644
index 00000000..b2536291
--- /dev/null
+++ b/libs/ui/switch/src/lib/hlm-switch-thumb.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: 'brn-switch-thumb[hlm],[hlmSwitchThumb]',
+})
+export class HlmSwitchThumb {
+ constructor() {
+ classes(
+ () =>
+ 'bg-background dark:group-data-[state=unchecked]:bg-foreground dark:group-data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform group-data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
+ );
+ }
+}
diff --git a/libs/ui/switch/src/lib/hlm-switch.ts b/libs/ui/switch/src/lib/hlm-switch.ts
new file mode 100644
index 00000000..477af163
--- /dev/null
+++ b/libs/ui/switch/src/lib/hlm-switch.ts
@@ -0,0 +1,114 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ forwardRef,
+ input,
+ linkedSignal,
+ model,
+ output,
+} from '@angular/core';
+import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
+import { BrnSwitch, BrnSwitchThumb } from '@spartan-ng/brain/switch';
+import { hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+import { HlmSwitchThumb } from './hlm-switch-thumb';
+
+export const HLM_SWITCH_VALUE_ACCESSOR = {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => HlmSwitch),
+ multi: true,
+};
+
+@Component({
+ selector: 'hlm-switch',
+ imports: [BrnSwitchThumb, BrnSwitch, HlmSwitchThumb],
+ providers: [HLM_SWITCH_VALUE_ACCESSOR],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ class: 'contents',
+ '[attr.id]': 'null',
+ '[attr.aria-label]': 'null',
+ '[attr.aria-labelledby]': 'null',
+ '[attr.aria-describedby]': 'null',
+ },
+ template: `
+
+
+
+ `,
+})
+export class HlmSwitch implements ControlValueAccessor {
+ public readonly userClass = input('', { alias: 'class' });
+ protected readonly _computedClass = computed(() =>
+ hlm(
+ 'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50',
+ this.userClass(),
+ ),
+ );
+
+ /** The checked state of the switch. */
+ public readonly checked = model(false);
+
+ /** Emits when the checked state of the switch changes. */
+ public readonly checkedChange = output();
+
+ /** The disabled state of the switch. */
+ public readonly disabled = input(false, {
+ transform: booleanAttribute,
+ });
+
+ /** Used to set the id on the underlying brn element. */
+ public readonly id = input(null);
+
+ /** Used to set the aria-label attribute on the underlying brn element. */
+ public readonly ariaLabel = input(null, { alias: 'aria-label' });
+
+ /** Used to set the aria-labelledby attribute on the underlying brn element. */
+ public readonly ariaLabelledby = input(null, { alias: 'aria-labelledby' });
+
+ /** Used to set the aria-describedby attribute on the underlying brn element. */
+ public readonly ariaDescribedby = input(null, { alias: 'aria-describedby' });
+
+ protected readonly _disabled = linkedSignal(this.disabled);
+
+ protected _onChange?: ChangeFn;
+ protected _onTouched?: TouchFn;
+
+ protected handleChange(value: boolean): void {
+ this.checked.set(value);
+ this._onChange?.(value);
+ this.checkedChange.emit(value);
+ }
+
+ /** CONROL VALUE ACCESSOR */
+
+ writeValue(value: boolean): void {
+ this.checked.set(Boolean(value));
+ }
+
+ registerOnChange(fn: ChangeFn): void {
+ this._onChange = fn;
+ }
+
+ registerOnTouched(fn: TouchFn): void {
+ this._onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this._disabled.set(isDisabled);
+ }
+}
diff --git a/libs/ui/tabs/src/index.ts b/libs/ui/tabs/src/index.ts
new file mode 100644
index 00000000..1b9c3a29
--- /dev/null
+++ b/libs/ui/tabs/src/index.ts
@@ -0,0 +1,22 @@
+import { HlmTabs } from './lib/hlm-tabs';
+import { HlmTabsContent } from './lib/hlm-tabs-content';
+import { HlmTabsContentLazy } from './lib/hlm-tabs-content-lazy';
+import { HlmTabsList } from './lib/hlm-tabs-list';
+import { HlmTabsPaginatedList } from './lib/hlm-tabs-paginated-list';
+import { HlmTabsTrigger } from './lib/hlm-tabs-trigger';
+
+export * from './lib/hlm-tabs';
+export * from './lib/hlm-tabs-content';
+export * from './lib/hlm-tabs-content-lazy';
+export * from './lib/hlm-tabs-list';
+export * from './lib/hlm-tabs-paginated-list';
+export * from './lib/hlm-tabs-trigger';
+
+export const HlmTabsImports = [
+ HlmTabs,
+ HlmTabsList,
+ HlmTabsTrigger,
+ HlmTabsContent,
+ HlmTabsContentLazy,
+ HlmTabsPaginatedList,
+] as const;
diff --git a/libs/ui/tabs/src/lib/hlm-tabs-content-lazy.ts b/libs/ui/tabs/src/lib/hlm-tabs-content-lazy.ts
new file mode 100644
index 00000000..95ef1c40
--- /dev/null
+++ b/libs/ui/tabs/src/lib/hlm-tabs-content-lazy.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnTabsContentLazy } from '@spartan-ng/brain/tabs';
+
+@Directive({
+ selector: 'ng-template[hlmTabsContentLazy]',
+ hostDirectives: [BrnTabsContentLazy],
+})
+export class HlmTabsContentLazy {}
diff --git a/libs/ui/tabs/src/lib/hlm-tabs-content.ts b/libs/ui/tabs/src/lib/hlm-tabs-content.ts
new file mode 100644
index 00000000..081c90f5
--- /dev/null
+++ b/libs/ui/tabs/src/lib/hlm-tabs-content.ts
@@ -0,0 +1,18 @@
+import { Directive, input } from '@angular/core';
+import { BrnTabsContent } from '@spartan-ng/brain/tabs';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmTabsContent]',
+ hostDirectives: [{ directive: BrnTabsContent, inputs: ['brnTabsContent: hlmTabsContent'] }],
+ host: {
+ 'data-slot': 'tabs-content',
+ },
+})
+export class HlmTabsContent {
+ public readonly contentFor = input.required({ alias: 'hlmTabsContent' });
+
+ constructor() {
+ classes(() => 'flex-1 text-sm outline-none');
+ }
+}
diff --git a/libs/ui/tabs/src/lib/hlm-tabs-list.ts b/libs/ui/tabs/src/lib/hlm-tabs-list.ts
new file mode 100644
index 00000000..c57b048d
--- /dev/null
+++ b/libs/ui/tabs/src/lib/hlm-tabs-list.ts
@@ -0,0 +1,36 @@
+import { Directive, input } from '@angular/core';
+import { BrnTabsList } from '@spartan-ng/brain/tabs';
+import { classes } from '@spartan-ng/helm/utils';
+import { type VariantProps, cva } from 'class-variance-authority';
+
+export const listVariants = cva(
+ 'group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center rounded-lg p-[3px] group-data-horizontal/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none',
+ {
+ variants: {
+ variant: {
+ default: 'bg-muted',
+ line: 'gap-1 bg-transparent',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+type ListVariants = VariantProps;
+
+@Directive({
+ selector: '[hlmTabsList],hlm-tabs-list',
+ hostDirectives: [BrnTabsList],
+ host: {
+ 'data-slot': 'tabs-list',
+ '[attr.data-variant]': 'variant()',
+ },
+})
+export class HlmTabsList {
+ public readonly variant = input('default');
+
+ constructor() {
+ classes(() => listVariants({ variant: this.variant() }));
+ }
+}
diff --git a/libs/ui/tabs/src/lib/hlm-tabs-paginated-list.ts b/libs/ui/tabs/src/lib/hlm-tabs-paginated-list.ts
new file mode 100644
index 00000000..4723a11d
--- /dev/null
+++ b/libs/ui/tabs/src/lib/hlm-tabs-paginated-list.ts
@@ -0,0 +1,105 @@
+import { CdkObserveContent } from '@angular/cdk/observers';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ type ElementRef,
+ computed,
+ contentChildren,
+ input,
+ viewChild,
+} from '@angular/core';
+import { toObservable } from '@angular/core/rxjs-interop';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronLeft, lucideChevronRight } from '@ng-icons/lucide';
+import { type BrnPaginatedTabHeaderItem, BrnTabsPaginatedList, BrnTabsTrigger } from '@spartan-ng/brain/tabs';
+import { buttonVariants } from '@spartan-ng/helm/button';
+import { HlmIcon } from '@spartan-ng/helm/icon';
+import { classes, hlm } from '@spartan-ng/helm/utils';
+import type { ClassValue } from 'clsx';
+import type { Observable } from 'rxjs';
+import { listVariants } from './hlm-tabs-list';
+
+@Component({
+ selector: 'hlm-paginated-tabs-list',
+ imports: [CdkObserveContent, NgIcon, HlmIcon],
+ providers: [provideIcons({ lucideChevronRight, lucideChevronLeft })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-slot': 'tabs-paginated-list',
+ },
+ template: `
+
+
+
+
+
+ `,
+})
+export class HlmTabsPaginatedList extends BrnTabsPaginatedList {
+ constructor() {
+ super();
+ classes(() => 'relative flex flex-shrink-0 gap-1 overflow-hidden');
+ }
+
+ public readonly items = contentChildren(BrnTabsTrigger, { descendants: false });
+ /** Explicitly annotating type to avoid non-portable inferred type */
+ public readonly itemsChanges: Observable> = toObservable(this.items);
+
+ public readonly tabListContainer = viewChild.required>('tabListContainer');
+ public readonly tabList = viewChild.required>('tabList');
+ public readonly tabListInner = viewChild.required>('tabListInner');
+ public readonly nextPaginator = viewChild.required>('nextPaginator');
+ public readonly previousPaginator = viewChild.required>('previousPaginator');
+
+ public readonly tabListClass = input('', { alias: 'tabListClass' });
+ protected readonly _tabListClass = computed(() => hlm(listVariants(), this.tabListClass()));
+
+ public readonly paginationButtonClass = input('', { alias: 'paginationButtonClass' });
+ protected readonly _paginationButtonClass = computed(() =>
+ hlm(
+ 'relative z-[2] select-none disabled:cursor-default',
+ buttonVariants({ variant: 'ghost', size: 'icon' }),
+ this.paginationButtonClass(),
+ ),
+ );
+
+ protected _itemSelected(event: KeyboardEvent) {
+ event.preventDefault();
+ }
+}
diff --git a/libs/ui/tabs/src/lib/hlm-tabs-trigger.ts b/libs/ui/tabs/src/lib/hlm-tabs-trigger.ts
new file mode 100644
index 00000000..95a0468c
--- /dev/null
+++ b/libs/ui/tabs/src/lib/hlm-tabs-trigger.ts
@@ -0,0 +1,22 @@
+import { Directive, input } from '@angular/core';
+import { BrnTabsTrigger } from '@spartan-ng/brain/tabs';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmTabsTrigger]',
+ hostDirectives: [{ directive: BrnTabsTrigger, inputs: ['brnTabsTrigger: hlmTabsTrigger', 'disabled'] }],
+ host: {
+ 'data-slot': 'tabs-trigger',
+ },
+})
+export class HlmTabsTrigger {
+ public readonly triggerFor = input.required({ alias: 'hlmTabsTrigger' });
+ constructor() {
+ classes(() => [
+ `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0 [&_ng-icon:not([class*='text-'])]:text-base`,
+ 'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
+ 'data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground',
+ 'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
+ ]);
+ }
+}
diff --git a/libs/ui/tabs/src/lib/hlm-tabs.ts b/libs/ui/tabs/src/lib/hlm-tabs.ts
new file mode 100644
index 00000000..89c3bdbc
--- /dev/null
+++ b/libs/ui/tabs/src/lib/hlm-tabs.ts
@@ -0,0 +1,24 @@
+import { Directive, input } from '@angular/core';
+import { BrnTabs } from '@spartan-ng/brain/tabs';
+import { classes } from '@spartan-ng/helm/utils';
+
+@Directive({
+ selector: '[hlmTabs],hlm-tabs',
+ hostDirectives: [
+ {
+ directive: BrnTabs,
+ inputs: ['orientation', 'activationMode', 'brnTabs: tab'],
+ outputs: ['tabActivated'],
+ },
+ ],
+ host: {
+ 'data-slot': 'tabs',
+ },
+})
+export class HlmTabs {
+ public readonly tab = input.required();
+
+ constructor() {
+ classes(() => 'group/tabs flex gap-2 data-[orientation=horizontal]:flex-col');
+ }
+}
diff --git a/libs/ui/textarea/src/index.ts b/libs/ui/textarea/src/index.ts
new file mode 100644
index 00000000..7fbe2a29
--- /dev/null
+++ b/libs/ui/textarea/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmTextarea } from './lib/hlm-textarea';
+
+export * from './lib/hlm-textarea';
+
+export const HlmTextareaImports = [HlmTextarea] as const;
diff --git a/libs/ui/textarea/src/lib/hlm-textarea.ts b/libs/ui/textarea/src/lib/hlm-textarea.ts
new file mode 100644
index 00000000..b8968710
--- /dev/null
+++ b/libs/ui/textarea/src/lib/hlm-textarea.ts
@@ -0,0 +1,100 @@
+import {
+ computed,
+ Directive,
+ type DoCheck,
+ effect,
+ forwardRef,
+ inject,
+ Injector,
+ input,
+ linkedSignal,
+ signal,
+ untracked,
+} from '@angular/core';
+import { FormGroupDirective, NgControl, NgForm } from '@angular/forms';
+import { BrnFormFieldControl } from '@spartan-ng/brain/form-field';
+import { ErrorStateMatcher, ErrorStateTracker } from '@spartan-ng/brain/forms';
+import { classes } from '@spartan-ng/helm/utils';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { ClassValue } from 'clsx';
+
+export const textareaVariants = cva(
+ 'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 flex [field-sizing:content] min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
+ {
+ variants: {
+ error: {
+ auto: '[&.ng-invalid.ng-touched]:border-destructive [&.ng-invalid.ng-touched]:ring-destructive/20 dark:[&.ng-invalid.ng-touched]:ring-destructive/40',
+ true: 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
+ },
+ },
+ defaultVariants: {
+ error: 'auto',
+ },
+ },
+);
+type TextareaVariants = VariantProps;
+
+@Directive({
+ selector: '[hlmTextarea]',
+ providers: [
+ {
+ provide: BrnFormFieldControl,
+ useExisting: forwardRef(() => HlmTextarea),
+ },
+ ],
+ host: {
+ 'data-slot': 'textarea',
+ },
+})
+export class HlmTextarea implements BrnFormFieldControl, DoCheck {
+ private readonly _injector = inject(Injector);
+ private readonly _additionalClasses = signal('');
+
+ private readonly _errorStateTracker: ErrorStateTracker;
+
+ private readonly _defaultErrorStateMatcher = inject(ErrorStateMatcher);
+ private readonly _parentForm = inject(NgForm, { optional: true });
+ private readonly _parentFormGroup = inject(FormGroupDirective, { optional: true });
+
+ public readonly error = input('auto');
+
+ protected readonly _state = linkedSignal(() => ({ error: this.error() }));
+
+ public readonly ngControl: NgControl | null = this._injector.get(NgControl, null);
+
+ public readonly errorState = computed(() => this._errorStateTracker.errorState());
+
+ constructor() {
+ classes(() => [textareaVariants({ error: this._state().error }), this._additionalClasses()]);
+
+ this._errorStateTracker = new ErrorStateTracker(
+ this._defaultErrorStateMatcher,
+ this.ngControl,
+ this._parentFormGroup,
+ this._parentForm,
+ );
+
+ effect(() => {
+ const error = this._errorStateTracker.errorState();
+ untracked(() => {
+ if (this.ngControl) {
+ const shouldShowError = error && this.ngControl.invalid && (this.ngControl.touched || this.ngControl.dirty);
+ this._errorStateTracker.errorState.set(shouldShowError ? true : false);
+ this.setError(shouldShowError ? true : 'auto');
+ }
+ });
+ });
+ }
+
+ ngDoCheck() {
+ this._errorStateTracker.updateErrorState();
+ }
+
+ public setError(error: TextareaVariants['error']): void {
+ this._state.set({ error });
+ }
+
+ public setClass(classes: string): void {
+ this._additionalClasses.set(classes);
+ }
+}
diff --git a/libs/ui/toggle-group/src/index.ts b/libs/ui/toggle-group/src/index.ts
new file mode 100644
index 00000000..ffe9b692
--- /dev/null
+++ b/libs/ui/toggle-group/src/index.ts
@@ -0,0 +1,8 @@
+import { HlmToggleGroup } from './lib/hlm-toggle-group';
+import { HlmToggleGroupItem } from './lib/hlm-toggle-group-item';
+
+export * from './lib/hlm-toggle-group';
+export * from './lib/hlm-toggle-group-item';
+export * from './lib/hlm-toggle-group.token';
+
+export const HlmToggleGroupImports = [HlmToggleGroup, HlmToggleGroupItem] as const;
diff --git a/libs/ui/toggle-group/src/lib/hlm-toggle-group-item.ts b/libs/ui/toggle-group/src/lib/hlm-toggle-group-item.ts
new file mode 100644
index 00000000..712ba4f8
--- /dev/null
+++ b/libs/ui/toggle-group/src/lib/hlm-toggle-group-item.ts
@@ -0,0 +1,42 @@
+import { computed, Directive, input } from '@angular/core';
+import { BrnToggleGroupItem } from '@spartan-ng/brain/toggle-group';
+import { toggleVariants, ToggleVariants } from '@spartan-ng/helm/toggle';
+import { classes } from '@spartan-ng/helm/utils';
+import { injectHlmToggleGroup } from './hlm-toggle-group.token';
+
+@Directive({
+ selector: 'button[hlmToggleGroupItem]',
+ hostDirectives: [
+ {
+ directive: BrnToggleGroupItem,
+ inputs: ['id', 'value', 'disabled', 'state', 'aria-label', 'type'],
+ outputs: ['stateChange'],
+ },
+ ],
+ host: {
+ 'data-slot': 'toggle-group-item',
+ '[attr.data-variant]': '_variant()',
+ '[attr.data-size]': '_size()',
+ '[attr.data-spacing]': '_toggleGroup.spacing()',
+ },
+})
+export class HlmToggleGroupItem {
+ protected readonly _toggleGroup = injectHlmToggleGroup();
+
+ public readonly variant = input('default');
+ public readonly size = input('default');
+
+ protected readonly _variant = computed(() => this._toggleGroup.variant() || this.variant());
+ protected readonly _size = computed(() => this._toggleGroup.size() || this.size());
+
+ constructor() {
+ classes(() => [
+ toggleVariants({
+ variant: this._variant(),
+ size: this._size(),
+ }),
+ 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
+ 'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
+ ]);
+ }
+}
diff --git a/libs/ui/toggle-group/src/lib/hlm-toggle-group.token.ts b/libs/ui/toggle-group/src/lib/hlm-toggle-group.token.ts
new file mode 100644
index 00000000..55011e4c
--- /dev/null
+++ b/libs/ui/toggle-group/src/lib/hlm-toggle-group.token.ts
@@ -0,0 +1,12 @@
+import { type ExistingProvider, InjectionToken, type Type, inject } from '@angular/core';
+import type { HlmToggleGroup } from './hlm-toggle-group';
+
+export const HlmToggleGroupToken = new InjectionToken('HlmToggleGroupToken');
+
+export function injectHlmToggleGroup(): HlmToggleGroup {
+ return inject(HlmToggleGroupToken);
+}
+
+export function provideHlmToggleGroup(toggleGroup: Type): ExistingProvider {
+ return { provide: HlmToggleGroupToken, useExisting: toggleGroup };
+}
diff --git a/libs/ui/toggle-group/src/lib/hlm-toggle-group.ts b/libs/ui/toggle-group/src/lib/hlm-toggle-group.ts
new file mode 100644
index 00000000..78b2136d
--- /dev/null
+++ b/libs/ui/toggle-group/src/lib/hlm-toggle-group.ts
@@ -0,0 +1,34 @@
+import { NumberInput } from '@angular/cdk/coercion';
+import { Directive, input, numberAttribute } from '@angular/core';
+import { BrnToggleGroup } from '@spartan-ng/brain/toggle-group';
+import { ToggleVariants } from '@spartan-ng/helm/toggle';
+import { classes } from '@spartan-ng/helm/utils';
+import { provideHlmToggleGroup } from './hlm-toggle-group.token';
+
+@Directive({
+ selector: '[hlmToggleGroup],hlm-toggle-group',
+ providers: [provideHlmToggleGroup(HlmToggleGroup)],
+ hostDirectives: [
+ {
+ directive: BrnToggleGroup,
+ inputs: ['type', 'value', 'nullable', 'disabled'],
+ outputs: ['valueChange'],
+ },
+ ],
+ host: {
+ 'data-slot': 'toggle-group',
+ '[attr.data-variant]': 'variant()',
+ '[attr.data-size]': 'size()',
+ '[attr.data-spacing]': 'spacing()',
+ '[style.--gap]': 'spacing()',
+ },
+})
+export class HlmToggleGroup {
+ public readonly variant = input('default');
+ public readonly size = input('default');
+ public readonly spacing = input