Skip to content

Commit d1bc965

Browse files
bram-atmireclaude
andcommitted
feat: Add runtime language activation/deactivation admin UI
Implements GitHub issues DSpace#309 and DSpace#310: - Admin page to activate/deactivate UI languages at runtime - Changes persist to config.prod.yml via file watching - Live updates via Server-Sent Events (SSE) - English language protected from deactivation - Disable all (except English) bulk action Features: - Language switcher updates automatically when languages change - Admin menu integration with proper authorization - DSpace UI design guidelines compliance - Accessible with ARIA labels Technical implementation: - Node.js Express API endpoints for language management - Chokidar file watcher for config.prod.yml changes - SSE for real-time updates to all connected clients - Angular LanguageConfigService with lazy initialization - Proper cleanup and error handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent c2258bf commit d1bc965

16 files changed

Lines changed: 1084 additions & 30 deletions

server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
RESPONSE,
5757
} from './src/express.tokens';
5858
import { SsrExcludePatterns } from './src/config/ssr-config.interface';
59+
import languageApiRoutes from './src/backend/language-api-routes';
5960

6061
/*
6162
* Set path for the browser application's dist folder
@@ -204,6 +205,12 @@ export function app() {
204205
*/
205206
server.get('/app/health', healthCheck);
206207

