UI: Add resource file max size and improved file input style
This commit is contained in:
parent
58cf97f68a
commit
35deb47584
@ -25,6 +25,7 @@ export interface SysParamsState {
|
|||||||
tbelEnabled: boolean;
|
tbelEnabled: boolean;
|
||||||
persistDeviceStateToTelemetry: boolean;
|
persistDeviceStateToTelemetry: boolean;
|
||||||
userSettings: UserSettings;
|
userSettings: UserSettings;
|
||||||
|
maxResourceSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SysParams extends SysParamsState {
|
export interface SysParams extends SysParamsState {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const emptyUserAuthState: AuthPayload = {
|
|||||||
hasRepository: false,
|
hasRepository: false,
|
||||||
tbelEnabled: false,
|
tbelEnabled: false,
|
||||||
persistDeviceStateToTelemetry: false,
|
persistDeviceStateToTelemetry: false,
|
||||||
|
maxResourceSize: 0,
|
||||||
userSettings: initialUserSettings
|
userSettings: initialUserSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,11 @@ export const selectPersistDeviceStateToTelemetry = createSelector(
|
|||||||
(state: AuthState) => state.persistDeviceStateToTelemetry
|
(state: AuthState) => state.persistDeviceStateToTelemetry
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const selectMaxResourceSize = createSelector(
|
||||||
|
selectAuthState,
|
||||||
|
(state: AuthState) => state.maxResourceSize
|
||||||
|
);
|
||||||
|
|
||||||
export const selectUserSettings = createSelector(
|
export const selectUserSettings = createSelector(
|
||||||
selectAuthState,
|
selectAuthState,
|
||||||
(state: AuthState) => state.userSettings
|
(state: AuthState) => state.userSettings
|
||||||
|
|||||||
@ -80,8 +80,7 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</section>
|
</section>
|
||||||
<section [fxShow]="repositorySettingsForm.get('authMethod').value === repositoryAuthMethod.PRIVATE_KEY" fxLayout="column">
|
<section [fxShow]="repositorySettingsForm.get('authMethod').value === repositoryAuthMethod.PRIVATE_KEY" fxLayout="column">
|
||||||
<tb-file-input style="margin-bottom: 16px;"
|
<tb-file-input [existingFileName]="repositorySettingsForm.get('privateKeyFileName').value"
|
||||||
[existingFileName]="repositorySettingsForm.get('privateKeyFileName').value"
|
|
||||||
required
|
required
|
||||||
formControlName="privateKey"
|
formControlName="privateKey"
|
||||||
dropLabel="{{ 'admin.drop-private-key-file-or' | translate }}"
|
dropLabel="{{ 'admin.drop-private-key-file-or' | translate }}"
|
||||||
|
|||||||
@ -69,7 +69,9 @@
|
|||||||
<tb-file-input *ngIf="isAdd || (isEdit && entityForm.get('resourceType').value === resourceType.JS_MODULE)"
|
<tb-file-input *ngIf="isAdd || (isEdit && entityForm.get('resourceType').value === resourceType.JS_MODULE)"
|
||||||
formControlName="data"
|
formControlName="data"
|
||||||
required
|
required
|
||||||
|
label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}"
|
||||||
[readAsBinary]="true"
|
[readAsBinary]="true"
|
||||||
|
[maxSizeByte]="maxResourceSize"
|
||||||
[allowedExtensions]="getAllowedExtensions()"
|
[allowedExtensions]="getAllowedExtensions()"
|
||||||
[contentConvertFunction]="convertToBase64File"
|
[contentConvertFunction]="convertToBase64File"
|
||||||
[accept]="getAcceptType()"
|
[accept]="getAcceptType()"
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { Store } from '@ngrx/store';
|
import { select, Store } from '@ngrx/store';
|
||||||
import { AppState } from '@core/core.state';
|
import { AppState } from '@core/core.state';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
|
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
|
||||||
@ -29,9 +29,10 @@ import {
|
|||||||
ResourceTypeMIMETypes,
|
ResourceTypeMIMETypes,
|
||||||
ResourceTypeTranslationMap
|
ResourceTypeTranslationMap
|
||||||
} from '@shared/models/resource.models';
|
} from '@shared/models/resource.models';
|
||||||
import { filter, startWith, takeUntil } from 'rxjs/operators';
|
import { filter, startWith, take, takeUntil } from 'rxjs/operators';
|
||||||
import { ActionNotificationShow } from '@core/notification/notification.actions';
|
import { ActionNotificationShow } from '@core/notification/notification.actions';
|
||||||
import { isDefinedAndNotNull } from '@core/utils';
|
import { isDefinedAndNotNull } from '@core/utils';
|
||||||
|
import { selectMaxResourceSize } from '@core/auth/auth.selectors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'tb-resources-library',
|
selector: 'tb-resources-library',
|
||||||
@ -43,6 +44,8 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
|
|||||||
readonly resourceTypes: ResourceType[] = Object.values(this.resourceType);
|
readonly resourceTypes: ResourceType[] = Object.values(this.resourceType);
|
||||||
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
|
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
|
||||||
|
|
||||||
|
maxResourceSize = 0;
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(protected store: Store<AppState>,
|
constructor(protected store: Store<AppState>,
|
||||||
@ -52,6 +55,11 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
|
|||||||
public fb: FormBuilder,
|
public fb: FormBuilder,
|
||||||
protected cd: ChangeDetectorRef) {
|
protected cd: ChangeDetectorRef) {
|
||||||
super(store, fb, entityValue, entitiesTableConfigValue, cd);
|
super(store, fb, entityValue, entitiesTableConfigValue, cd);
|
||||||
|
this.store.pipe(select(selectMaxResourceSize)).pipe(
|
||||||
|
take(1)
|
||||||
|
).subscribe(maxResourceSize => {
|
||||||
|
this.maxResourceSize = maxResourceSize;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
|||||||
@ -120,6 +120,7 @@
|
|||||||
<tb-file-input
|
<tb-file-input
|
||||||
formControlName="file"
|
formControlName="file"
|
||||||
workFromFileObj="true"
|
workFromFileObj="true"
|
||||||
|
label="{{ 'ota-update.package-file' | translate }}"
|
||||||
[required]="!entityForm.get('isURL').value"
|
[required]="!entityForm.get('isURL').value"
|
||||||
dropLabel="{{'ota-update.drop-package-file-or' | translate}}">
|
dropLabel="{{'ota-update.drop-package-file-or' | translate}}">
|
||||||
</tb-file-input>
|
</tb-file-input>
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
-->
|
-->
|
||||||
<div class="tb-container">
|
<div class="tb-container">
|
||||||
<label class="tb-title"
|
<label class="tb-title" *ngIf="label"
|
||||||
[class.tb-required]="!disabled && required"
|
[class.tb-required]="!disabled && required"
|
||||||
[class.pointer-event]="hint"
|
[class.pointer-event]="hint"
|
||||||
tb-hint-tooltip-icon="{{ hint }}">{{ label }}
|
tb-hint-tooltip-icon="{{ hint }}">{{ label }}
|
||||||
@ -38,7 +38,7 @@
|
|||||||
flowDrop
|
flowDrop
|
||||||
[flow]="flow.flowJs">
|
[flow]="flow.flowJs">
|
||||||
<div class="upload-label">
|
<div class="upload-label">
|
||||||
<mat-icon>cloud_upload</mat-icon>
|
<mat-icon class="tb-mat-32">cloud_upload</mat-icon>
|
||||||
<span>{{ dropLabel }}</span>
|
<span>{{ dropLabel }}</span>
|
||||||
<button type="button" mat-button color="primary" class="browse-file">
|
<button type="button" mat-button color="primary" class="browse-file">
|
||||||
<label
|
<label
|
||||||
@ -50,9 +50,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<div class="tb-file-info-container">
|
||||||
|
<tb-error *ngIf="!fileName && required && requiredAsError" class="tb-file-name" error="{{ noFileText | translate }}"></tb-error>
|
||||||
|
<div *ngIf="!fileName && !requiredAsError" class="tb-file-name" translate>{{ noFileText }}</div>
|
||||||
|
<div *ngIf="fileName" class="tb-file-name" [title]="fileName">{{ fileName }}</div>
|
||||||
|
<div *ngIf="maxSizeByte && !disabled" class="tb-file-hint" translate [translateParams]="{ size: maxSizeByte | fileSize }">dashboard.maximum-upload-file-size</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<tb-error *ngIf="!fileName && required && requiredAsError" error="{{ noFileText | translate }}"></tb-error>
|
|
||||||
<div *ngIf="!fileName && !requiredAsError" translate>{{ noFileText }}</div>
|
|
||||||
<div *ngIf="fileName">{{ fileName }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,9 +21,13 @@ $previewSize: 100px !default;
|
|||||||
|
|
||||||
.tb-container {
|
.tb-container {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
padding: 0 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
label.tb-title {
|
label.tb-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,19 +82,46 @@ $previewSize: 100px !default;
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
.mat-icon {
|
.mat-icon {
|
||||||
margin-right: 17px;
|
margin-right: 17px;
|
||||||
}
|
color: rgba(0,0,0,0.12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tb-file-info-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-file-name {
|
||||||
|
color: rgba(0, 0, 0, 0.54);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-file-hint {
|
||||||
|
color: rgba(0, 0, 0, 0.38);
|
||||||
|
font-weight: 400;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:host ::ng-deep {
|
:host ::ng-deep {
|
||||||
button.browse-file {
|
button.mat-mdc-button.mat-mdc-button-base.browse-file {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
height: 24px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 16px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,10 +32,12 @@ import { Store } from '@ngrx/store';
|
|||||||
import { AppState } from '@core/core.state';
|
import { AppState } from '@core/core.state';
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
|
||||||
import { FlowDirective } from '@flowjs/ngx-flow';
|
import { FlowDirective } from '@flowjs/ngx-flow';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { UtilsService } from '@core/services/utils.service';
|
import { UtilsService } from '@core/services/utils.service';
|
||||||
|
import { DialogService } from '@core/services/dialog.service';
|
||||||
|
import { FileSizePipe } from '@shared/pipe/file-size.pipe';
|
||||||
|
import { coerceBoolean } from '@shared/decorators/coercion';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'tb-file-input',
|
selector: 'tb-file-input',
|
||||||
@ -72,44 +74,29 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
|
|||||||
@Input()
|
@Input()
|
||||||
dropLabel: string;
|
dropLabel: string;
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
maxSizeByte: number;
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
contentConvertFunction: (content: string) => any;
|
contentConvertFunction: (content: string) => any;
|
||||||
|
|
||||||
private requiredValue: boolean;
|
@Input()
|
||||||
|
@coerceBoolean()
|
||||||
get required(): boolean {
|
required: boolean;
|
||||||
return this.requiredValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
set required(value: boolean) {
|
@coerceBoolean()
|
||||||
const newVal = coerceBooleanProperty(value);
|
requiredAsError: boolean;
|
||||||
if (this.requiredValue !== newVal) {
|
|
||||||
this.requiredValue = newVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private requiredAsErrorValue: boolean;
|
|
||||||
|
|
||||||
get requiredAsError(): boolean {
|
|
||||||
return this.requiredAsErrorValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
set requiredAsError(value: boolean) {
|
|
||||||
const newVal = coerceBooleanProperty(value);
|
|
||||||
if (this.requiredAsErrorValue !== newVal) {
|
|
||||||
this.requiredAsErrorValue = newVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
|
@coerceBoolean()
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
existingFileName: string;
|
existingFileName: string;
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
|
@coerceBoolean()
|
||||||
readAsBinary = false;
|
readAsBinary = false;
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
@ -148,7 +135,9 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
|
|||||||
|
|
||||||
constructor(protected store: Store<AppState>,
|
constructor(protected store: Store<AppState>,
|
||||||
private utils: UtilsService,
|
private utils: UtilsService,
|
||||||
public translate: TranslateService) {
|
private translate: TranslateService,
|
||||||
|
private dialog: DialogService,
|
||||||
|
private fileSize: FileSizePipe) {
|
||||||
super(store);
|
super(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,11 +145,22 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
|
|||||||
this.autoUploadSubscription = this.flow.events$.subscribe(event => {
|
this.autoUploadSubscription = this.flow.events$.subscribe(event => {
|
||||||
if (event.type === 'filesAdded') {
|
if (event.type === 'filesAdded') {
|
||||||
const readers = [];
|
const readers = [];
|
||||||
|
let showMaxSizeAlert = false;
|
||||||
(event.event[0] as flowjs.FlowFile[]).forEach(file => {
|
(event.event[0] as flowjs.FlowFile[]).forEach(file => {
|
||||||
if (this.filterFile(file)) {
|
if (!this.checkMaxSize(file)) {
|
||||||
|
showMaxSizeAlert = true;
|
||||||
|
} else if (this.filterFile(file)) {
|
||||||
readers.push(this.readerAsFile(file));
|
readers.push(this.readerAsFile(file));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (showMaxSizeAlert) {
|
||||||
|
this.dialog.alert(
|
||||||
|
this.translate.instant('dashboard.cannot-upload-file'),
|
||||||
|
this.translate.instant('dashboard.maximum-upload-file-size', {size: this.fileSize.transform(this.maxSizeByte)})
|
||||||
|
).subscribe(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
if (readers.length) {
|
if (readers.length) {
|
||||||
Promise.all(readers).then((files) => {
|
Promise.all(readers).then((files) => {
|
||||||
files = files.filter(file => file.fileContent != null || file.files != null);
|
files = files.filter(file => file.fileContent != null || file.files != null);
|
||||||
@ -218,6 +218,10 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private checkMaxSize(file: flowjs.FlowFile): boolean {
|
||||||
|
return !(this.maxSizeByte && this.maxSizeByte < file.size);
|
||||||
|
}
|
||||||
|
|
||||||
private filterFile(file: flowjs.FlowFile): boolean {
|
private filterFile(file: flowjs.FlowFile): boolean {
|
||||||
if (this.allowedExtensions) {
|
if (this.allowedExtensions) {
|
||||||
return this.allowedExtensions.split(',').indexOf(file.getExtension()) > -1;
|
return this.allowedExtensions.split(',').indexOf(file.getExtension()) > -1;
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
label="{{'image.image-preview' | translate}}"
|
label="{{'image.image-preview' | translate}}"
|
||||||
formControlName="file"
|
formControlName="file"
|
||||||
showFileName
|
showFileName
|
||||||
|
[maxSizeByte]="maxResourceSize"
|
||||||
[fileName]="data?.image?.fileName"
|
[fileName]="data?.image?.fileName"
|
||||||
(fileNameChanged)="imageFileNameChanged($event)">
|
(fileNameChanged)="imageFileNameChanged($event)">
|
||||||
</tb-image-input>
|
</tb-image-input>
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
import { Component, Inject, OnInit, SkipSelf } from '@angular/core';
|
import { Component, Inject, OnInit, SkipSelf } from '@angular/core';
|
||||||
import { ErrorStateMatcher } from '@angular/material/core';
|
import { ErrorStateMatcher } from '@angular/material/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { Store } from '@ngrx/store';
|
import { select, Store } from '@ngrx/store';
|
||||||
import { AppState } from '@core/core.state';
|
import { AppState } from '@core/core.state';
|
||||||
import {
|
import {
|
||||||
FormGroupDirective,
|
FormGroupDirective,
|
||||||
@ -31,6 +31,8 @@ import { DialogComponent } from '@shared/components/dialog.component';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ImageService } from '@core/http/image.service';
|
import { ImageService } from '@core/http/image.service';
|
||||||
import { ImageResourceInfo, imageResourceType } from '@shared/models/resource.models';
|
import { ImageResourceInfo, imageResourceType } from '@shared/models/resource.models';
|
||||||
|
import { selectMaxResourceSize } from '@core/auth/auth.selectors';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
|
||||||
export interface UploadImageDialogData {
|
export interface UploadImageDialogData {
|
||||||
image?: ImageResourceInfo;
|
image?: ImageResourceInfo;
|
||||||
@ -51,6 +53,8 @@ export class UploadImageDialogComponent extends
|
|||||||
|
|
||||||
submitted = false;
|
submitted = false;
|
||||||
|
|
||||||
|
maxResourceSize = 0;
|
||||||
|
|
||||||
constructor(protected store: Store<AppState>,
|
constructor(protected store: Store<AppState>,
|
||||||
protected router: Router,
|
protected router: Router,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
@ -59,6 +63,11 @@ export class UploadImageDialogComponent extends
|
|||||||
public dialogRef: MatDialogRef<UploadImageDialogComponent, ImageResourceInfo>,
|
public dialogRef: MatDialogRef<UploadImageDialogComponent, ImageResourceInfo>,
|
||||||
public fb: UntypedFormBuilder) {
|
public fb: UntypedFormBuilder) {
|
||||||
super(store, router, dialogRef);
|
super(store, router, dialogRef);
|
||||||
|
this.store.pipe(select(selectMaxResourceSize)).pipe(
|
||||||
|
take(1)
|
||||||
|
).subscribe(maxResourceSize => {
|
||||||
|
this.maxResourceSize = maxResourceSize;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
|||||||
@ -3511,6 +3511,7 @@
|
|||||||
"ota-update": "OTA update",
|
"ota-update": "OTA update",
|
||||||
"ota-update-details": "OTA update details",
|
"ota-update-details": "OTA update details",
|
||||||
"ota-updates": "OTA updates",
|
"ota-updates": "OTA updates",
|
||||||
|
"package-file": "Package file",
|
||||||
"package-type": "Package type",
|
"package-type": "Package type",
|
||||||
"packages-repository": "Packages repository",
|
"packages-repository": "Packages repository",
|
||||||
"search": "Search packages",
|
"search": "Search packages",
|
||||||
@ -3697,6 +3698,8 @@
|
|||||||
"no-resource-text": "No resources found",
|
"no-resource-text": "No resources found",
|
||||||
"open-widgets-bundle": "Open widgets bundle",
|
"open-widgets-bundle": "Open widgets bundle",
|
||||||
"resource": "Resource",
|
"resource": "Resource",
|
||||||
|
"resource-file": "Resource file",
|
||||||
|
"resource-files": "Resource files",
|
||||||
"resource-library-details": "Resource details",
|
"resource-library-details": "Resource details",
|
||||||
"resource-type": "Resource type",
|
"resource-type": "Resource type",
|
||||||
"resources-library": "Resources library",
|
"resources-library": "Resources library",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user