@devstroupe/devkit-cli 1.1.8 → 1.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -955,13 +955,13 @@ program
955
955
  process.exit(0);
956
956
  }
957
957
  });
958
- // Comando generate crud
958
+ // Comando generate crud ou project
959
959
  program
960
- .command('generate <type> <entityName>')
961
- .description('Gera artefatos do DevKit. Tipos suportados: crud')
960
+ .command('generate <type> [entityName]')
961
+ .description('Gera artefatos do DevKit. Tipos suportados: crud, project')
962
962
  .action((type, entityName) => {
963
- if (type !== 'crud') {
964
- console.error(`Tipo de geração "${type}" não é suportado. Use "crud".`);
963
+ if (type !== 'crud' && type !== 'project') {
964
+ console.error(`Tipo de geração "${type}" não é suportado. Use "crud" ou "project".`);
965
965
  process.exit(1);
966
966
  }
967
967
  const configPath = path.join(process.cwd(), 'devstroupe.config.ts');
@@ -978,6 +978,14 @@ program
978
978
  console.error('Erro ao carregar devstroupe.config.ts:', error);
979
979
  process.exit(1);
980
980
  }
981
+ if (type === 'project') {
982
+ generateProject(process.cwd(), config);
983
+ return;
984
+ }
985
+ if (!entityName) {
986
+ console.error('Nome da entidade não fornecido. Use "devstroupe generate crud all" ou "devstroupe generate crud <nome_da_entidade>".');
987
+ process.exit(1);
988
+ }
981
989
  try {
982
990
  assertValidRelationshipConfig(config);
983
991
  }
@@ -1862,19 +1870,22 @@ volumes:
1862
1870
  fs.writeFileSync(path.join(projectRoot, 'docker-compose.dev.yml'), dockerComposeDevContent, 'utf-8');
1863
1871
  console.log(`[Docker] docker-compose.dev.yml gerado com sucesso.`);
1864
1872
  }
1865
- // Comando new-project
1866
- program
1867
- .command('new-project <projectName>')
1868
- .description('Cria um novo projeto com frontend Angular e backend NestJS pré-configurados')
1869
- .option('--microservices', 'Cria o projeto utilizando arquitetura de microsserviços')
1870
- .action((projectName, options) => {
1871
- const projectRoot = path.resolve(process.cwd(), projectName);
1872
- if (fs.existsSync(projectRoot) && fs.readdirSync(projectRoot).length > 0) {
1873
- console.error(`[Erro] O diretório "${projectName}" já existe e não está vazio.`);
1874
- process.exit(1);
1873
+ // Função que gera a estrutura de pastas e arquivos base a partir de um devstroupe.config.ts existente
1874
+ function generateProject(projectRoot, config) {
1875
+ const projectName = config.projectName || 'DevsTroupe Project';
1876
+ const isMicroservices = config.backendType === 'microservices';
1877
+ const frontendPathRel = config.frontendPath || './frontend';
1878
+ const backendPathRel = config.backendPath || './backend';
1879
+ const frontendDest = path.resolve(projectRoot, frontendPathRel);
1880
+ let backendDest;
1881
+ if (isMicroservices) {
1882
+ backendDest = path.resolve(projectRoot, backendPathRel, 'api-gateway');
1883
+ fs.ensureDirSync(path.resolve(projectRoot, backendPathRel, 'services'));
1875
1884
  }
1876
- const isMicroservices = !!options.microservices;
1877
- console.log(`Criando novo projeto "${projectName}" (Modo: ${isMicroservices ? 'Microsserviços' : 'Monólito'}) em: ${projectRoot}...`);
1885
+ else {
1886
+ backendDest = path.resolve(projectRoot, backendPathRel);
1887
+ }
1888
+ console.log(`Configurando estrutura física do projeto "${projectName}" (Modo: ${isMicroservices ? 'Microsserviços' : 'Monólito'})...`);
1878
1889
  // Resolver caminhos de boilerplates
1879
1890
  let boilerplatesDir = path.resolve(__dirname, 'boilerplates');
1880
1891
  if (!fs.existsSync(boilerplatesDir)) {
@@ -1890,25 +1901,14 @@ program
1890
1901
  process.exit(1);
1891
1902
  }
1892
1903
  try {
1893
- // 1. Criar diretórios de destino
1894
- fs.ensureDirSync(projectRoot);
1895
- const frontendDest = path.join(projectRoot, 'frontend');
1896
- let backendDest;
1897
- if (isMicroservices) {
1898
- backendDest = path.join(projectRoot, 'backend', 'api-gateway');
1899
- fs.ensureDirSync(path.join(projectRoot, 'backend', 'services'));
1900
- }
1901
- else {
1902
- backendDest = path.join(projectRoot, 'backend');
1903
- }
1904
- // 2. Copiar boilerplates
1905
- console.log(`Copiando boilerplate do backend (${isMicroservices ? 'API Gateway' : 'NestJS Monólito'})...`);
1906
- fs.copySync(nestTemplateDir, backendDest, { filter: createBoilerplateCopyFilter(nestTemplateDir) });
1907
- console.log('Copiando boilerplate do frontend (Angular)...');
1908
- fs.copySync(angularTemplateDir, frontendDest, { filter: createBoilerplateCopyFilter(angularTemplateDir) });
1909
- // 2.1 Criar arquivos .env e .env.example no backend
1910
- console.log('Gerando arquivos .env e .env.example no backend...');
1911
- const envContent = `PORT=13000
1904
+ // 1. Criar diretórios de destino e copiar boilerplates
1905
+ if (!fs.existsSync(backendDest) || fs.readdirSync(backendDest).length === 0) {
1906
+ console.log(`Copiando boilerplate do backend para: ${backendDest}...`);
1907
+ fs.ensureDirSync(backendDest);
1908
+ fs.copySync(nestTemplateDir, backendDest, { filter: createBoilerplateCopyFilter(nestTemplateDir) });
1909
+ // Gerar arquivos .env e .env.example
1910
+ console.log('Gerando arquivos .env e .env.example no backend...');
1911
+ const envContent = `PORT=13000
1912
1912
  DB_HOST=localhost
1913
1913
  DB_PORT=3306
1914
1914
  DB_USER=root
@@ -1931,90 +1931,20 @@ R2_ACCOUNT_ID=
1931
1931
  R2_ACCESS_KEY=
1932
1932
  R2_SECRET_KEY=
1933
1933
  `;
1934
- fs.writeFileSync(path.join(backendDest, '.env'), envContent, 'utf-8');
1935
- fs.writeFileSync(path.join(backendDest, '.env.example'), envContent, 'utf-8');
1934
+ fs.writeFileSync(path.join(backendDest, '.env'), envContent, 'utf-8');
1935
+ fs.writeFileSync(path.join(backendDest, '.env.example'), envContent, 'utf-8');
1936
+ }
1937
+ if (!fs.existsSync(frontendDest) || fs.readdirSync(frontendDest).length === 0) {
1938
+ console.log(`Copiando boilerplate do frontend para: ${frontendDest}...`);
1939
+ fs.ensureDirSync(frontendDest);
1940
+ fs.copySync(angularTemplateDir, frontendDest, { filter: createBoilerplateCopyFilter(angularTemplateDir) });
1941
+ }
1936
1942
  // Patch local dependencies if in monorepo development
1937
1943
  patchLocalDependencies(backendDest, frontendDest, boilerplatesDir);
1938
- // 3. Criar devstroupe.config.ts
1939
- console.log('Gerando arquivo devstroupe.config.ts...');
1940
- const configPath = path.join(projectRoot, 'devstroupe.config.ts');
1941
- const designSystemConfig = {
1942
- provider: 'spartan',
1943
- spartanStyle: 'vega',
1944
- spartanTheme: 'neutral',
1945
- installAllPrimitives: true,
1946
- includePlayground: true,
1947
- };
1948
- const configContent = `export default {
1949
- projectName: '${projectName}',
1950
- backendType: '${isMicroservices ? 'microservices' : 'monolith'}',
1951
- backendPath: './backend',
1952
- frontendPath: './frontend',
1953
- designSystem: {
1954
- provider: '${designSystemConfig.provider}',
1955
- spartanStyle: '${designSystemConfig.spartanStyle}',
1956
- spartanTheme: '${designSystemConfig.spartanTheme}',
1957
- installAllPrimitives: ${designSystemConfig.installAllPrimitives},
1958
- includePlayground: ${designSystemConfig.includePlayground}
1959
- },
1960
- entities: [
1961
- {
1962
- name: 'company',
1963
- label: 'Empresa',
1964
- tableName: 'companies',
1965
- properties: [
1966
- { name: 'name', type: 'string', required: true, label: 'Nome' },
1967
- {
1968
- name: 'cabins', type: 'relationship', required: false, label: 'Cabines',
1969
- relation: {
1970
- target: 'cabin', kind: 'one-to-many', foreignKey: 'company_id',
1971
- inverseSide: 'company', control: 'table', embedded: true,
1972
- columns: ['name', 'code', 'is_active'], lookup: { pageSize: 10 }
1973
- }
1974
- }
1975
- ],
1976
- crud: { generateFrontend: true, generateBackend: true }
1977
- },
1978
- {
1979
- name: 'cabin',
1980
- label: 'Cabine',
1981
- tableName: 'cabins',
1982
- properties: [
1983
- { name: 'name', type: 'string', required: true, label: 'Nome' },
1984
- { name: 'code', type: 'string', required: false, label: 'Código' },
1985
- {
1986
- name: 'company_id', type: 'relationship', required: true, label: 'Empresa',
1987
- relation: {
1988
- target: 'company', kind: 'many-to-one', relationName: 'company',
1989
- inverseSide: 'cabins', control: 'select', displayField: 'name', onDelete: 'CASCADE',
1990
- lookup: { mode: 'remote', pageSize: 20, minSearchLength: 0 }
1991
- }
1992
- },
1993
- { name: 'is_active', type: 'boolean', required: true, label: 'Ativo', default: true }
1994
- ],
1995
- crud: {
1996
- generateFrontend: true,
1997
- generateBackend: true
1998
- }
1999
- }
2000
- ]
2001
- };
2002
- `;
2003
- fs.writeFileSync(configPath, configContent, 'utf-8');
2004
- // 3.1 Criar docker-compose.yml e init.sql
1944
+ // 2. Gerar docker-compose.yml e init.sql
2005
1945
  console.log('Gerando docker-compose.yml e base de dados...');
2006
- const tempConfig = {
2007
- projectName,
2008
- backendType: isMicroservices ? 'microservices' : 'monolith',
2009
- entities: [
2010
- {
2011
- name: 'cabin',
2012
- tableName: 'cabins',
2013
- }
2014
- ]
2015
- };
2016
- generateDockerComposeAndDbInit(projectRoot, tempConfig);
2017
- // 3.2 Criar Makefile na raiz
1946
+ generateDockerComposeAndDbInit(projectRoot, config);
1947
+ // 3. Gerar Makefile na raiz
2018
1948
  console.log('Gerando arquivo Makefile na raiz...');
2019
1949
  const backendServiceName = isMicroservices ? 'api-gateway' : 'backend';
2020
1950
  const makefileContent = `.PHONY: up down restart build logs ps clean backend-shell frontend-shell db-shell dev-up dev-down dev-restart dev-build dev-logs dev-ps dev-clean help
@@ -2120,17 +2050,100 @@ db-shell:
2120
2050
  catch (err) {
2121
2051
  console.warn('[Aviso] Falha ao rodar "npm install" no frontend. Instale as dependências manualmente.');
2122
2052
  }
2123
- // 5. Inicializar UI no frontend depois do install para disponibilizar @spartan-ng/cli
2053
+ // 5. Inicializar componentes de UI Spartan
2124
2054
  console.log('Inicializando componentes de UI locais...');
2125
- const generatedConfig = require(configPath).default ?? require(configPath);
2126
- initUi(frontendDest, generatedConfig);
2127
- console.log(`\n\x1b[32m[SUCESSO] Projeto "${projectName}" criado com sucesso!\x1b[0m`);
2055
+ initUi(frontendDest, config);
2056
+ console.log(`\n\x1b[32m[SUCESSO] Projeto "${projectName}" gerado com sucesso!\x1b[0m`);
2057
+ }
2058
+ catch (err) {
2059
+ console.error('[Erro] Falha ao gerar o projeto:', err);
2060
+ process.exit(1);
2061
+ }
2062
+ }
2063
+ // Comando new-project
2064
+ program
2065
+ .command('new-project <projectName>')
2066
+ .description('Cria um novo diretório de projeto e inicializa o devstroupe.config.ts')
2067
+ .option('--microservices', 'Cria o projeto utilizando arquitetura de microsserviços')
2068
+ .action((projectName, options) => {
2069
+ const projectRoot = path.resolve(process.cwd(), projectName);
2070
+ if (fs.existsSync(projectRoot) && fs.readdirSync(projectRoot).length > 0) {
2071
+ console.error(`[Erro] O diretório "${projectName}" já existe e não está vazio.`);
2072
+ process.exit(1);
2073
+ }
2074
+ const isMicroservices = !!options.microservices;
2075
+ console.log(`Inicializando novo projeto "${projectName}" em: ${projectRoot}...`);
2076
+ try {
2077
+ // 1. Criar diretório de destino
2078
+ fs.ensureDirSync(projectRoot);
2079
+ // 2. Criar devstroupe.config.ts padrão
2080
+ console.log('Gerando arquivo devstroupe.config.ts...');
2081
+ const configPath = path.join(projectRoot, 'devstroupe.config.ts');
2082
+ const configContent = `export default {
2083
+ projectName: '${projectName}',
2084
+ backendType: '${isMicroservices ? 'microservices' : 'monolith'}',
2085
+ backendPath: './backend',
2086
+ frontendPath: './frontend',
2087
+ designSystem: {
2088
+ provider: 'spartan',
2089
+ spartanStyle: 'vega',
2090
+ spartanTheme: 'neutral',
2091
+ installAllPrimitives: true,
2092
+ includePlayground: true
2093
+ },
2094
+ entities: [
2095
+ {
2096
+ name: 'company',
2097
+ label: 'Empresa',
2098
+ tableName: 'companies',
2099
+ properties: [
2100
+ { name: 'name', type: 'string', required: true, label: 'Nome' },
2101
+ {
2102
+ name: 'cabins', type: 'relationship', required: false, label: 'Cabines',
2103
+ relation: {
2104
+ target: 'cabin', kind: 'one-to-many', foreignKey: 'company_id',
2105
+ inverseSide: 'company', control: 'table', embedded: true,
2106
+ columns: ['name', 'code', 'is_active'], lookup: { pageSize: 10 }
2107
+ }
2108
+ }
2109
+ ],
2110
+ crud: { generateFrontend: true, generateBackend: true }
2111
+ },
2112
+ {
2113
+ name: 'cabin',
2114
+ label: 'Cabine',
2115
+ tableName: 'cabins',
2116
+ properties: [
2117
+ { name: 'name', type: 'string', required: true, label: 'Nome' },
2118
+ { name: 'code', type: 'string', required: false, label: 'Código' },
2119
+ {
2120
+ name: 'company_id', type: 'relationship', required: true, label: 'Empresa',
2121
+ relation: {
2122
+ target: 'company', kind: 'many-to-one', relationName: 'company',
2123
+ inverseSide: 'cabins', control: 'select', displayField: 'name', onDelete: 'CASCADE',
2124
+ lookup: { mode: 'remote', pageSize: 20, minSearchLength: 0 }
2125
+ }
2126
+ },
2127
+ { name: 'is_active', type: 'boolean', required: true, label: 'Ativo', default: true }
2128
+ ],
2129
+ crud: {
2130
+ generateFrontend: true,
2131
+ generateBackend: true
2132
+ }
2133
+ }
2134
+ ]
2135
+ };
2136
+ `;
2137
+ fs.writeFileSync(configPath, configContent, 'utf-8');
2138
+ console.log(`\n\x1b[32m[SUCESSO] Projeto "${projectName}" inicializado com sucesso!\x1b[0m`);
2128
2139
  console.log(`Para começar:`);
2129
- console.log(` 1. Entre na pasta do projeto: cd ${projectName}`);
2130
- console.log(` 2. Desenvolva sua aplicação com o DT-DevKit!`);
2140
+ console.log(` 1. Acesse a pasta do projeto: cd ${projectName}`);
2141
+ console.log(` 2. Ajuste suas entidades e configurações no arquivo: devstroupe.config.ts`);
2142
+ console.log(` 3. Execute o comando a seguir para gerar toda a estrutura física (frontend, backend, Docker):`);
2143
+ console.log(` devstroupe generate project`);
2131
2144
  }
2132
2145
  catch (err) {
2133
- console.error('[Erro] Falha ao criar o novo projeto:', err);
2146
+ console.error('[Erro] Falha ao inicializar o novo projeto:', err);
2134
2147
  process.exit(1);
2135
2148
  }
2136
2149
  });
@@ -80,16 +80,25 @@ function nestMigrationTemplate(entity, entities, timestamp, properties = entity.
80
80
  const relationColumns = relationships
81
81
  .filter((relationship) => (0, relationships_1.isSingularRelationship)(relationship.kind))
82
82
  .map((relationship) => ` { name: '${relationship.foreignKey}', type: 'int', isNullable: ${!relationship.property.required}, isUnique: ${relationship.kind === 'one-to-one'} }`);
83
- const foreignKeys = relationships
83
+ // Gera SQL idempotente para cada FK: DROP IF EXISTS + ADD CONSTRAINT
84
+ const foreignKeysSql = relationships
84
85
  .filter((relationship) => (0, relationships_1.isSingularRelationship)(relationship.kind))
85
- .map((relationship) => ` new TableForeignKey({
86
- name: 'FK_${tableName}_${relationship.foreignKey}',
87
- columnNames: ['${relationship.foreignKey}'],
88
- referencedTableName: '${targetTable(relationship.target, entities)}',
89
- referencedColumnNames: ['${relationship.valueField}'],
90
- onDelete: '${relationship.onDelete}'
91
- })`)
92
- .join(',\n');
86
+ .map((relationship) => {
87
+ const fkName = `FK_${tableName}_${relationship.foreignKey}`;
88
+ const refTable = targetTable(relationship.target, entities);
89
+ return [
90
+ ` await queryRunner.query(\`ALTER TABLE \\\`${tableName}\\\` DROP FOREIGN KEY IF EXISTS \\\`${fkName}\\\`\`);`,
91
+ ` await queryRunner.query(\`ALTER TABLE \\\`${tableName}\\\` ADD CONSTRAINT \\\`${fkName}\\\` FOREIGN KEY (\\\`${relationship.foreignKey}\\\`) REFERENCES \\\`${refTable}\\\`(\\\`${relationship.valueField}\\\`) ON DELETE ${relationship.onDelete}\`);`,
92
+ ].join('\n');
93
+ })
94
+ .join('\n');
95
+ const dropForeignKeysSql = relationships
96
+ .filter((relationship) => (0, relationships_1.isSingularRelationship)(relationship.kind))
97
+ .map((relationship) => {
98
+ const fkName = `FK_${tableName}_${relationship.foreignKey}`;
99
+ return ` await queryRunner.query(\`ALTER TABLE \\\`${tableName}\\\` DROP FOREIGN KEY IF EXISTS \\\`${fkName}\\\`\`);`;
100
+ })
101
+ .join('\n');
93
102
  const junctions = relationships
94
103
  .filter((relationship) => relationship.kind === 'many-to-many' && relationship.owner)
95
104
  .map((relationship) => {
@@ -154,7 +163,7 @@ function nestMigrationTemplate(entity, entities, timestamp, properties = entity.
154
163
  return ` await queryRunner.query('ALTER TABLE \`${tableName}\` DROP CONSTRAINT \`${checkName}\`');`;
155
164
  }).join('\n')
156
165
  : '';
157
- return `import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
166
+ return `import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
158
167
 
159
168
  export class ${className} implements MigrationInterface {
160
169
  name = '${className}';
@@ -166,10 +175,7 @@ export class ${className} implements MigrationInterface {
166
175
  { name: 'id', type: 'int', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }${scalarColumns.length + relationColumns.length > 0 ? ',\n' : ''}${[...scalarColumns, ...relationColumns].join(',\n')}
167
176
  ]
168
177
  }), true);
169
- ${foreignKeys ? `
170
- await queryRunner.createForeignKeys('${tableName}', [
171
- ${foreignKeys}
172
- ]);` : ''}
178
+ ${foreignKeysSql ? `\n${foreignKeysSql}` : ''}
173
179
  ${tableIndexes ? `\n${tableIndexes}` : ''}
174
180
  ${checkConstraints ? `\n${checkConstraints}` : ''}
175
181
  ${junctions.map((junction) => `
@@ -177,7 +183,7 @@ ${junction.up}`).join('\n')}
177
183
  }
178
184
 
179
185
  async down(queryRunner: QueryRunner): Promise<void> {
180
- ${dropChecks ? `${dropChecks}\n` : ''}${dropIndexes ? `${dropIndexes}\n` : ''}${junctions.slice().reverse().map((junction) => ` await queryRunner.dropTable('${junction.tableName}', true);`).join('\n')}${junctions.length > 0 ? '\n' : ''} await queryRunner.dropTable('${tableName}', true);
186
+ ${dropChecks ? `${dropChecks}\n` : ''}${dropIndexes ? `${dropIndexes}\n` : ''}${dropForeignKeysSql ? `${dropForeignKeysSql}\n` : ''}${junctions.slice().reverse().map((junction) => ` await queryRunner.dropTable('${junction.tableName}', true);`).join('\n')}${junctions.length > 0 ? '\n' : ''} await queryRunner.dropTable('${tableName}', true);
181
187
  }
182
188
  }
183
189
  `;
@@ -149,8 +149,9 @@ function assertValidTypeScript(source) {
149
149
  strict_1.default.match(companyMigration, /name: 'companies'/);
150
150
  strict_1.default.doesNotMatch(companyMigration, /FK_companies_cabins/);
151
151
  strict_1.default.match(cabinMigration, /name: 'company_id', type: 'int'/);
152
- strict_1.default.match(cabinMigration, /referencedTableName: 'companies'/);
153
- strict_1.default.match(cabinMigration, /onDelete: 'CASCADE'/);
152
+ strict_1.default.match(cabinMigration, /ADD CONSTRAINT/);
153
+ strict_1.default.match(cabinMigration, /FK_cabins_company_id/);
154
+ strict_1.default.match(cabinMigration, /ON DELETE CASCADE/);
154
155
  strict_1.default.match(dataSource, /migrations: \[path\.join/);
155
156
  strict_1.default.match(dataSource, /synchronize: false/);
156
157
  assertValidTypeScript(companyMigration);
@@ -8,11 +8,12 @@ import { CommonModule } from '@angular/common';
8
8
  import { HlmButton } from '@spartan-ng/helm/button';
9
9
  import { HlmInput } from '@spartan-ng/helm/input';
10
10
  import { NgIconComponent } from '@ng-icons/core';
11
+ import type { ThemeMode } from '../../../core/services/theme.service';
11
12
 
12
13
  export interface IUserProfile {
13
14
  name: string;
14
15
  avatar?: string;
15
- role?: string;
16
+ role?: any; // pode ser string ou { name: string, permissions: string[] }
16
17
  }
17
18
 
18
19
  @Component({
@@ -28,16 +29,35 @@ export class DevkitHeaderComponent {
28
29
  @Input() user: IUserProfile | null = null;
29
30
  @Input() isSidebarCollapsed = false;
30
31
  @Input() isDarkMode = false;
32
+ @Input() themeMode: ThemeMode = 'auto';
31
33
 
32
34
  @Output() toggleSidebar = new EventEmitter<void>();
33
35
  @Output() logout = new EventEmitter<void>();
34
36
  @Output() toggleTheme = new EventEmitter<void>();
35
37
  @Output() openProfile = new EventEmitter<void>();
38
+ @Output() setThemeMode = new EventEmitter<ThemeMode>();
36
39
 
37
40
  isProfileDropdownOpen = false;
41
+ isThemeDropdownOpen = false;
42
+
43
+ get roleName(): string {
44
+ if (!this.user?.role) return '';
45
+ return typeof this.user.role === 'object' ? (this.user.role.name ?? '') : this.user.role;
46
+ }
38
47
 
39
48
  toggleProfileDropdown(): void {
40
49
  this.isProfileDropdownOpen = !this.isProfileDropdownOpen;
50
+ this.isThemeDropdownOpen = false;
51
+ }
52
+
53
+ toggleThemeDropdown(): void {
54
+ this.isThemeDropdownOpen = !this.isThemeDropdownOpen;
55
+ this.isProfileDropdownOpen = false;
56
+ }
57
+
58
+ onSelectTheme(mode: ThemeMode): void {
59
+ this.isThemeDropdownOpen = false;
60
+ this.setThemeMode.emit(mode);
41
61
  }
42
62
 
43
63
  onLogoutClick(): void {
@@ -53,6 +73,18 @@ export class DevkitHeaderComponent {
53
73
  onToggleTheme(): void {
54
74
  this.toggleTheme.emit();
55
75
  }
76
+
77
+ get themeIcon(): string {
78
+ if (this.themeMode === 'light') return 'lucideSun';
79
+ if (this.themeMode === 'dark') return 'lucideMoon';
80
+ return 'lucideMonitor';
81
+ }
82
+
83
+ get themeLabel(): string {
84
+ if (this.themeMode === 'light') return 'Light';
85
+ if (this.themeMode === 'dark') return 'Dark';
86
+ return 'Auto';
87
+ }
56
88
  }
57
89
  `,
58
90
  html: `<header class="dt-header">
@@ -70,18 +102,35 @@ export class DevkitHeaderComponent {
70
102
 
71
103
  <div class="flex items-center space-x-3">
72
104
 
73
- <!-- Toggle Dark / Light Mode -->
74
- <button
75
- hlmBtn
76
- variant="ghost"
77
- size="icon"
78
- class="dt-header-action"
79
- [title]="isDarkMode ? 'Mudar para modo claro' : 'Mudar para modo escuro'"
80
- (click)="onToggleTheme()"
81
- >
82
- <ng-icon *ngIf="isDarkMode" name="lucideSun" class="size-5"></ng-icon>
83
- <ng-icon *ngIf="!isDarkMode" name="lucideMoon" class="size-5"></ng-icon>
84
- </button>
105
+ <!-- Seletor de Tema (Light / Dark / Auto) -->
106
+ <div class="relative">
107
+ <button
108
+ hlmBtn
109
+ variant="ghost"
110
+ size="icon"
111
+ class="dt-header-action"
112
+ [title]="'Tema: ' + themeLabel"
113
+ (click)="toggleThemeDropdown()"
114
+ >
115
+ <ng-icon [name]="themeIcon" class="size-5"></ng-icon>
116
+ </button>
117
+
118
+ <!-- Dropdown de Tema -->
119
+ <div *ngIf="isThemeDropdownOpen" class="dt-theme-dropdown">
120
+ <button class="dt-theme-option" [class.dt-theme-option--active]="themeMode === 'light'" (click)="onSelectTheme('light')">
121
+ <ng-icon name="lucideSun" class="size-4 mr-2"></ng-icon>
122
+ Light
123
+ </button>
124
+ <button class="dt-theme-option" [class.dt-theme-option--active]="themeMode === 'dark'" (click)="onSelectTheme('dark')">
125
+ <ng-icon name="lucideMoon" class="size-4 mr-2"></ng-icon>
126
+ Dark
127
+ </button>
128
+ <button class="dt-theme-option" [class.dt-theme-option--active]="themeMode === 'auto'" (click)="onSelectTheme('auto')">
129
+ <ng-icon name="lucideMonitor" class="size-4 mr-2"></ng-icon>
130
+ Auto
131
+ </button>
132
+ </div>
133
+ </div>
85
134
 
86
135
  <!-- Notificações -->
87
136
  <button *ngIf="showNotifications" hlmBtn variant="ghost" size="icon" class="dt-header-action relative" title="Notificações">
@@ -103,7 +152,7 @@ export class DevkitHeaderComponent {
103
152
  </ng-template>
104
153
  <div class="hidden md:flex flex-col text-left">
105
154
  <span class="text-sm font-semibold leading-none text-foreground">{{ user.name }}</span>
106
- <span *ngIf="user.role" class="text-[10px] font-medium leading-none mt-1 text-muted-foreground">{{ user.role }}</span>
155
+ <span *ngIf="roleName" class="text-[10px] font-medium leading-none mt-1 text-muted-foreground">{{ roleName }}</span>
107
156
  </div>
108
157
  </button>
109
158
 
@@ -111,7 +160,7 @@ export class DevkitHeaderComponent {
111
160
  <div *ngIf="isProfileDropdownOpen" class="dt-header-dropdown">
112
161
  <div class="px-4 py-3 border-b border-border">
113
162
  <p class="text-sm font-bold text-foreground">{{ user.name }}</p>
114
- <p *ngIf="user.role" class="text-xs text-muted-foreground mt-0.5">{{ user.role }}</p>
163
+ <p *ngIf="roleName" class="text-xs text-muted-foreground mt-0.5">{{ roleName }}</p>
115
164
  </div>
116
165
  <button hlmBtn variant="ghost" (click)="onOpenProfile()" class="dt-header-dropdown-item">
117
166
  <ng-icon name="lucideUser" class="size-4 mr-2"></ng-icon>
@@ -180,6 +229,46 @@ export class DevkitHeaderComponent {
180
229
  width: 280px;
181
230
  }
182
231
 
232
+ .dt-theme-dropdown {
233
+ position: absolute;
234
+ right: 0;
235
+ top: calc(100% + 8px);
236
+ width: 140px;
237
+ background-color: var(--popover);
238
+ color: var(--popover-foreground);
239
+ border: 1px solid var(--border);
240
+ border-radius: var(--radius);
241
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
242
+ z-index: 100;
243
+ display: flex;
244
+ flex-direction: column;
245
+ padding: 4px 0;
246
+ font-family: var(--font-family);
247
+ }
248
+
249
+ .dt-theme-option {
250
+ display: flex;
251
+ align-items: center;
252
+ width: 100%;
253
+ padding: 9px 14px;
254
+ background: transparent;
255
+ border: none;
256
+ font-size: 13px;
257
+ color: var(--popover-foreground);
258
+ cursor: pointer;
259
+ font-family: var(--font-family);
260
+ transition: background-color 0.15s ease-in-out;
261
+ }
262
+
263
+ .dt-theme-option:hover {
264
+ background-color: var(--muted);
265
+ }
266
+
267
+ .dt-theme-option--active {
268
+ color: var(--primary);
269
+ font-weight: 600;
270
+ }
271
+
183
272
  .dt-header-dropdown {
184
273
  position: absolute;
185
274
  right: 0;
@@ -32,6 +32,7 @@ export class MainLayoutComponent implements OnInit {
32
32
  menuItems: INavigationItem[] = APP_NAVIGATION;
33
33
  currentUser$ = this.authService.currentUser$;
34
34
  isDark$ = this.themeService.isDark$;
35
+ themeMode$ = this.themeService.themeMode$;
35
36
 
36
37
  brandLogo = '${brandLogo}';
37
38
  brandLogoCollapsed = '${brandLogoCollapsed}';
@@ -53,6 +54,10 @@ export class MainLayoutComponent implements OnInit {
53
54
  this.themeService.toggle();
54
55
  }
55
56
 
57
+ onSetThemeMode(mode: 'light' | 'dark' | 'auto'): void {
58
+ this.themeService.setMode(mode);
59
+ }
60
+
56
61
  onOpenProfile(): void {
57
62
  this.router.navigate(['/profile']);
58
63
  }
@@ -87,9 +92,11 @@ export class MainLayoutComponent implements OnInit {
87
92
  [user]="currentUser$ | async"
88
93
  [isSidebarCollapsed]="isCollapsed"
89
94
  [isDarkMode]="(isDark$ | async) ?? false"
95
+ [themeMode]="(themeMode$ | async) ?? 'auto'"
90
96
  (toggleSidebar)="isCollapsed = !isCollapsed"
91
97
  (logout)="onLogout()"
92
98
  (toggleTheme)="onToggleTheme()"
99
+ (setThemeMode)="onSetThemeMode($event)"
93
100
  (openProfile)="onOpenProfile()"
94
101
  ></devkit-header>
95
102
 
@@ -5,42 +5,57 @@ function devkitThemeServiceTemplate() {
5
5
  return `import { Injectable } from '@angular/core';
6
6
  import { BehaviorSubject } from 'rxjs';
7
7
 
8
+ export type ThemeMode = 'light' | 'dark' | 'auto';
9
+
8
10
  const THEME_KEY = 'devkit-theme';
9
11
 
10
12
  @Injectable({
11
13
  providedIn: 'root'
12
14
  })
13
15
  export class ThemeService {
16
+ private modeSubject = new BehaviorSubject<ThemeMode>('auto');
14
17
  private isDarkSubject = new BehaviorSubject<boolean>(false);
18
+
19
+ themeMode$ = this.modeSubject.asObservable();
15
20
  isDark$ = this.isDarkSubject.asObservable();
16
21
 
17
- get isDark(): boolean {
18
- return this.isDarkSubject.getValue();
19
- }
22
+ get isDark(): boolean { return this.isDarkSubject.getValue(); }
23
+ get mode(): ThemeMode { return this.modeSubject.getValue(); }
20
24
 
21
25
  constructor() {
22
26
  this.loadTheme();
27
+ window.matchMedia?.('(prefers-color-scheme: dark)')
28
+ .addEventListener('change', () => {
29
+ if (this.mode === 'auto') this.applyMode('auto');
30
+ });
23
31
  }
24
32
 
33
+ /** Cicla entre light → dark → auto */
25
34
  toggle(): void {
26
- this.setDark(!this.isDark);
35
+ const next: ThemeMode = this.mode === 'light' ? 'dark' : this.mode === 'dark' ? 'auto' : 'light';
36
+ this.setMode(next);
27
37
  }
28
38
 
29
- setDark(dark: boolean): void {
39
+ setMode(mode: ThemeMode): void {
40
+ this.modeSubject.next(mode);
41
+ localStorage.setItem(THEME_KEY, mode);
42
+ this.applyMode(mode);
43
+ }
44
+
45
+ /** @deprecated use setMode */
46
+ setDark(dark: boolean): void { this.setMode(dark ? 'dark' : 'light'); }
47
+
48
+ private applyMode(mode: ThemeMode): void {
49
+ const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
50
+ const dark = mode === 'dark' || (mode === 'auto' && prefersDark);
30
51
  this.isDarkSubject.next(dark);
31
- localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
32
- if (dark) {
33
- document.documentElement.classList.add('dark');
34
- } else {
35
- document.documentElement.classList.remove('dark');
36
- }
52
+ document.documentElement.classList.toggle('dark', dark);
37
53
  }
38
54
 
39
55
  private loadTheme(): void {
40
- const saved = localStorage.getItem(THEME_KEY);
41
- const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
42
- const dark = saved ? saved === 'dark' : prefersDark;
43
- this.setDark(dark);
56
+ const saved = (localStorage.getItem(THEME_KEY) ?? 'auto') as ThemeMode;
57
+ this.modeSubject.next(saved);
58
+ this.applyMode(saved);
44
59
  }
45
60
  }
46
61
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devstroupe/devkit-cli",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "DevsTroupe Development Kit CLI — scaffold NestJS+Angular projects, inject Spartan UI, generate CRUDs and audit architectural governance",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -36,7 +36,7 @@
36
36
  "dependencies": {
37
37
  "commander": "^12.0.0",
38
38
  "fs-extra": "^11.2.0",
39
- "@devstroupe/devkit-core": "1.1.8"
39
+ "@devstroupe/devkit-core": "1.1.10"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/fs-extra": "^11.0.4",