208+
/**
209+
* Language configuration API routes
210+
* Allows runtime activation/deactivation of UI languages
211+
*/
212+
server.use('/api/ui', languageApiRoutes);
213+
207214
/**
208215
* Default sending all incoming requests to ngApp() function, after first checking for a cached
209216
* copy of the page (see cacheCheck())
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<div class="container">
2+
<div class="row">
3+
<div class="col-12">
4+
<h2>{{ 'admin.language-settings.title' | translate }}</h2>
5+
<p class="text-muted">{{ 'admin.language-settings.description' | translate }}</p>
6+
</div>
7+
</div>
8+
9+
<div class="row mt-3">
10+
<div class="col-12 col-md-8 col-lg-6">
11+
<div class="mb-3">
12+
<button
13+
type="button"
14+
class="btn btn-outline-danger btn-sm"
15+
(click)="disableAllLanguages()"
16+
[dsBtnDisabled]="loading"
17+
[attr.aria-label]="'admin.language-settings.disable-all' | translate"
18+
>
19+
<i class="fas fa-ban" aria-hidden="true"></i>
20+
{{ 'admin.language-settings.disable-all' | translate }}
21+
</button>
22+
</div>
23+
24+
@if (languages$ | async; as languages) {
25+
@if (languages.length > 0) {
26+
<div class="table-responsive">
27+
<table class="table table-hover">
28+
<thead class="table-light">
29+
<tr>
30+
<th scope="col">{{ 'admin.language-settings.table.code' | translate }}</th>
31+
<th scope="col">{{ 'admin.language-settings.table.label' | translate }}</th>
32+
<th scope="col" class="text-center">{{ 'admin.language-settings.table.actions' | translate }}</th>
33+
</tr>
34+
</thead>
35+
<tbody>
36+
@for (language of languages; track trackByCode($index, language)) {
37+
<tr [ngClass]="{ 'table-success': language.active }">
38+
<td>
39+
<code>{{ language.code }}</code>
40+
</td>
41+
<td>
42+
<strong>{{ language.label }}</strong>
43+
</td>
44+
<td class="text-center">
45+
@if (language.active) {
46+
@if (language.code === 'en') {
47+
<small class="text-muted fst-italic">
48+
<i class="fas fa-info-circle" aria-hidden="true"></i>
49+
{{ 'admin.language-settings.english-protected' | translate }}
50+
</small>
51+
} @else {
52+
<button
53+
type="button"
54+
class="btn btn-sm btn-outline-danger"
55+
(click)="toggleLanguage(language)"
56+
[dsBtnDisabled]="loading"
57+
[attr.aria-label]="'admin.language-settings.actions.disable' | translate : { language: language.label }"
58+
>
59+
<i class="fas fa-times-circle" aria-hidden="true"></i>
60+
{{ 'admin.language-settings.actions.disable' | translate }}
61+
</button>
62+
}
63+
} @else {
64+
<button
65+
type="button"
66+
class="btn btn-sm btn-outline-success"
67+
(click)="toggleLanguage(language)"
68+
[dsBtnDisabled]="loading"
69+
[attr.aria-label]="'admin.language-settings.actions.enable' | translate : { language: language.label }"
70+
>
71+
<i class="fas fa-check-circle" aria-hidden="true"></i>
72+
{{ 'admin.language-settings.actions.enable' | translate }}
73+
</button>
74+
}
75+
</td>
76+
</tr>
77+
}
78+
</tbody>
79+
</table>
80+
</div>
81+
82+
<div class="alert alert-info mt-3" role="alert">
83+
<i class="fas fa-info-circle"></i>
84+
{{ 'admin.language-settings.info' | translate }}
85+
</div>
86+
} @else {
87+
<div class="alert alert-warning" role="alert">
88+
<i class="fas fa-exclamation-triangle"></i>
89+
{{ 'admin.language-settings.no-languages' | translate }}
90+
</div>
91+
}
92+
}
93+
94+
@if (loading) {
95+
<div class="text-center my-5">
96+
<div class="spinner-border text-primary" role="status">
97+
<span class="visually-hidden">{{ 'admin.language-settings.loading' | translate }}</span>
98+
</div>
99+
</div>
100+
}
101+
</div>
102+
</div>
103+
</div>
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
:host {
2+
display: block;
3+
padding: 1rem 0;
4+
}
5+
6+
h2 {
7+
color: var(--bs-heading-color);
8+
margin-bottom: 0.5rem;
9+
}
10+
11+
.table-responsive {
12+
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
13+
border-radius: 0.25rem;
14+
overflow: hidden;
15+
}
16+
17+
table {
18+
margin-bottom: 0;
19+
20+
thead {
21+
th {
22+
font-weight: 600;
23+
border-bottom-width: 2px;
24+
}
25+
}
26+
27+
tbody {
28+
tr {
29+
transition: all 0.2s ease-in-out;
30+
31+
&.table-success {
32+
--bs-table-bg: rgba(25, 135, 84, 0.1);
33+
--bs-table-hover-bg: rgba(25, 135, 84, 0.15);
34+
35+
td {
36+
border-bottom-width: 1px;
37+
}
38+
}
39+
40+
&.table-secondary {
41+
--bs-table-bg: rgba(108, 117, 125, 0.05);
42+
--bs-table-hover-bg: rgba(108, 117, 125, 0.1);
43+
}
44+
}
45+
46+
td {
47+
vertical-align: middle;
48+
}
49+
}
50+
51+
code {
52+
font-size: 0.9em;
53+
padding: 0.2rem 0.4rem;
54+
background-color: rgba(0, 0, 0, 0.05);
55+
border-radius: 0.25rem;
56+
}
57+
}
58+
59+
.form-switch {
60+
.form-check-input {
61+
width: 2.5rem;
62+
height: 1.25rem;
63+
cursor: pointer;
64+
65+
&:disabled {
66+
cursor: not-allowed;
67+
opacity: 0.5;
68+
}
69+
70+
&:focus {
71+
border-color: var(--bs-primary);
72+
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
73+
}
74+
}
75+
}
76+
77+
.badge {
78+
font-size: 0.875rem;
79+
padding: 0.35rem 0.65rem;
80+
font-weight: 500;
81+
}
82+
83+
.alert {
84+
i {
85+
margin-right: 0.5rem;
86+
}
87+
}
88+
89+
.btn {
90+
i {
91+
margin-right: 0.25rem;
92+
}
93+
}
94+
95+
.spinner-border {
96+
width: 3rem;
97+
height: 3rem;
98+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import {
2+
Component,
3+
OnDestroy,
4+
OnInit,
5+
} from '@angular/core';
6+
import {
7+
AsyncPipe,
8+
NgClass,
9+
} from '@angular/common';
10+
import {
11+
Observable,
12+
Subscription,
13+
} from 'rxjs';
14+
import { take } from 'rxjs/operators';
15+
import {
16+
TranslateModule,
17+
TranslateService,
18+
} from '@ngx-translate/core';
19+
20+
import { LangConfig } from '../../../config/lang-config.interface';
21+
import { LanguageConfigService } from '../../core/config/language-config.service';
22+
import { NotificationsService } from '../../core/notification-system/notifications.service';
23+
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
24+
25+
/**
26+
* Component for managing UI language activation/deactivation.
27+
* Allows administrators to enable or disable languages at runtime without rebuilding the application.
28+
*/
29+
@Component({
30+
selector: 'ds-admin-language-settings',
31+
templateUrl: './admin-language-settings.component.html',
32+
styleUrls: ['./admin-language-settings.component.scss'],
33+
imports: [
34+
AsyncPipe,
35+
BtnDisabledDirective,
36+
NgClass,
37+
TranslateModule,
38+
],
39+
})
40+
export class AdminLanguageSettingsComponent implements OnInit, OnDestroy {
41+
/**
42+
* Observable of all available languages
43+
*/
44+
languages$: Observable<LangConfig[]>;
45+
46+
/**
47+
* Loading state
48+
*/
49+
loading = false;
50+
51+
/**
52+
* Subscription to track ongoing operations
53+
*/
54+
private subscription: Subscription = new Subscription();
55+
56+
constructor(
57+
private languageConfigService: LanguageConfigService,
58+
private notificationsService: NotificationsService,
59+
private translateService: TranslateService,
60+
) {}
61+
62+
ngOnInit(): void {
63+
// Get all languages (active and inactive)
64+
this.languages$ = this.languageConfigService.getAllLanguages();
65+
}
66+
67+
/**
68+
* Toggle the active status of a language
69+
* @param language The language to toggle
70+
*/
71+
toggleLanguage(language: LangConfig): void {
72+
const newActiveState = !language.active;
73+
const action = newActiveState ? 'activate' : 'deactivate';
74+
75+
this.loading = true;
76+
77+
const sub = this.languageConfigService.setLanguageActive(language.code, newActiveState).subscribe({
78+
next: () => {
79+
this.loading = false;
80+
this.notificationsService.success(
81+
this.translateService.get(`admin.language-settings.notifications.${action}.success.title`),
82+
this.translateService.get(`admin.language-settings.notifications.${action}.success.content`),
83+
);
84+
},
85+
error: (error) => {
86+
this.loading = false;
87+
console.error(`Error ${action}ing language ${language.code}:`, error);
88+
this.notificationsService.error(
89+
this.translateService.get(`admin.language-settings.notifications.${action}.error.title`),
90+
this.translateService.get(`admin.language-settings.notifications.${action}.error.content`),
91+
);
92+
},
93+
});
94+
95+
this.subscription.add(sub);
96+
}
97+
98+
/**
99+
* Disable all languages except English
100+
*/
101+
disableAllLanguages(): void {
102+
this.loading = true;
103+
104+
// Get all active languages except English
105+
const sub = this.languageConfigService.getAllLanguages().pipe(
106+
take(1),
107+
).subscribe({
108+
next: (languages) => {
109+
const languagesToDisable = languages.filter(lang => lang.active && lang.code !== 'en');
110+
111+
if (languagesToDisable.length === 0) {
112+
// No languages to disable, just return silently
113+
this.loading = false;
114+
return;
115+
}
116+
117+
// Disable each language
118+
let completed = 0;
119+
let hasError = false;
120+
121+
languagesToDisable.forEach(language => {
122+
const langSub = this.languageConfigService.setLanguageActive(language.code, false).subscribe({
123+
next: () => {
124+
completed++;
125+
if (completed === languagesToDisable.length) {
126+
this.loading = false;
127+
if (!hasError) {
128+
this.notificationsService.success(
129+
this.translateService.get('admin.language-settings.notifications.disable-all.success.title'),
130+
this.translateService.get('admin.language-settings.notifications.disable-all.success.content'),
131+
);
132+
}
133+
}
134+
},
135+
error: (error) => {
136+
completed++;
137+
hasError = true;
138+
console.error(`Error disabling language ${language.code}:`, error);
139+
if (completed === languagesToDisable.length) {
140+
this.loading = false;
141+
this.notificationsService.error(
142+
this.translateService.get('admin.language-settings.notifications.disable-all.error.title'),
143+
this.translateService.get('admin.language-settings.notifications.disable-all.error.content'),
144+
);
145+
}
146+
},
147+
});
148+
this.subscription.add(langSub);
149+
});
150+
},
151+
error: (error) => {
152+
this.loading = false;
153+
console.error('Error fetching languages:', error);
154+
this.notificationsService.error(
155+
this.translateService.get('admin.language-settings.notifications.disable-all.error.title'),
156+
this.translateService.get('admin.language-settings.notifications.disable-all.error.content'),
157+
);
158+
},
159+
});
160+
161+
this.subscription.add(sub);
162+
}
163+
164+
165+
/**
166+
* Track by function for ngFor optimization
167+
*/
168+
trackByCode(index: number, language: LangConfig): string {
169+
return language.code;
170+
}
171+
172+
ngOnDestroy(): void {
173+
this.subscription.unsubscribe();
174+
}
175+
}

0 commit comments

Comments
 (0)