[Feat] Remove add plugin from installed plugins (#9314)

* feat(plugins): implement upload selection modal and intent handling

Introduces a new upload selection modal that allows users to choose between installing a plugin locally or publishing it to the marketplace.
This change includes:
- A new `UploadSelectionComponent` with its associated HTML, SCSS, and TypeScript files.
- New actions (`PluginUploadIntentActions`) and effects (`PluginUploadIntentEffects`) to manage the upload intent flow.
- Updates to `PluginMarketplaceComponent` to trigger the upload selection.
- New Akita store (`PluginUploadIntentStore`) and query (`PluginUploadIntentQuery`) to manage the upload intent state.
- Internationalization updates for the new upload selection options.
This refactors the previous direct "Upload" action in the plugin marketplace to provide a more guided user experience. The "Add Plugin" button's functionality is now integrated within the "Install Locally" flow of the new upload selection modal.

* feat(plugins): redesign add plugin modal

Refactors the add plugin modal to improve user experience and UI clarity.
The changes include:
- A new card-based design for installation options (local, CDN, NPM) with clear descriptions and icons.
- Improved form structure for CDN and NPM installations, including better input fields and error handling.
- Enhanced accessibility features such as ARIA labels and focus management.
- A new signal-based approach for managing component state.
- Refined styling for better responsiveness and visual appeal.
- Added a subtitle to the plugin modal header.

* feat(plugins): enhance plugin list and marketplace detail components with improved state handling and accessibility features

* feat(plugins): adjust nb-toggle label margin for improved layout consistency

* fix(i18n): remove unused upload translation

The "UPLOAD" translation key was no longer being used in the UI. Removing it helps to keep the i18n files clean and maintainable.

* fix(desktop-ui-lib): enable keyboard navigation for upload selection

Add keyboard event listeners for 'enter' and 'space' keys to the upload selection cards. This allows users to select an installation option using their keyboard, improving accessibility and usability.

* feat(plugins): translate add plugin options and aria-labels

Translate the descriptive text and ARIA labels for the "Install from Computer", "Install from CDN", and "Install from NPM" options within the add plugin component.
This change ensures that these options are properly localized and accessible to users who rely on translated interfaces. The `en.json` file has been updated with new translation keys for these strings.

* fix(add-plugin): correct conditional rendering logic

Update the template to use `@else if` for conditional rendering of plugin installation forms. This ensures that only one form is displayed at a time based on the context (CDN or NPM), preventing potential rendering issues and improving user experience.

* fix(desktop-ui-lib): refactor plugin settings styles to use theme variables

This commit refactors the SCSS styles for the plugin settings component in the desktop UI library. Instead of using hardcoded color and shadow values, it now utilizes theme variables. This change improves consistency across the application and makes it easier to manage the visual theme.
The specific changes include:
- Replacing hardcoded rgba shadows with `var(--gauzy-shadow)`.
- Replacing hardcoded rgba borders with `var(--border-basic-color-3)`.
- Replacing hardcoded rgba background colors with theme-specific variables like `var(--background-basic-color-2)`, `var(--color-primary-100)`, `var(--color-success-100)`, and `var(--color-danger-100)`.
- Replacing hardcoded focus outlines with `var(--color-primary-focus)`.
- Replacing hardcoded color values for icons and text with theme variables.
- Replacing hardcoded input border colors on hover and focus with theme variables.
- Replacing hardcoded background for the plugin details section with `var(--gauzy-card-2)`.

* fix(add-plugin): initialize npmModel with default structure

The `npmModel` was not being initialized with its default structure when the `reset` method was called. This commit ensures that `npmModel` is properly reset to its expected shape, preventing potential issues with form handling or state management.

* refactor(plugin-list): improve readability of template variable declarations

Break down the long `@let` declaration into multiple lines for better readability.
Add comments to explain the purpose of each state variable.

* fix(plugin-list): use theme variable for hover shadow

Replaces the hardcoded box-shadow value with the `--gauzy-shadow` CSS variable for the plugin list item hover effect. This ensures consistency with the application's theme and allows for easier global shadow management.

* fix(desktop-ui-lib): improve danger button styling and use inject

This commit refactors the `.danger` CSS class to use CSS variables for more maintainable and themeable background colors. It also updates the `PluginUploadIntentEffects` to use the `inject` function for dependency injection, which is a more modern Angular practice.

* chore: remove unused animation keyframes

The `@keyframes pulse` was defined but not used anywhere in the
component's stylesheet. This commit removes the unused animation
definition to clean up the code and reduce file size.

* fix(plugin-upload-intent): handle empty or null intents correctly

The previous implementation incorrectly returned an empty array or a single action without proper handling of null or undefined upload intents. This change ensures that the observable correctly emits `EMPTY` when no valid intent is provided, and uses `of()` for dispatching single actions to avoid unexpected behavior.

* fix(plugin-upload-intent): allow null for intent

The `intent` property in `PluginUploadIntentQuery` and `IPluginUploadIntentState` was previously typed as `UploadIntent`, implying it must always have a value. However, the `setIntent` method in `PluginUploadIntentStore` was intended to allow clearing the intent by not explicitly setting it.
This change updates the type definitions to `UploadIntent | null` to accurately reflect the possibility of the intent being null, allowing the clearing of the upload intent state.

* fix(ui): add role button to upload selection cards

Adds the `role="button"` attribute to the `nb-card` elements within the upload selection component. This improves accessibility by clearly indicating that these cards are interactive elements that can be activated using a mouse or keyboard.

* fix(desktop-ui-lib): update aria-label for close button

The aria-label for the close button in the upload selection component was previously hardcoded. This commit updates it to use the translated string 'BUTTONS.CLOSE' for better internationalization.

* feat(plugins): internationalize plugin marketplace details

This commit introduces internationalization for various text elements within the plugin marketplace detail view.
Key changes include:
- Translating labels such as "by", "downloads", "Free", "No description available", "Subscription Required", "Installing...", "Uninstalling...", "Trial", "Updated", "License", "Released", and "more tags".
- Localizing the dynamic text for install/uninstall progress.
- Translating the labels for the install toggle button based on plugin status and plan.
- Internationalizing the action menu items for plugins, including "View Details", "Settings", "Manage Subscription", "Manage Users", "Homepage", "Repository", "Edit Plugin", and "Delete".
This enhancement improves the accessibility and usability of the plugin marketplace for a global audience.

* fix(plugins): update scss for gradient and button alignment

Refactors the linear gradient calculation in `add-plugin.component.scss` to use `color-mix` for better theme integration. Adjusts `plugin-list.component.scss` to change `justify-content` from `stretch` to `flex-start` for improved button alignment.

* refactor(plugin-list): extract smart table settings to a method

Extract the `smartTableSettings` object into a dedicated `buildSmartTableSettings` method. This improves code organization and makes it easier to reinitialize settings, especially when the language changes.
This change also moves the call to `loadPlugins()` to `ngAfterViewInit` to ensure that the table settings are fully initialized before attempting to load plugins. This addresses a potential race condition.

* fix(ui): adjust button sizes and styling in plugin components

Update the `size` attribute for close buttons in both the `add-plugin` and `upload-selection` components from `tiny` to `small`. This ensures a consistent and more appropriately sized close button across these plugin-related UI elements.
Additionally, the SCSS for the `.close-button` in `add-plugin.component.scss` has been updated to use `var(--border-radius)` for its `border-radius` property, aligning with global theming. Padding has also been added to `.close-button` in both components for better visual spacing.

* fix(desktop-ui-lib): use theme variable for card shadows

Refactors the shadow styles in the upload selection component to use the `--gauzy-shadow` theme variable. This ensures consistency in the application's visual styling and makes it easier to manage shadows across different components.
This commit is contained in:
Kifungo A
2026-01-07 17:04:40 +01:00
committed by GitHub
parent 8ae889848a
commit 43d68e4ef4
21 changed files with 1918 additions and 454 deletions
+1 -1
View File
@@ -182,4 +182,4 @@
"@gauzy/desktop-window": "file:../../../dist/packages/desktop-window",
"@gauzy/ui-config": "file:../../../dist/packages/ui-config"
}
}
}
@@ -1,176 +1,273 @@
@let installing = query.installing$() |async;
@let installing = query.installing$() | async;
<nb-card>
<nb-card-header class="d-flex flex-column">
<span class="cancel"><i (click)="close()" class="fas fa-times"></i></span>
<h4>{{ 'TIMER_TRACKER.SETTINGS.PLUGIN' | translate }}</h4>
</nb-card-header>
<nb-card-body>
<ng-container *ngIf="context === 'local'; else web">
<div class="full-width mb-3 description">
{{ 'TIMER_TRACKER.SETTINGS.ADD_PLUGIN_INSTALLATION_DESCRIPTION' | translate }}
</div>
<div class="load-button">
<button
nbButton
[disabled]="installing"
class="full-width button"
status="primary"
type="submit"
(click)="localPluginInstall()"
>
<nb-icon *gauzySpinnerButton="installing" icon="monitor-outline"></nb-icon
>{{ 'BUTTONS.LOAD_PLUGIN' | translate }}
</button>
<button
nbButton
[disabled]="installing"
class="full-width button"
status="info"
type="submit"
(click)="context = 'cdn'"
>
<nb-icon icon="globe-outline"></nb-icon>{{ 'BUTTONS.FROM_CDN' | translate }}
</button>
<button
nbButton
[disabled]="installing"
class="full-width button"
status="danger"
type="submit"
(click)="context = 'npm'"
>
<i class="fab fa-npm"></i>
</button>
<button
nbButton
[disabled]="installing"
class="full-width button"
status="warning"
type="submit"
(click)="redirectToStore()"
>
<nb-icon icon="grid-outline"></nb-icon>
{{ 'BUTTONS.MARKETPLACE' | translate }}
</button>
</div>
</ng-container>
</nb-card-body>
</nb-card>
<ng-template #web>
<ng-container *ngIf="context === 'cdn'; else npm">
<form class="form" (ngSubmit)="installPlugin(input.value)">
<label
><button (click)="reset()" class="return" status="primary" size="small" outline nbButton>
<nb-icon icon="arrow-back-outline"></nb-icon>
</button>
{{ 'BUTTONS.ADD_PLUGIN' | translate }}</label
>
<div class="description">
{{ 'TIMER_TRACKER.SETTINGS.ADD_PLUGIN_CDN_DESCRIPTION' | translate }}
</div>
<input
nbInput
#input
type="url"
[status]="error ? 'danger' : 'basic'"
placeholder="https://cdn.server.tld/public/plugin.zip"
/>
<div class="error" *ngIf="error">
{{ error | translate }}
<nb-card-header>
<div class="header-content">
<div class="header-text">
<h4>{{ 'TIMER_TRACKER.SETTINGS.PLUGIN' | translate }}</h4>
<span class="subtitle">{{ 'TIMER_TRACKER.SETTINGS.PLUGIN_SUBTITLE' | translate }}</span>
</div>
<button
nbButton
[disabled]="!input.value || installing"
status="primary"
type="submit"
(click)="installPlugin(input.value)"
ghost
size="small"
(click)="close()"
class="close-button"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
{{ 'BUTTONS.INSTALL' | translate
}}<nb-icon *gauzySpinnerButton="installing" icon="cloud-download-outline"></nb-icon>
<nb-icon icon="close-outline"></nb-icon>
</button>
</form>
</ng-container>
</ng-template>
</div>
</nb-card-header>
<ng-template #npm>
<ng-container *ngIf="context === 'npm'">
<form class="form" (ngSubmit)="installPluginFromNPM()">
<label>
<button (click)="reset()" class="return" status="primary" size="small" outline nbButton>
<nb-card-body>
@if (isLocalContext()) {
<div class="plugin-options">
<p class="description">
{{ 'TIMER_TRACKER.SETTINGS.ADD_PLUGIN_INSTALLATION_DESCRIPTION' | translate }}
</p>
<div class="installation-grid">
<nb-card
class="installation-option"
[class.disabled]="installing"
(click)="!installing && localPluginInstall()"
(keydown.enter)="!installing && localPluginInstall()"
(keydown.space)="!installing && localPluginInstall()"
[tabindex]="installing ? -1 : 0"
[attr.aria-label]="'BUTTONS.LOAD_PLUGIN' | translate"
role="button"
>
<nb-card-body>
<div class="option-icon-container local">
<nb-icon
*gauzySpinnerButton="installing"
icon="monitor-outline"
class="option-icon"
></nb-icon>
</div>
<h6 class="option-title">{{ 'BUTTONS.LOAD_PLUGIN' | translate }}</h6>
<p class="option-description">
{{ 'TIMER_TRACKER.SETTINGS.INSTALL_FROM_COMPUTER' | translate }}
</p>
</nb-card-body>
</nb-card>
<nb-card
class="installation-option"
[class.disabled]="installing"
(click)="!installing && switchContext('cdn')"
(keydown.enter)="!installing && switchContext('cdn')"
(keydown.space)="!installing && switchContext('cdn')"
[tabindex]="installing ? -1 : 0"
[attr.aria-label]="'BUTTONS.FROM_CDN' | translate"
role="button"
>
<nb-card-body>
<div class="option-icon-container cdn">
<nb-icon icon="globe-outline" class="option-icon"></nb-icon>
</div>
<h6 class="option-title">{{ 'BUTTONS.FROM_CDN' | translate }}</h6>
<p class="option-description">
{{ 'TIMER_TRACKER.SETTINGS.INSTALL_FROM_CDN' | translate }}
</p>
</nb-card-body>
</nb-card>
<nb-card
class="installation-option"
[class.disabled]="installing"
(click)="!installing && switchContext('npm')"
(keydown.enter)="!installing && switchContext('npm')"
(keydown.space)="!installing && switchContext('npm')"
[tabindex]="installing ? -1 : 0"
[attr.aria-label]="'BUTTONS.FROM_NPM' | translate"
role="button"
>
<nb-card-body>
<div class="option-icon-container npm">
<nb-icon icon="cube-outline" class="option-icon"></nb-icon>
</div>
<h6 class="option-title">{{ 'BUTTONS.FROM_NPM' | translate }}</h6>
<p class="option-description">
{{ 'TIMER_TRACKER.SETTINGS.INSTALL_FROM_NPM' | translate }}
</p>
</nb-card-body>
</nb-card>
</div>
</div>
} @else if (isCdnContext()) {
<form class="plugin-form" (ngSubmit)="installPlugin(cdnUrl())">
<div class="form-header">
<button
nbButton
ghost
size="small"
(click)="reset()"
class="back-button"
type="button"
[attr.aria-label]="'BUTTONS.BACK' | translate"
>
<nb-icon icon="arrow-back-outline"></nb-icon>
</button>
{{ 'BUTTONS.ADD_PLUGIN' | translate }}
</label>
<span class="form-title">{{ 'BUTTONS.ADD_PLUGIN' | translate }}</span>
</div>
<div class="description">
{{ 'TIMER_TRACKER.SETTINGS.ADD_PLUGIN_CDN_DESCRIPTION' | translate }}
</div>
<input
nbInput
fullWidth
[value]="cdnUrl()"
(input)="cdnUrl.set($any($event.target).value)"
type="url"
[status]="error() ? 'danger' : 'basic'"
placeholder="https://cdn.server.tld/public/plugin.zip"
[attr.aria-label]="'TIMER_TRACKER.SETTINGS.CDN_URL' | translate"
autocomplete="url"
required
/>
@if (error()) {
<nb-alert status="danger" class="error-alert">
{{ error() | translate }}
</nb-alert>
}
<button
nbButton
fullWidth
[disabled]="!cdnUrl() || installing"
status="primary"
type="submit"
class="submit-button"
>
<span>{{ 'BUTTONS.INSTALL' | translate }}</span>
<nb-icon *gauzySpinnerButton="installing" icon="cloud-download-outline"></nb-icon>
</button>
</form>
} @else if (isNpmContext()) {
<form class="plugin-form" (ngSubmit)="installPluginFromNPM()">
<div class="form-header">
<button
nbButton
ghost
size="small"
(click)="reset()"
class="back-button"
type="button"
[attr.aria-label]="'BUTTONS.BACK' | translate"
>
<nb-icon icon="arrow-back-outline"></nb-icon>
</button>
<span class="form-title">{{ 'BUTTONS.ADD_PLUGIN' | translate }}</span>
</div>
<div class="description">
{{ 'TIMER_TRACKER.SETTINGS.ADD_PLUGIN_NPM_DESCRIPTION' | translate }}
</div>
<input
nbInput
type="text"
id="pkgName"
name="pkgName"
[(ngModel)]="npmModel.pkg.name"
required
#pkgName="ngModel"
[status]="error ? 'danger' : 'basic'"
[placeholder]="'TIMER_TRACKER.SETTINGS.PACKAGE_NAME' | translate"
/>
<input
nbInput
type="text"
id="pkgVersion"
name="pkgVersion"
[(ngModel)]="npmModel.pkg.version"
[status]="error ? 'danger' : 'basic'"
[placeholder]="'TIMER_TRACKER.SETTINGS.PACKAGE_VERSION' | translate"
/>
<nb-toggle [checked]="showRegistry" (checkedChange)="toggleRegistry($event)" status="basic">
{{ 'TIMER_TRACKER.SETTINGS.REGISTRY' | translate }}
</nb-toggle>
<ng-container *ngIf="showRegistry">
<div class="form-group">
<label for="pkgName" class="form-label">
{{ 'TIMER_TRACKER.SETTINGS.PACKAGE_NAME' | translate }} <span class="required">*</span>
</label>
<input
nbInput
gaTextMask
[config]="{
text: npmModel.registry.authToken,
replacement: 0.75,
maskFromLeft: false
}"
(unmaskedValueChange)="handleUnmaskedValueChange($event)"
fullWidth
type="text"
id="authToken"
name="authToken"
[status]="error ? 'danger' : 'basic'"
[placeholder]="'TIMER_TRACKER.SETTINGS.AUTH_TOKEN' | translate"
id="pkgName"
[value]="npmModel().pkg.name || ''"
(input)="updateNpmPackageName($any($event.target).value)"
required
[status]="error() ? 'danger' : 'basic'"
[placeholder]="'TIMER_TRACKER.SETTINGS.PACKAGE_NAME' | translate"
autocomplete="off"
aria-required="true"
/>
<input
nbInput
type="url"
id="privateURL"
name="privateURL"
[(ngModel)]="npmModel.registry.privateURL"
pattern="https?://.+"
#privateURL="ngModel"
[status]="error ? 'danger' : 'basic'"
[placeholder]="'TIMER_TRACKER.SETTINGS.PRIVATE_REGISTRY_URL' | translate"
/>
</ng-container>
<div class="error" *ngIf="error">
{{ error | translate }}
</div>
<button nbButton [disabled]="!pkgName.value || installing" status="primary" type="submit">
{{ 'BUTTONS.INSTALL' | translate }}
<div class="form-group">
<label for="pkgVersion" class="form-label">
{{ 'TIMER_TRACKER.SETTINGS.PACKAGE_VERSION' | translate }}
</label>
<input
nbInput
fullWidth
type="text"
id="pkgVersion"
[value]="npmModel().pkg.version || ''"
(input)="updateNpmPackageVersion($any($event.target).value)"
[status]="error() ? 'danger' : 'basic'"
[placeholder]="'TIMER_TRACKER.SETTINGS.PACKAGE_VERSION' | translate"
/>
</div>
<div class="registry-toggle">
<nb-toggle [checked]="showRegistry()" (checkedChange)="toggleRegistry($event)" status="primary">
{{ 'TIMER_TRACKER.SETTINGS.REGISTRY' | translate }}
</nb-toggle>
</div>
@if (showRegistry()) {
<div class="registry-fields">
<div class="form-group">
<label for="authToken" class="form-label">
{{ 'TIMER_TRACKER.SETTINGS.AUTH_TOKEN' | translate }}
</label>
<input
nbInput
fullWidth
gaTextMask
[config]="{
text: npmModel().registry.authToken,
replacement: 0.75,
maskFromLeft: false
}"
(unmaskedValueChange)="handleUnmaskedValueChange($event)"
type="text"
id="authToken"
[status]="error() ? 'danger' : 'basic'"
[placeholder]="'TIMER_TRACKER.SETTINGS.AUTH_TOKEN' | translate"
/>
</div>
<div class="form-group">
<label for="privateURL" class="form-label">
{{ 'TIMER_TRACKER.SETTINGS.PRIVATE_REGISTRY_URL' | translate }}
</label>
<input
nbInput
fullWidth
type="url"
id="privateURL"
[value]="npmModel().registry.privateURL || ''"
(input)="updatePrivateUrl($any($event.target).value)"
pattern="https?://.+"
[status]="error() ? 'danger' : 'basic'"
[placeholder]="'TIMER_TRACKER.SETTINGS.PRIVATE_REGISTRY_URL' | translate"
/>
</div>
</div>
} @if (error()) {
<nb-alert status="danger" class="error-alert">
{{ error() | translate }}
</nb-alert>
}
<button
nbButton
fullWidth
[disabled]="!npmModel().pkg.name || installing"
status="primary"
type="submit"
class="submit-button"
>
<span>{{ 'BUTTONS.INSTALL' | translate }}</span>
<nb-icon *gauzySpinnerButton="installing" icon="cloud-download-outline"></nb-icon>
</button>
</form>
</ng-container>
</ng-template>
}
</nb-card-body>
</nb-card>
@@ -1,89 +1,477 @@
.error {
color: red;
font-weight: 500;
font-size: 13px;
padding: 8px;
background-color: rgb(255 0 0 / 10%);
border-radius: 4px;
text-wrap: pretty;
overflow: hidden;
text-overflow: ellipsis;
}
// Card Container
nb-card {
width: 350px;
display: flex;
width: 100%;
max-width: min(600px, 92vw);
display: flex;
flex-direction: column;
border-radius: calc(var(--border-radius) * 2);
box-shadow: var(--gauzy-shadow);
background: var(--gauzy-card-1);
animation: fadeIn 0.3s ease-in-out;
@media (max-width: 576px) {
max-width: 96vw;
border-radius: calc(var(--border-radius) * 1.5);
box-shadow: var(--gauzy-shadow);
}
@media (min-width: 577px) and (max-width: 768px) {
max-width: 90vw;
}
}
nb-card-body {
background-color: var(--gauzy-card-2);
border-radius: var(--border-radius);
padding: 1rem;
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
// Header
nb-card-header {
padding: 1.75rem 1.5rem;
background: var(--gauzy-card-1);
border-bottom: 1px solid var(--border-basic-color-3);
@media (max-width: 576px) {
padding: 1.25rem 1rem;
}
@media (min-width: 577px) and (max-width: 768px) {
padding: 1.5rem 1.25rem;
}
}
.header-content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.header-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
h4 {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0em;
color: var(--gauzy-text-color-1);
margin: 0;
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
color: var(--gauzy-text-color-1);
@media (max-width: 576px) {
font-size: 1.125rem;
}
}
label {
font-weight: 600;
color: var(--gauzy-text-color-2);
.subtitle {
font-size: 0.875rem;
font-weight: 400;
color: var(--gauzy-text-color-2);
line-height: 1.4;
}
button {
width: fit-content;
align-self: flex-end;
}
.close-button {
flex-shrink: 0;
padding: 0.5rem;
color: var(--gauzy-text-color-2);
transition: all 0.2s ease-in-out;
border-radius: var(--border-radius);
nb-card-header {
position: relative;
padding: 1rem;
}
.cancel {
position: absolute;
right: 0.75rem;
top: 0.25rem;
cursor: pointer;
font-size: 11px;
&:hover {
color: var(--gauzy-text-color-1);
background: var(--background-basic-color-2);
transform: rotate(90deg);
}
&:focus {
outline: 2px solid var(--color-primary-focus);
outline-offset: 2px;
}
nb-icon {
display: flex;
align-items: center;
justify-content: center;
}
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
// Body
nb-card-body {
padding: 1.75rem 1.5rem;
background: var(--gauzy-card-2);
overflow: auto;
max-height: 80vh !important;
.load-button {
justify-content: center;
align-items: stretch;
align-content: space-evenly;
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 1rem;
row-gap: 1rem;
@media (max-width: 576px) {
padding: 1.25rem 1rem;
}
.button {
height: 72px;
border-radius: var(--border-radius);
i {
font-size: 2rem;
}
}
@media (min-width: 577px) and (max-width: 768px) {
padding: 1.5rem 1.25rem;
}
}
// Description Text
.description {
font-size: 13px;
font-weight: 500;
color: var(--gauzy-text-color-2);
font-size: 0.9375rem;
font-weight: 400;
color: var(--gauzy-text-color-2);
line-height: 1.65;
margin-bottom: 1.75rem;
max-width: 100%;
@media (max-width: 576px) {
font-size: 0.875rem;
margin-bottom: 1.25rem;
}
}
.return {
padding: 4px 2px !important;
// Installation Options Grid
.plugin-options {
display: flex;
flex-direction: column;
}
.installation-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
width: 100%;
@media (max-width: 576px) {
grid-template-columns: 1fr;
gap: 0.875rem;
}
@media (min-width: 577px) and (max-width: 768px) {
grid-template-columns: repeat(3, 1fr);
gap: 0.875rem;
}
@media (min-width: 400px) and (max-width: 576px) {
grid-template-columns: 1fr;
}
}
.installation-option {
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border: 2px solid transparent;
background: var(--gauzy-card-1);
border-radius: calc(var(--border-radius) * 1.5);
box-shadow: var(--gauzy-shadow);
&:hover:not(.disabled) {
transform: translateY(-3px);
box-shadow: var(--gauzy-shadow);
border-color: var(--color-primary-default);
.option-icon-container {
transform: scale(1.05);
}
}
&:focus {
outline: none;
border-color: var(--color-primary-default);
box-shadow: 0 0 0 3px var(--color-primary-focus);
}
&.disabled {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
}
nb-card-body {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 1.5rem 1rem;
@media (max-width: 576px) {
padding: 1.25rem 1rem;
}
}
.option-icon-container {
width: 64px;
height: 64px;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
&.local {
background: var(--color-primary-100);
.option-icon {
color: var(--color-primary-default);
}
}
&.cdn {
background: var(--color-success-100);
.option-icon {
color: var(--color-success-default);
}
}
&.npm {
background: var(--color-danger-100);
.option-icon {
color: var(--color-danger-default);
}
}
.option-icon {
font-size: 2rem;
}
@media (max-width: 576px) {
width: 56px;
height: 56px;
margin-bottom: 0.75rem;
.option-icon {
font-size: 1.75rem;
}
}
}
.option-title {
margin: 0 0 0.5rem 0;
font-weight: 600;
color: var(--gauzy-text-color-1);
font-size: 1rem;
@media (max-width: 576px) {
font-size: 0.9375rem;
}
}
.option-description {
margin: 0;
color: var(--gauzy-text-color-2);
font-size: 0.875rem;
line-height: 1.5;
@media (max-width: 576px) {
font-size: 0.8125rem;
}
}
}
// Form Styles
.plugin-form {
display: flex;
flex-direction: column;
gap: 1rem;
@media (max-width: 576px) {
gap: 0.875rem;
}
}
.form-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.back-button {
color: var(--gauzy-text-color-2);
padding: 0.25rem;
transition: all 0.2s ease-in-out;
border-radius: 50%;
&:hover {
color: var(--gauzy-text-color-1);
background: var(--background-basic-color-2);
transform: translateX(-2px);
}
&:focus {
outline: 2px solid var(--color-primary-focus);
outline-offset: 2px;
}
nb-icon {
display: flex;
align-items: center;
justify-content: center;
}
}
.form-title {
font-size: 1rem;
font-weight: 600;
color: var(--gauzy-text-color-1);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--gauzy-text-color-1);
.required {
color: var(--color-danger-default);
}
}
// Input Styles
input[nbInput] {
border-radius: var(--border-radius);
font-size: 0.9375rem;
transition: all 0.2s ease-in-out;
min-height: 44px;
&::placeholder {
color: var(--gauzy-text-color-2);
opacity: 0.65;
}
&:hover:not([disabled]) {
border-color: var(--color-primary-hover);
}
&:focus {
box-shadow: 0 0 0 3px var(--color-primary-focus);
border-color: var(--color-primary-default);
}
&[status='danger'] {
&:focus {
box-shadow: 0 0 0 3px var(--color-danger-focus);
}
}
@media (max-width: 576px) {
font-size: 1rem; // Prevents zoom on iOS
min-height: 48px;
}
}
// Registry Toggle
.registry-toggle {
padding: 0.5rem 0;
}
.registry-fields {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--gauzy-card-2);
border-radius: var(--border-radius);
border: 1px solid var(--border-basic-color-3);
animation: slideDown 0.2s ease-in-out;
@media (max-width: 576px) {
padding: 0.875rem;
gap: 0.875rem;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Error Alert
.error-alert {
margin: 0;
font-size: 0.8125rem;
border-radius: var(--border-radius);
}
// Submit Button
.submit-button {
margin-top: 0.75rem;
border-radius: calc(var(--border-radius) * 1.25);
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
min-height: 48px;
font-weight: 600;
font-size: 0.9375rem;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, color-mix(in srgb, var(--gauzy-card-1) 25%, transparent), transparent);
opacity: 0;
transition: opacity 0.25s ease-in-out;
}
&:hover:not([disabled]) {
transform: translateY(-2px);
box-shadow: var(--gauzy-shadow);
&::before {
opacity: 1;
}
}
&:active:not([disabled]) {
transform: translateY(0);
box-shadow: var(--gauzy-shadow);
}
&:focus {
outline: 2px solid var(--color-primary-focus);
outline-offset: 2px;
}
&[disabled] {
cursor: not-allowed;
opacity: 0.6;
}
span {
z-index: 1;
}
nb-icon {
font-size: 1.375rem;
z-index: 1;
}
@media (max-width: 576px) {
min-height: 52px;
font-size: 1rem;
margin-top: 1rem;
}
}
@@ -1,27 +1,40 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { NbDialogRef } from '@nebular/theme';
import { Actions } from '@ngneat/effects-ng';
import { UntilDestroy } from '@ngneat/until-destroy';
import { PluginInstallationQuery } from '../plugin-marketplace/+state';
import { PluginInstallationActions } from '../plugin-marketplace/+state/actions/plugin-installation.action';
type PluginContext = 'local' | 'cdn' | 'npm';
interface NpmModel {
pkg: {
name: string | null;
version: string | null;
};
registry: {
privateURL: string | null;
authToken: string | null;
};
}
@UntilDestroy({ checkProperties: true })
@Component({
selector: 'ngx-add-plugin',
templateUrl: './add-plugin.component.html',
styleUrls: ['./add-plugin.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
})
export class AddPluginComponent {
private readonly dialogRef = inject(NbDialogRef<AddPluginComponent>);
private readonly router = inject(Router);
private readonly action = inject(Actions);
public readonly query = inject(PluginInstallationQuery);
public error = null;
public context = 'local';
public showRegistry = false;
public npmModel = {
protected readonly query = inject(PluginInstallationQuery);
protected readonly error = signal<string | null>(null);
protected readonly context = signal<PluginContext>('local');
protected readonly showRegistry = signal<boolean>(false);
protected readonly cdnUrl = signal<string>('');
protected readonly npmModel = signal<NpmModel>({
pkg: {
name: null,
version: null
@@ -30,46 +43,90 @@ export class AddPluginComponent {
privateURL: null,
authToken: null
}
};
});
public installPlugin(value: string) {
if (!value) {
this.error = 'TIMER_TRACKER.SETTINGS.PLUGIN_INSTALL_CDN_ERROR';
protected readonly isLocalContext = computed(() => this.context() === 'local');
protected readonly isCdnContext = computed(() => this.context() === 'cdn');
protected readonly isNpmContext = computed(() => this.context() === 'npm');
protected installPlugin(value: string): void {
if (!value?.trim()) {
this.error.set('TIMER_TRACKER.SETTINGS.PLUGIN_INSTALL_CDN_ERROR');
return;
}
this.context = 'cdn';
this.error.set(null);
this.action.dispatch(PluginInstallationActions.install({ url: value.trim(), contextType: 'cdn' }));
}
public localPluginInstall() {
this.context = 'local';
protected localPluginInstall(): void {
this.error.set(null);
this.action.dispatch(PluginInstallationActions.install({ contextType: 'local' }));
}
public handleUnmaskedValueChange(authToken: string) {
this.npmModel.registry.authToken = authToken;
protected handleUnmaskedValueChange(authToken: string): void {
this.npmModel.update((model) => ({
...model,
registry: { ...model.registry, authToken }
}));
}
public installPluginFromNPM() {
this.context = 'npm';
this.action.dispatch(PluginInstallationActions.install({ ...this.npmModel, contextType: 'npm' }));
protected updateNpmPackageName(name: string): void {
this.npmModel.update((model) => ({
...model,
pkg: { ...model.pkg, name }
}));
}
public close() {
protected updateNpmPackageVersion(version: string): void {
this.npmModel.update((model) => ({
...model,
pkg: { ...model.pkg, version }
}));
}
protected updatePrivateUrl(privateURL: string): void {
this.npmModel.update((model) => ({
...model,
registry: { ...model.registry, privateURL }
}));
}
protected installPluginFromNPM(): void {
const model = this.npmModel();
if (!model.pkg.name?.trim()) {
this.error.set('TIMER_TRACKER.SETTINGS.PACKAGE_NAME_REQUIRED');
return;
}
this.error.set(null);
this.action.dispatch(PluginInstallationActions.install({ ...model, contextType: 'npm' }));
}
protected close(): void {
this.dialogRef.close();
}
public reset() {
this.context = 'local';
this.error = null;
protected reset(): void {
this.context.set('local');
this.error.set(null);
this.cdnUrl.set('');
this.npmModel.set({
pkg: {
name: null,
version: null
},
registry: {
privateURL: null,
authToken: null
}
});
}
public toggleRegistry(enabled: boolean) {
this.showRegistry = enabled;
protected toggleRegistry(enabled: boolean): void {
this.showRegistry.set(enabled);
}
public async redirectToStore(): Promise<void> {
await this.router.navigate(['plugins', 'marketplace']);
this.close();
protected switchContext(contextType: PluginContext): void {
this.context.set(contextType);
this.error.set(null);
}
}
@@ -1,53 +1,78 @@
<!-- Installing -->
<!-- State variables -->
<!-- Whether a plugin is being installed -->
@let installing = installationQuery.installing$() | async;
<!-- uninstalling -->
<!-- Whether a plugin is being uninstalled -->
@let uninstalling = installationQuery.uninstalling$() | async;
<!-- activating -->
<!-- Whether a plugin is being activated -->
@let activating = query.activating$ | async;
<!-- deactivating -->
<!-- Whether a plugin is being deactivated -->
@let deactivating = query.deactivating$ | async;
<!-- disabled -->
<!-- Whether any operation is in progress -->
@let disabled = installing || uninstalling || activating || deactivating;
<!-- selected -->
<!-- The currently selected plugin -->
@let selected = query.plugin$ | async;
<!-- loading -->
<!-- Whether the plugins are loading -->
@let loading = query.isLoading$ | async;
<!-- source -->
<!-- The source data for the table -->
@let source = sourceData$ | async;
<div class="content-active">
<div class="button-container mb-3">
<div class="button-add">
<button (click)="addPlugin()" [disabled]="disabled" status="success" size="small" nbButton>
<nb-icon *gauzySpinnerButton="installing" icon="plus-outline"></nb-icon>
{{ 'BUTTONS.ADD_PLUGIN' | translate }}
</button>
</div>
<div *ngIf="selected" class="button-hide">
<!-- The list of plugins -->
@let plugins = plugins$ | async;
<!-- Whether there are any plugins available -->
@let isPluginAvailable = plugins && plugins.length > 0;
<section class="content-active first" aria-label="Plugin List">
<!-- Content States -->
@if (loading) {
<!-- Loading State -->
<div class="no-data" aria-live="polite" aria-busy="true">
<ngx-no-data-message [message]="'SM_TABLE.NO_DATA.LOADING' | translate"></ngx-no-data-message>
</div>
} @else if (isPluginAvailable) {
<!-- Action Buttons -->
@if (selected) {
<div class="button-container" aria-label="Plugin actions">
<div class="button-hide">
@if (selected.renderer) {
<button
*ngIf="selected.renderer"
class="action secondary"
status="basic"
size="small"
(click)="view()"
[disabled]="disabled"
[attr.aria-disabled]="disabled"
aria-label="View plugin details"
nbButton
>
<nb-icon icon="eye-outline"></nb-icon>{{ 'BUTTONS.VIEW' | translate }}
<nb-icon icon="eye-outline"></nb-icon>
<span class="btn-text">{{ 'BUTTONS.VIEW' | translate }}</span>
</button>
}
<button
size="small"
[status]="selected.isActivate ? 'warning' : 'primary'"
class="button-install"
class="button-status"
(click)="changeStatus()"
[disabled]="disabled"
[attr.aria-disabled]="disabled"
[attr.aria-label]="(selected.isActivate ? 'Deactivate' : 'Activate') + ' plugin'"
nbButton
>
<nb-icon
*gauzySpinnerButton="activating || deactivating"
[icon]="selected.isActivate ? 'close-circle-outline' : 'checkmark-circle-outline'"
></nb-icon
>{{ (selected.isActivate ? 'BUTTONS.DEACTIVATE' : 'BUTTONS.ACTIVATE') | translate }}
></nb-icon>
<span class="btn-text">{{
(selected.isActivate ? 'BUTTONS.DEACTIVATE' : 'BUTTONS.ACTIVATE') | translate
}}</span>
</button>
<button
size="small"
@@ -55,32 +80,40 @@
status="basic"
(click)="uninstall()"
[disabled]="disabled"
[attr.aria-disabled]="disabled"
aria-label="Uninstall plugin"
nbButton
>
<nb-icon *gauzySpinnerButton="uninstalling" status="danger" icon="slash-outline"></nb-icon>
{{ 'BUTTONS.UNINSTALL' | translate }}
<span class="btn-text">{{ 'BUTTONS.UNINSTALL' | translate }}</span>
</button>
</div>
</div>
<div *ngIf="(plugins$ | async)?.length > 0; else noPlugin" class="col-12 custom-table">
}
<!-- Table View -->
<div class="custom-table" aria-label="Plugins table">
<div class="table-scroll-container">
@if (source) {
<angular2-smart-table
#pluginTable
(userRowSelect)="handleRowSelection($event)"
[settings]="smartTableSettings"
[source]="source"
style="cursor: pointer"
>
</angular2-smart-table>
[class.table-disabled]="disabled"
[attr.aria-disabled]="disabled"
></angular2-smart-table>
}
</div>
<div class="pagination-container">
@if (source) {
<nav class="pagination-container" aria-label="Table pagination">
<ga-pagination [source]="source"></ga-pagination>
</div>
</nav>
}
</div>
</div>
<ng-template #noPlugin>
<div class="no-data">
<ngx-no-data-message [message]="(loading ? 'SM_TABLE.NO_DATA.LOADING' : 'SM_TABLE.NO_DATA.PLUGIN') | translate">
</ngx-no-data-message>
} @else {
<!-- Empty State -->
<div class="no-data" aria-live="polite">
<ngx-no-data-message [message]="'SM_TABLE.NO_DATA.PLUGIN' | translate"></ngx-no-data-message>
</div>
</ng-template>
}
</section>
@@ -1,38 +1,456 @@
@use 'gauzy/_gauzy-table' as *;
// ============================================
// Host Element
// ============================================
:host {
display: flex;
flex-direction: column;
height: calc(100% - 5.25rem);
min-height: 0;
overflow: hidden;
}
// ============================================
// Content Container
// ============================================
.content-active {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
gap: 0.75rem;
}
// ============================================
// Action Button Container
// ============================================
.button-container {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
flex-shrink: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.button-hide {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
animation: fadeIn 0.25s ease-out;
button {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: calc(var(--border-radius) * 1.2);
font-weight: 500;
letter-spacing: 0.01em;
nb-icon {
margin-right: 0.25rem;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: var(--gauzy-shadow);
}
&:not(:disabled):active {
transform: translateY(0);
}
}
}
}
// ============================================
// Custom Table Container
// ============================================
.custom-table {
background-color: var(--gauzy-card-2);
margin: 0;
display: flex;
flex-direction: column;
padding: 8px 4px 0px;
border-radius: var(--border-radius);
height: calc(100vh - 13rem);
width: auto;
padding: 0.75rem;
border-radius: calc(var(--border-radius) * 1.5);
flex: 1;
min-height: 0;
width: 100%;
box-shadow: var(--gauzy-shadow);
overflow: hidden;
.table-scroll-container {
flex-grow: 10;
max-height: unset;
padding: 0 4px 0;
margin-bottom: 8px;
overflow-x: hidden;
flex: 1;
min-height: 0;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
border-radius: calc(var(--border-radius) * 1);
// Smart Table Styling
::ng-deep angular2-smart-table {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
table {
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
thead {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--gauzy-card-1);
tr th {
padding: 0.875rem 1rem;
font-weight: 600;
font-size: 0.8125rem;
color: var(--gauzy-text-color-2);
text-transform: uppercase;
letter-spacing: 0.03em;
border-bottom: 1px solid var(--border-basic-color-3);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
tbody {
tr {
transition: background-color 0.15s ease;
&:hover {
background-color: var(--smart-table-bg-active);
}
&.selected {
background-color: var(--smart-table-bg-active);
box-shadow: inset 4px 0 0 var(--color-primary-default);
}
td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: var(--gauzy-text-color-1);
border-bottom: 1px solid var(--border-basic-color-3);
vertical-align: middle;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&:last-child td {
border-bottom: none;
}
}
}
}
// Hide default pager (using custom pagination)
angular2-smart-table-pager {
display: none;
}
}
// Disabled state overlay
.table-disabled {
opacity: 0.5;
pointer-events: none;
user-select: none;
filter: grayscale(20%);
}
}
.pagination-container,
nav.pagination-container {
flex-shrink: 0;
padding: 0.75rem 0 0;
border-top: 1px solid var(--border-basic-color-3);
margin-top: auto;
}
}
.button-container,
.button-hide {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
}
// ============================================
// No Data / Empty State
// ============================================
.no-data {
height: calc(100vh - 13rem);
flex: 1;
min-height: 200px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--gauzy-card-2);
border-radius: calc(var(--border-radius) * 1.5);
box-shadow: var(--gauzy-shadow);
padding: 1rem;
text-align: center;
::ng-deep ngx-no-data-message {
width: 100%;
.no-data-container {
padding: 1.5rem;
}
nb-icon,
.icon {
font-size: 3rem;
color: var(--gauzy-text-color-2);
opacity: 0.6;
margin-bottom: 1rem;
}
.message,
p {
font-size: 0.9375rem;
color: var(--gauzy-text-color-2);
line-height: 1.5;
}
}
}
// ============================================
// Button Status Classes
// ============================================
.danger {
color: var(--icon-danger-color) !important;
background-color: #ff00002b !important;
--danger-bg: color-mix(in srgb, var(--color-danger-default) 10%, transparent);
--danger-bg-hover: color-mix(in srgb, var(--color-danger-default) 15%, transparent);
color: var(--color-danger-default) !important;
background-color: var(--danger-bg) !important;
&:hover:not(:disabled) {
background-color: var(--danger-bg-hover) !important;
}
}
.secondary {
color: var(--gauzy-text-color-2);
}
// ============================================
// Animations
// ============================================
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ============================================
// Responsive Design
// ============================================
// Large tablets and small desktops
@media screen and (max-width: 1200px) {
.custom-table {
padding: 0.625rem;
.table-scroll-container ::ng-deep angular2-smart-table table {
thead tr th,
tbody tr td {
padding: 0.625rem 0.75rem;
}
}
}
}
// Tablets
@media screen and (max-width: 992px) {
.button-container .button-hide {
gap: 0.375rem;
button {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
}
.custom-table .table-scroll-container {
::ng-deep angular2-smart-table table {
thead tr th {
font-size: 0.75rem;
padding: 0.625rem 0.5rem;
}
tbody tr td {
font-size: 0.8125rem;
padding: 0.5rem;
}
}
}
}
// Mobile landscape and small tablets
@media screen and (max-width: 768px) {
.content-active {
gap: 0.5rem;
}
.button-container {
min-height: 2rem;
.button-hide {
flex-wrap: wrap;
width: 100%;
justify-content: flex-start;
button {
flex: 1 1 calc(50% - 0.25rem);
min-width: 0;
justify-content: center;
// Hide text on very small buttons, show icon only
&.action {
nb-icon {
margin-right: 0.125rem;
}
}
}
}
}
.custom-table {
padding: 0.5rem;
border-radius: calc(var(--border-radius) * 1.25);
.table-scroll-container {
::ng-deep angular2-smart-table table {
// Enable horizontal scroll for table
min-width: 500px;
thead tr th,
tbody tr td {
padding: 0.5rem;
font-size: 0.8125rem;
}
}
}
.pagination-container {
padding: 0.5rem 0 0;
}
}
.no-data {
min-height: 180px;
padding: 1.5rem;
border-radius: calc(var(--border-radius) * 1.25);
::ng-deep ngx-no-data-message {
max-width: 280px;
nb-icon,
.icon {
font-size: 2.5rem;
}
.message,
p {
font-size: 0.875rem;
}
}
}
}
// Mobile portrait
@media screen and (max-width: 480px) {
.button-container .button-hide {
button {
flex: 1 1 100%;
font-size: 0.8125rem;
padding: 0.625rem 0.75rem;
nb-icon {
font-size: 1rem;
}
}
}
.custom-table {
padding: 0.375rem;
.table-scroll-container {
margin: 0 -0.25rem;
padding: 0 0.25rem;
::ng-deep angular2-smart-table table {
min-width: 450px;
thead tr th,
tbody tr td {
padding: 0.5rem 0.375rem;
font-size: 0.75rem;
}
}
}
}
.no-data {
min-height: 160px;
padding: 1rem;
}
}
// ============================================
// Accessibility & Motion Preferences
// ============================================
@media (prefers-reduced-motion: reduce) {
.button-container .button-hide {
animation: none;
button {
transition: none;
&:hover {
transform: none;
}
}
}
.custom-table .table-scroll-container ::ng-deep angular2-smart-table table tbody tr {
transition: none;
}
}
// High contrast mode
@media (prefers-contrast: high) {
.custom-table {
border: 2px solid var(--border-basic-color-4);
.table-scroll-container ::ng-deep angular2-smart-table table {
thead tr th,
tbody tr td {
border-bottom-width: 2px;
}
tbody tr.selected {
box-shadow: inset 6px 0 0 var(--color-primary-default);
}
}
}
.no-data {
border: 2px solid var(--border-basic-color-4);
}
.button-container .button-hide button {
border: 2px solid currentColor;
}
}
@@ -1,4 +1,4 @@
import { Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { AfterViewInit, Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { NbDialogService } from '@nebular/theme';
import { Actions } from '@ngneat/effects-ng';
@@ -10,7 +10,6 @@ import { PluginActions } from '../+state/plugin.action';
import { PluginQuery } from '../+state/plugin.query';
import { AlertComponent } from '../../../../dialogs/alert/alert.component';
import { IPlugin } from '../../services/plugin-loader.service';
import { AddPluginComponent } from '../add-plugin/add-plugin.component';
import { PluginInstallationActions } from '../plugin-marketplace/+state/actions/plugin-installation.action';
import { PluginInstallationQuery } from '../plugin-marketplace/+state/queries/plugin-installation.query';
import { PluginStatusComponent } from './plugin-status/plugin-status.component';
@@ -23,7 +22,7 @@ import { PluginUpdateComponent } from './plugin-update/plugin-update.component';
styleUrls: ['./plugin-list.component.scss'],
standalone: false
})
export class PluginListComponent implements OnInit, OnDestroy {
export class PluginListComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly translateService = inject(TranslateService);
private readonly action = inject(Actions);
public readonly query = inject(PluginQuery);
@@ -56,46 +55,16 @@ export class PluginListComponent implements OnInit, OnDestroy {
return this._pluginTable;
}
public smartTableSettings = {
columns: {
name: {
title: this.translateService.instant('SM_TABLE.NAME')
},
isActivate: {
title: this.translateService.instant('SM_TABLE.STATUS'),
type: 'custom',
renderComponent: PluginStatusComponent,
componentInitFunction: (instance: PluginStatusComponent, cell: Cell) => {
instance.rowData = cell.getRow().getData();
}
},
version: {
title: this.translateService.instant('TIMER_TRACKER.SETTINGS.VERSION')
},
updatedAt: {
title: this.translateService.instant('TIMER_TRACKER.SETTINGS.UPDATE'),
type: 'custom',
renderComponent: PluginUpdateComponent,
componentInitFunction: (instance: PluginUpdateComponent, cell: Cell) => {
instance.rowData = cell.getRow().getData();
}
}
},
hideSubHeader: true,
actions: false,
noDataMessage: this.translateService.instant('SM_TABLE.NO_DATA.PLUGIN'),
pager: {
display: true,
perPage: 10,
page: 1
}
};
public smartTableSettings = this.buildSmartTableSettings();
ngOnInit(): void {
this.reset();
}
ngAfterViewInit(): void {
this.observePlugins();
this.loadPlugins();
this.onLanguageChange();
this.loadPlugins();
}
private observePlugins(): void {
@@ -162,13 +131,6 @@ export class PluginListComponent implements OnInit, OnDestroy {
this.router.navigate(['/plugins', 'installed', this.plugin.name]);
}
public addPlugin() {
this.dialog
.open(AddPluginComponent, { backdropClass: 'backdrop-blur' })
.onClose.pipe(untilDestroyed(this))
.subscribe();
}
public uninstall() {
this.action.dispatch(PluginInstallationActions.uninstall(this.plugin.marketplaceId, this.plugin.id));
}
@@ -201,14 +163,49 @@ export class PluginListComponent implements OnInit, OnDestroy {
private onLanguageChange() {
this.translateService.onLangChange
.pipe(
tap(() => {
this.smartTableSettings = Object.assign({}, this.smartTableSettings);
}),
tap(() => (this.smartTableSettings = this.buildSmartTableSettings())),
untilDestroyed(this)
)
.subscribe();
}
private buildSmartTableSettings() {
return {
columns: {
name: {
title: this.translateService.instant('SM_TABLE.NAME')
},
isActivate: {
title: this.translateService.instant('SM_TABLE.STATUS'),
type: 'custom',
renderComponent: PluginStatusComponent,
componentInitFunction: (instance: PluginStatusComponent, cell: Cell) => {
instance.rowData = cell.getRow().getData();
}
},
version: {
title: this.translateService.instant('TIMER_TRACKER.SETTINGS.VERSION')
},
updatedAt: {
title: this.translateService.instant('TIMER_TRACKER.SETTINGS.UPDATE'),
type: 'custom',
renderComponent: PluginUpdateComponent,
componentInitFunction: (instance: PluginUpdateComponent, cell: Cell) => {
instance.rowData = cell.getRow().getData();
}
}
},
hideSubHeader: true,
actions: false,
noDataMessage: this.translateService.instant('SM_TABLE.NO_DATA.PLUGIN'),
pager: {
display: true,
perPage: 10,
page: 1
}
};
}
public get plugin(): IPlugin {
return this.query.plugin;
}
@@ -14,20 +14,23 @@ export interface IPaymentMethod {
export class PluginMarketplaceActions {
// Core plugin actions
public static upload = createAction('[Plugin Marketplace] Upload');
public static getAll = createAction('[Plugin Marketplace] Get All', <T>(params?: T) => ({ params }));
public static getOne = createAction('[Plugin Marketplace] Get One', <T>(id: ID, params?: T) => ({ id, params }));
public static update = createAction('[Plugin Marketplace] Update', (plugin: IPlugin) => ({
public static readonly upload = createAction('[Plugin Marketplace] Upload');
public static readonly getAll = createAction('[Plugin Marketplace] Get All', <T>(params?: T) => ({ params }));
public static readonly getOne = createAction('[Plugin Marketplace] Get One', <T>(id: ID, params?: T) => ({
id,
params
}));
public static readonly update = createAction('[Plugin Marketplace] Update', (plugin: IPlugin) => ({
plugin
}));
public static delete = createAction('[Plugin Marketplace] Delete', (id: ID) => ({ id }));
public static reset = createAction('[Plugin Marketplace] Reset');
public static readonly delete = createAction('[Plugin Marketplace] Delete', (id: ID) => ({ id }));
public static readonly reset = createAction('[Plugin Marketplace] Reset');
// Search actions
public static search = createAction('[Plugin Marketplace] Search', (query: string) => ({ query }));
public static readonly search = createAction('[Plugin Marketplace] Search', (query: string) => ({ query }));
// Installation actions
public static install = createAction(
public static readonly install = createAction(
'[Plugin Marketplace] Install Plugin',
(plugin: IPlugin, isUpdate: boolean = false) => ({
plugin,
@@ -36,11 +39,11 @@ export class PluginMarketplaceActions {
);
// Install update action
public static installUpdate = createAction('[Plugin Marketplace] Install Update', (plugin: IPlugin) => ({
public static readonly installUpdate = createAction('[Plugin Marketplace] Install Update', (plugin: IPlugin) => ({
plugin
}));
public static uninstall = createAction(
public static readonly uninstall = createAction(
'[Plugin Marketplace] Uninstall Plugin',
(pluginId: ID, installationId: ID, reason?: string) => ({
installationId,
@@ -50,38 +53,47 @@ export class PluginMarketplaceActions {
);
// Filter actions
public static setFilters = createAction('[Plugin Marketplace] Set Filters', (filters: IPluginFilter) => ({
public static readonly setFilters = createAction('[Plugin Marketplace] Set Filters', (filters: IPluginFilter) => ({
filters
}));
public static applyFilters = createAction('[Plugin Marketplace] Apply Filters');
public static clearFilters = createAction('[Plugin Marketplace] Clear Filters');
public static toggleAdvancedFilters = createAction(
public static readonly applyFilters = createAction('[Plugin Marketplace] Apply Filters');
public static readonly clearFilters = createAction('[Plugin Marketplace] Clear Filters');
public static readonly toggleAdvancedFilters = createAction(
'[Plugin Marketplace] Toggle Advanced Filters',
(show: boolean) => ({ show })
);
public static setViewMode = createAction('[Plugin Marketplace] Set View Mode', (view: 'grid' | 'list') => ({
view
}));
public static readonly setViewMode = createAction(
'[Plugin Marketplace] Set View Mode',
(view: 'grid' | 'list') => ({
view
})
);
// Tags actions
public static loadTags = createAction('[Plugin Marketplace] Load Tags');
public static createTag = createAction('[Plugin Marketplace] Create Tag', (tag: IPluginTagCreateInput) => ({
tag
}));
public static updateTag = createAction('[Plugin Marketplace] Update Tag', (id: ID, tag: Partial<IPluginTag>) => ({
id,
tag
}));
public static deleteTag = createAction('[Plugin Marketplace] Delete Tag', (id: ID) => ({ id }));
public static readonly loadTags = createAction('[Plugin Marketplace] Load Tags');
public static readonly createTag = createAction(
'[Plugin Marketplace] Create Tag',
(tag: IPluginTagCreateInput) => ({
tag
})
);
public static readonly updateTag = createAction(
'[Plugin Marketplace] Update Tag',
(id: ID, tag: Partial<IPluginTag>) => ({
id,
tag
})
);
public static readonly deleteTag = createAction('[Plugin Marketplace] Delete Tag', (id: ID) => ({ id }));
// Subscription actions
public static loadSubscriptionPlans = createAction(
public static readonly loadSubscriptionPlans = createAction(
'[Plugin Marketplace] Load Subscription Plans',
(pluginId: ID) => ({
pluginId
})
);
public static subscribe = createAction(
public static readonly subscribe = createAction(
'[Plugin Marketplace] Subscribe to Plugin',
(pluginId: ID, planId: ID, paymentMethod: IPaymentMethod) => ({
pluginId,
@@ -89,10 +101,13 @@ export class PluginMarketplaceActions {
paymentMethod
})
);
public static unsubscribe = createAction('[Plugin Marketplace] Unsubscribe from Plugin', (pluginId: ID) => ({
pluginId
}));
public static updateSubscription = createAction(
public static readonly unsubscribe = createAction(
'[Plugin Marketplace] Unsubscribe from Plugin',
(pluginId: ID) => ({
pluginId
})
);
public static readonly updateSubscription = createAction(
'[Plugin Marketplace] Update Subscription',
(subscriptionId: ID, updates: Partial<IPluginSubscription>) => ({
subscriptionId,
@@ -101,10 +116,13 @@ export class PluginMarketplaceActions {
);
// Settings actions
public static loadPluginSettings = createAction('[Plugin Marketplace] Load Plugin Settings', (pluginId: ID) => ({
pluginId
}));
public static updatePluginSetting = createAction(
public static readonly loadPluginSettings = createAction(
'[Plugin Marketplace] Load Plugin Settings',
(pluginId: ID) => ({
pluginId
})
);
public static readonly updatePluginSetting = createAction(
'[Plugin Marketplace] Update Plugin Setting',
(pluginId: ID, key: string, value: any) => ({
pluginId,
@@ -112,19 +130,22 @@ export class PluginMarketplaceActions {
value
})
);
public static resetPluginSettings = createAction('[Plugin Marketplace] Reset Plugin Settings', (pluginId: ID) => ({
pluginId
}));
public static readonly resetPluginSettings = createAction(
'[Plugin Marketplace] Reset Plugin Settings',
(pluginId: ID) => ({
pluginId
})
);
// Analytics actions
public static loadPluginAnalytics = createAction(
public static readonly loadPluginAnalytics = createAction(
'[Plugin Marketplace] Load Plugin Analytics',
(pluginId: ID, period?: AnalyticsPeriod) => ({
pluginId,
period
})
);
public static trackPluginEvent = createAction(
public static readonly trackPluginEvent = createAction(
'[Plugin Marketplace] Track Plugin Event',
(pluginId: ID, eventType: string, eventData: any) => ({
pluginId,
@@ -134,22 +155,25 @@ export class PluginMarketplaceActions {
);
// Security actions
public static loadPluginSecurity = createAction('[Plugin Marketplace] Load Plugin Security', (pluginId: ID) => ({
pluginId
}));
public static updatePluginSecurity = createAction(
public static readonly loadPluginSecurity = createAction(
'[Plugin Marketplace] Load Plugin Security',
(pluginId: ID) => ({
pluginId
})
);
public static readonly updatePluginSecurity = createAction(
'[Plugin Marketplace] Update Plugin Security',
(pluginId: ID, security: Partial<IPluginSecurity>) => ({
pluginId,
security
})
);
public static runSecurityScan = createAction('[Plugin Marketplace] Run Security Scan', (pluginId: ID) => ({
public static readonly runSecurityScan = createAction('[Plugin Marketplace] Run Security Scan', (pluginId: ID) => ({
pluginId
}));
// Rating and review actions
public static ratePlugin = createAction(
public static readonly ratePlugin = createAction(
'[Plugin Marketplace] Rate Plugin',
(pluginId: ID, rating: number, review?: string) => ({
pluginId,
@@ -157,13 +181,16 @@ export class PluginMarketplaceActions {
review
})
);
public static loadPluginReviews = createAction('[Plugin Marketplace] Load Plugin Reviews', (pluginId: ID) => ({
pluginId
}));
public static readonly loadPluginReviews = createAction(
'[Plugin Marketplace] Load Plugin Reviews',
(pluginId: ID) => ({
pluginId
})
);
// Category actions
public static loadCategories = createAction('[Plugin Marketplace] Load Categories');
public static setSelectedCategory = createAction(
public static readonly loadCategories = createAction('[Plugin Marketplace] Load Categories');
public static readonly setSelectedCategory = createAction(
'[Plugin Marketplace] Set Selected Category',
(category: string) => ({
category
@@ -171,8 +198,8 @@ export class PluginMarketplaceActions {
);
// Featured plugins actions
public static loadFeaturedPlugins = createAction('[Plugin Marketplace] Load Featured Plugins');
public static setFeaturedPlugin = createAction(
public static readonly loadFeaturedPlugins = createAction('[Plugin Marketplace] Load Featured Plugins');
public static readonly setFeaturedPlugin = createAction(
'[Plugin Marketplace] Set Featured Plugin',
(pluginId: ID, featured: boolean) => ({
pluginId,
@@ -181,8 +208,8 @@ export class PluginMarketplaceActions {
);
// Marketplace statistics actions
public static loadMarketplaceStats = createAction('[Plugin Marketplace] Load Marketplace Stats');
public static loadMarketplaceTrends = createAction(
public static readonly loadMarketplaceStats = createAction('[Plugin Marketplace] Load Marketplace Stats');
public static readonly loadMarketplaceTrends = createAction(
'[Plugin Marketplace] Load Marketplace Trends',
(category?: string) => ({
category
@@ -190,42 +217,54 @@ export class PluginMarketplaceActions {
);
// Notification actions
public static markNotificationRead = createAction(
public static readonly markNotificationRead = createAction(
'[Plugin Marketplace] Mark Notification Read',
(notificationId: ID) => ({
notificationId
})
);
public static clearAllNotifications = createAction('[Plugin Marketplace] Clear All Notifications');
public static readonly clearAllNotifications = createAction('[Plugin Marketplace] Clear All Notifications');
// UI state actions
public static setSelectedPlugin = createAction(
public static readonly setSelectedPlugin = createAction(
'[Plugin Marketplace] Set Selected Plugin',
(plugin: IPlugin | null) => ({
plugin
})
);
public static togglePluginDetails = createAction('[Plugin Marketplace] Toggle Plugin Details', (pluginId: ID) => ({
pluginId
}));
public static setLoadingState = createAction('[Plugin Marketplace] Set Loading State', (isLoading: boolean) => ({
isLoading
}));
public static setError = createAction('[Plugin Marketplace] Set Error', (error: string | null) => ({
public static readonly togglePluginDetails = createAction(
'[Plugin Marketplace] Toggle Plugin Details',
(pluginId: ID) => ({
pluginId
})
);
public static readonly setLoadingState = createAction(
'[Plugin Marketplace] Set Loading State',
(isLoading: boolean) => ({
isLoading
})
);
public static readonly setError = createAction('[Plugin Marketplace] Set Error', (error: string | null) => ({
error
}));
// Bulk operations
public static bulkInstallPlugins = createAction('[Plugin Marketplace] Bulk Install Plugins', (pluginIds: ID[]) => ({
pluginIds
}));
public static bulkUninstallPlugins = createAction(
public static readonly bulkInstallPlugins = createAction(
'[Plugin Marketplace] Bulk Install Plugins',
(pluginIds: ID[]) => ({
pluginIds
})
);
public static readonly bulkUninstallPlugins = createAction(
'[Plugin Marketplace] Bulk Uninstall Plugins',
(pluginIds: ID[]) => ({
pluginIds
})
);
public static bulkUpdatePlugins = createAction('[Plugin Marketplace] Bulk Update Plugins', (pluginIds: ID[]) => ({
pluginIds
}));
public static readonly bulkUpdatePlugins = createAction(
'[Plugin Marketplace] Bulk Update Plugins',
(pluginIds: ID[]) => ({
pluginIds
})
);
}
@@ -0,0 +1,14 @@
import { createAction } from '@ngneat/effects';
export class PluginUploadIntentActions {
/**
* Action to open the upload selection modal
* User chooses between installing locally or publishing to marketplace
*/
public static readonly openUploadSelection = createAction('[Plugin Upload Intent] Open Upload Selection');
/**
* Action to clear the upload intent
*/
public static readonly clearUploadIntent = createAction('[Plugin Upload Intent] Clear Upload Intent');
}
@@ -0,0 +1,70 @@
import { inject, Injectable } from '@angular/core';
import { NbDialogService } from '@nebular/theme';
import { createEffect, ofType } from '@ngneat/effects';
import { Actions } from '@ngneat/effects-ng';
import { asyncScheduler, EMPTY, exhaustMap, filter, map, of, switchMap, take, tap } from 'rxjs';
import { AddPluginComponent } from '../../../add-plugin/add-plugin.component';
import { UploadSelectionComponent } from '../../../upload-selection/upload-selection.component';
import { PluginMarketplaceActions } from '../actions/plugin-marketplace.action';
import { PluginUploadIntentActions } from '../actions/plugin-upload-intent.action';
import { PluginUploadIntentStore, UploadIntent } from '../stores/plugin-upload-intent.store';
@Injectable({ providedIn: 'root' })
export class PluginUploadIntentEffects {
private readonly action$ = inject(Actions);
private readonly dialogService = inject(NbDialogService);
private readonly uploadIntentStore = inject(PluginUploadIntentStore);
/**
* Effect to handle opening the upload selection modal
* Opens modal, waits for user selection, stores intent, and dispatches appropriate action
*/
openUploadSelection$ = createEffect(
() =>
this.action$.pipe(
ofType(PluginUploadIntentActions.openUploadSelection),
exhaustMap(() =>
this.dialogService
.open(UploadSelectionComponent, {
backdropClass: 'backdrop-blur',
closeOnEsc: true
})
.onClose.pipe(
take(1),
filter(Boolean),
switchMap((intent: UploadIntent) => {
// Store the intent in Akita
this.uploadIntentStore.setIntent(intent);
// Route based on the user's selection
if (intent === UploadIntent.Install) {
// Open AddPluginComponent which has Local, CDN, and NPM options
return this.dialogService
.open(AddPluginComponent, {
backdropClass: 'backdrop-blur'
})
.onClose.pipe(
take(1),
map(() => PluginUploadIntentActions.clearUploadIntent())
);
} else if (intent === UploadIntent.Publish) {
// Dispatch action to trigger marketplace publish flow
return of(PluginMarketplaceActions.upload());
}
return EMPTY;
})
)
)
),
{ dispatch: true }
);
onClearIntent$ = createEffect(
() =>
this.action$.pipe(
ofType(PluginUploadIntentActions.clearUploadIntent, PluginMarketplaceActions.upload),
tap(() => asyncScheduler.schedule(() => this.uploadIntentStore.clearIntent(), 100))
),
{ dispatch: false }
);
}
@@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { Query } from '@datorama/akita';
import { Observable } from 'rxjs';
import { IPluginUploadIntentState, PluginUploadIntentStore, UploadIntent } from '../stores/plugin-upload-intent.store';
@Injectable({ providedIn: 'root' })
export class PluginUploadIntentQuery extends Query<IPluginUploadIntentState> {
/**
* Observable of the current upload intent
*/
public intent$: Observable<UploadIntent | null> = this.select((state) => state.intent);
constructor(protected store: PluginUploadIntentStore) {
super(store);
}
/**
* Get the current upload intent value
*/
public get intent(): UploadIntent | null {
return this.getValue().intent;
}
}
@@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { Store, StoreConfig } from '@datorama/akita';
export enum UploadIntent {
Install = 'install',
Publish = 'publish'
}
export interface IPluginUploadIntentState {
intent: UploadIntent | null;
}
export function createInitialUploadIntentState(): IPluginUploadIntentState {
return {
intent: null
};
}
@StoreConfig({ name: '_plugin_upload_intent' })
@Injectable({ providedIn: 'root' })
export class PluginUploadIntentStore extends Store<IPluginUploadIntentState> {
constructor() {
super(createInitialUploadIntentState());
}
/**
* Set the upload intent
* @param intent - 'install' for local installation, 'publish' for marketplace publishing
*/
public setIntent(intent: UploadIntent): void {
this.update({ intent });
}
/**
* Clear the upload intent
*/
public clearIntent(): void {
this.update({ intent: null });
}
}
@@ -56,11 +56,11 @@
<div class="plugin-meta">
<span class="plugin-version">v{{ plugin.version?.number || '1.0.0' }}</span>
@if(plugin.author) {
<span class="plugin-author">by {{ plugin.author }}</span>
<span class="plugin-author">{{ 'PLUGIN.BY' | translate }} {{ plugin.author }}</span>
}
<span class="download-count">
<nb-icon icon="download-outline"></nb-icon>
{{ plugin.downloadCount | number }} downloads
{{ plugin.downloadCount | number }} {{ 'PLUGIN.DOWNLOADS' | translate }}
</span>
</div>
</div>
@@ -72,7 +72,7 @@
<nb-badge status="basic" class="type-badge" [text]="plugin.type | titlecase"> </nb-badge>
<!-- Free Badge if plugin has no plans -->
@if(!plugin.hasPlan) {
<nb-badge status="success" class="free-badge" text="Free"> </nb-badge>
<nb-badge status="success" class="free-badge" [text]="'PLUGIN.FREE' | translate"> </nb-badge>
}
</div>
</div>
@@ -84,7 +84,7 @@
[readMoreText]="'BUTTONS.READ_MORE' | translate"
[readLessText]="'BUTTONS.READ_LESS' | translate"
>
{{ plugin.description || 'No description available' }}
{{ plugin.description || ('PLUGIN.NO_DESCRIPTION' | translate) }}
</div>
</div>
@@ -103,7 +103,7 @@
@for(tag of plugin.tags.slice(0, 5); track tag.id) {
<nb-badge status="basic" class="tag-badge" [class.featured]="tag.isFeatured" [text]="tag.name"> </nb-badge>
} @if(plugin.tags.length > 5) {
<span class="more-tags">+{{ plugin.tags.length - 5 }} more</span>
<span class="more-tags">{{ 'PLUGIN.MORE_TAGS' | translate : { count: plugin.tags.length - 5 } }}</span>
}
</div>
</div>
@@ -143,7 +143,7 @@
@if(subscription.trialEndDate) {
<nb-badge status="info" class="trial-badge">
<nb-icon icon="time-outline"></nb-icon>
Trial
{{ 'PLUGIN.TRIAL' | translate }}
</nb-badge>
}
</div>
@@ -155,19 +155,19 @@
<div class="plugin-metrics">
<div class="metric-item">
<nb-icon icon="calendar-outline"></nb-icon>
<span class="metric-label">Updated</span>
<span class="metric-label">{{ 'PLUGIN.UPDATED' | translate }}</span>
<span class="metric-value">{{ plugin.updatedAt | date : 'short' }}</span>
</div>
@if(plugin.license) {
<div class="metric-item">
<nb-icon icon="file-text-outline"></nb-icon>
<span class="metric-label">License</span>
<span class="metric-label">{{ 'PLUGIN.LICENSE' | translate }}</span>
<span class="metric-value">{{ plugin.license }}</span>
</div>
} @if(plugin.version?.releaseDate) {
<div class="metric-item">
<nb-icon icon="layers-outline"></nb-icon>
<span class="metric-label">Released</span>
<span class="metric-label">{{ 'PLUGIN.RELEASED' | translate }}</span>
<span class="metric-value">{{ plugin.version.releaseDate | date : 'short' }}</span>
</div>
}
@@ -238,7 +238,7 @@
<div class="progress-indicator">
<nb-icon *gauzySpinnerButton="true" icon="sync-outline"></nb-icon>
<span class="progress-text">
{{ installing ? 'Installing...' : 'Uninstalling...' }}
{{ installing ? ('PLUGIN.INSTALLING' | translate) : ('PLUGIN.UNINSTALLING' | translate) }}
</span>
</div>
} @else {
@@ -247,12 +247,12 @@
@if(plugin.hasPlan && !installed) {
<div class="subscription-required">
<nb-icon icon="card-outline" class="subscription-icon"></nb-icon>
<span class="subscription-text">Subscription Required</span>
<span class="subscription-text">{{ 'PLUGIN.SUBSCRIPTION_REQUIRED' | translate }}</span>
</div>
} @else if(!plugin.hasPlan && !installed) {
<div class="free-plugin">
<nb-icon icon="gift-outline" class="free-icon" status="success"></nb-icon>
<span class="free-text">Free</span>
<span class="free-text">{{ 'PLUGIN.FREE' | translate }}</span>
</div>
}
@@ -263,10 +263,16 @@
(checkedChange)="togglePlugin($event)"
class="install-toggle"
>
<span class="toggle-label">
@if(installed) {
{{ 'PLUGIN.INSTALL_LABEL.INSTALLED' | translate }}
} @else if(plugin.hasPlan) {
{{ 'PLUGIN.INSTALL_LABEL.SUBSCRIBE' | translate }}
} @else {
{{ 'PLUGIN.INSTALL_LABEL.INSTALL' | translate }}
}
</span>
</nb-toggle>
<span class="toggle-label">
@if(installed) { Installed } @else if(plugin.hasPlan) { Subscribe & Install } @else { Install }
</span>
</div>
</div>
}
@@ -4,6 +4,7 @@ import { IPlugin, PluginStatus, PluginSubscriptionType } from '@gauzy/contracts'
import { NbMenuService } from '@nebular/theme';
import { Actions } from '@ngneat/effects-ng';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import { filter, tap } from 'rxjs';
import { PluginSettingsActions, PluginSubscriptionActions } from '../+state';
import { PluginInstallationActions } from '../+state/actions/plugin-installation.action';
@@ -34,6 +35,7 @@ export class PluginMarketplaceDetailComponent implements OnInit {
private readonly router = inject(Router);
private readonly action = inject(Actions);
private readonly translate = inject(TranslateService);
constructor(
private readonly utils: PluginMarketplaceUtilsService,
@@ -150,7 +152,7 @@ export class PluginMarketplaceDetailComponent implements OnInit {
const items: any[] = [
{
title: 'View Details',
title: this.translate.instant('PLUGIN.ACTIONS.VIEW_DETAILS'),
icon: 'eye-outline',
link: `/plugins/marketplace/${this.plugin.id}`
}
@@ -159,7 +161,7 @@ export class PluginMarketplaceDetailComponent implements OnInit {
// Add plugin management options if installed
if (this.plugin.installed && this.isOwner) {
items.push({
title: 'Settings',
title: this.translate.instant('PLUGIN.ACTIONS.SETTINGS'),
icon: 'settings-outline',
data: { action: 'settings' }
});
@@ -167,13 +169,13 @@ export class PluginMarketplaceDetailComponent implements OnInit {
// Add subscription management for plugins with plans
if (this.plugin.hasPlan) {
items.push({
title: 'Manage Subscription',
title: this.translate.instant('PLUGIN.ACTIONS.MANAGE_SUBSCRIPTION'),
icon: 'credit-card-outline',
data: { action: 'manage-subscription' }
});
// Only show user management for plugins with plans (subscription-based)
items.push({
title: 'Manage Users',
title: this.translate.instant('PLUGIN.ACTIONS.MANAGE_USERS'),
icon: 'people-outline',
data: { action: 'manage-users' }
});
@@ -183,7 +185,7 @@ export class PluginMarketplaceDetailComponent implements OnInit {
// Add external links
if (this.plugin.homepage) {
items.push({
title: 'Homepage',
title: this.translate.instant('PLUGIN.ACTIONS.HOMEPAGE'),
icon: 'external-link-outline',
data: { action: 'homepage', url: this.plugin.homepage }
});
@@ -191,7 +193,7 @@ export class PluginMarketplaceDetailComponent implements OnInit {
if (this.plugin.repository) {
items.push({
title: 'Repository',
title: this.translate.instant('PLUGIN.ACTIONS.REPOSITORY'),
icon: 'github-outline',
data: { action: 'repository', url: this.plugin.repository }
});
@@ -201,12 +203,12 @@ export class PluginMarketplaceDetailComponent implements OnInit {
if (this.isOwner) {
items.push(
{
title: 'Edit Plugin',
title: this.translate.instant('PLUGIN.ACTIONS.EDIT'),
icon: 'edit-outline',
data: { action: 'edit' }
},
{
title: 'Delete',
title: this.translate.instant('PLUGIN.ACTIONS.DELETE'),
icon: 'trash-outline',
data: { action: 'delete' }
}
@@ -542,4 +542,10 @@ nb-button-group {
nb-dialog-container nb-card {
max-height: unset !important;
}
nb-toggle {
label {
margin-bottom: 0 !important;
}
}
}
@@ -5,6 +5,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { combineLatest, debounceTime, distinctUntilChanged, map, tap } from 'rxjs';
import { Store } from '../../../../services';
import { PluginMarketplaceActions } from './+state/actions/plugin-marketplace.action';
import { PluginUploadIntentActions } from './+state/actions/plugin-upload-intent.action';
import { PluginMarketplaceQuery } from './+state/queries/plugin-marketplace.query';
import { IPluginFilter, PluginMarketplaceStore } from './+state/stores/plugin-market.store';
@@ -204,8 +205,12 @@ export class PluginMarketplaceComponent implements OnInit, OnDestroy {
return !!this.route.snapshot.data['isUploadAvailable'];
}
/**
* Open the upload selection modal
* Dispatches action to show modal where user chooses between installing locally or publishing to marketplace
*/
public upload(): void {
this.action.dispatch(PluginMarketplaceActions.upload());
this.action.dispatch(PluginUploadIntentActions.openUploadSelection());
}
ngOnDestroy(): void {
@@ -0,0 +1,64 @@
<nb-card class="upload-selection-card">
<nb-card-header>
<div class="header-content">
<nb-icon icon="cloud-upload-outline" class="header-icon"></nb-icon>
<h5 class="title">{{ 'PLUGIN.UPLOAD_SELECTION.TITLE' | translate }}</h5>
</div>
<button
class="close-button"
size="small"
nbButton
ghost
(click)="dismiss()"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<nb-icon icon="close-outline"></nb-icon>
</button>
</nb-card-header>
<nb-card-body>
<p class="description">{{ 'PLUGIN.UPLOAD_SELECTION.DESCRIPTION' | translate }}</p>
<div class="options-container">
<!-- Install Plugin Locally Option -->
<nb-card
class="option-card"
role="button"
(click)="selectInstall()"
(keydown.enter)="selectInstall()"
(keydown.space)="selectInstall()"
[tabindex]="0"
>
<nb-card-body>
<div class="option-icon-container install">
<nb-icon icon="monitor-outline" class="option-icon"></nb-icon>
</div>
<h6 class="option-title">{{ 'PLUGIN.UPLOAD_SELECTION.INSTALL_LOCAL' | translate }}</h6>
<p class="option-description">
{{ 'PLUGIN.UPLOAD_SELECTION.INSTALL_LOCAL_DESC' | translate }}
</p>
</nb-card-body>
</nb-card>
<!-- Publish Plugin to Marketplace Option -->
<nb-card
class="option-card"
role="button"
(click)="selectPublish()"
(keydown.enter)="selectPublish()"
(keydown.space)="selectPublish()"
[tabindex]="0"
>
<nb-card-body>
<div class="option-icon-container publish">
<nb-icon icon="shopping-bag-outline" class="option-icon"></nb-icon>
</div>
<h6 class="option-title">{{ 'PLUGIN.UPLOAD_SELECTION.PUBLISH_MARKETPLACE' | translate }}</h6>
<p class="option-description">
{{ 'PLUGIN.UPLOAD_SELECTION.PUBLISH_MARKETPLACE_DESC' | translate }}
</p>
</nb-card-body>
</nb-card>
</div>
</nb-card-body>
</nb-card>
@@ -0,0 +1,124 @@
.upload-selection-card {
max-width: 600px;
width: 100%;
nb-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--gauzy-card-2);
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
.header-icon {
font-size: 1.75rem;
color: var(--gauzy-text-color-1);
}
.title {
margin: 0;
font-weight: 600;
color: var(--gauzy-text-color-1);
}
}
.close-button {
padding: 0.5rem;
border-radius: var(--border-radius);
}
}
nb-card-body {
padding: 1.5rem;
.description {
margin-bottom: 1.5rem;
color: var(--gauzy-text-color-2);
text-align: center;
font-size: 0.9375rem;
}
.options-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
.option-card {
cursor: pointer;
transition: all 0.2s ease-in-out;
border: 2px solid transparent;
background: var(--gauzy-card-1);
border-radius: calc(var(--border-radius) * 1.5);
box-shadow: var(--gauzy-shadow);
&:hover {
transform: translateY(-2px);
box-shadow: var(--gauzy-shadow);
border-color: var(--gauzy-text-color-1);
}
&:focus {
outline: none;
border-color: var(--gauzy-text-color-1);
box-shadow: var(--gauzy-shadow);
}
nb-card-body {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 1.5rem;
.option-icon-container {
width: 64px;
height: 64px;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
&.install {
background: rgba(59, 130, 246, 0.1);
.option-icon {
color: #3b82f6;
}
}
&.publish {
background: rgba(16, 185, 129, 0.1);
.option-icon {
color: #10b981;
}
}
.option-icon {
font-size: 2rem;
}
}
.option-title {
margin: 0 0 0.5rem 0;
font-weight: 600;
color: var(--gauzy-text-color-1);
font-size: 1rem;
}
.option-description {
margin: 0;
color: var(--gauzy-text-color-2);
font-size: 0.875rem;
line-height: 1.5;
}
}
}
}
}
}
@@ -0,0 +1,35 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NbDialogRef } from '@nebular/theme';
import { UploadIntent } from '../plugin-marketplace/+state/stores/plugin-upload-intent.store';
@Component({
selector: 'ngx-upload-selection',
templateUrl: './upload-selection.component.html',
styleUrls: ['./upload-selection.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
})
export class UploadSelectionComponent {
private readonly dialogRef = inject(NbDialogRef<UploadSelectionComponent>);
/**
* Handle selection of install plugin locally option
*/
public selectInstall(): void {
this.dialogRef.close(UploadIntent.Install);
}
/**
* Handle selection of publish plugin to marketplace option
*/
public selectPublish(): void {
this.dialogRef.close(UploadIntent.Publish);
}
/**
* Handle dialog dismissal
*/
public dismiss(): void {
this.dialogRef.close(null);
}
}
@@ -131,6 +131,9 @@ import { LanguageModule } from '../../language/language.module';
import { Store } from '../../services';
import { PluginCategoryEffects } from './component/plugin-marketplace/+state';
import { PluginToggleEffects } from './component/plugin-marketplace/+state/effects/plugin-toggle.effects';
import { PluginUploadIntentEffects } from './component/plugin-marketplace/+state/effects/plugin-upload-intent.effect';
import { PluginUploadIntentQuery } from './component/plugin-marketplace/+state/queries/plugin-upload-intent.query';
import { PluginUploadIntentStore } from './component/plugin-marketplace/+state/stores/plugin-upload-intent.store';
import { PluginSubscriptionHierarchyComponent } from './component/plugin-marketplace/plugin-subscription-hierarchy/plugin-subscription-hierarchy.component';
import {
BillingContactSectionComponent,
@@ -144,6 +147,7 @@ import {
SubscriptionStatusBadgeComponent,
SubscriptionStatusService
} from './component/plugin-marketplace/shared';
import { UploadSelectionComponent } from './component/upload-selection/upload-selection.component';
@NgModule({
declarations: [
@@ -197,7 +201,8 @@ import {
PaymentMethodSectionComponent,
CardDetailsSectionComponent,
BillingContactSectionComponent,
SubscriptionConsentSectionComponent
SubscriptionConsentSectionComponent,
UploadSelectionComponent
],
exports: [PluginLayoutComponent],
imports: [
@@ -276,7 +281,8 @@ import {
PluginSubscriptionAccessEffects,
AvailableUsersEffects,
PluginToggleEffects,
PluginCategoryEffects
PluginCategoryEffects,
PluginUploadIntentEffects
),
PluginQuery,
PluginStore,
@@ -293,6 +299,8 @@ import {
AvailableUsersStore,
AvailableUsersQuery,
UserManagementFacade,
PluginUploadIntentStore,
PluginUploadIntentQuery,
NbInfiniteListDirective,
NbToastrService,
NbDialogService,
+40 -2
View File
@@ -174,13 +174,15 @@
"DEACTIVATE": "Deactivate",
"ACTIVATE": "Activate",
"ADD_PLUGIN": "Add Plugin",
"UPLOAD": "Upload",
"UPLOAD_PLUGIN": "Upload Plugin",
"LOAD_PLUGIN": "Load Plugin",
"FROM_CDN": "From CDN",
"FROM_NPM": "From NPM",
"ADD_MODULE": "Add Module",
"SHARE": "Share",
"GET_INFO": "Get Info",
"RETRY": "Retry",
"UPLOAD": "Upload",
"ADD_VERSION": "Add Version",
"MARKETPLACE": "Marketplace",
"READ_MORE": "Read More",
@@ -4319,6 +4321,9 @@
"UNABLE_TO_CONNECT": "Unable to connect to the server. Please check your API host or internet connection."
},
"SETTINGS": {
"INSTALL_FROM_COMPUTER": "Install from your computer",
"INSTALL_FROM_CDN": "Install from a CDN URL",
"INSTALL_FROM_NPM": "Install from NPM registry",
"SCREEN_CAPTURE": "Screen Capture",
"MONITOR": "Monitor",
"AUTOMATIC_SCREEN_CAPTURE": "Automatic Screen Capture",
@@ -4423,12 +4428,15 @@
"PLUGINS": "Plugins",
"PLUGIN_DESCRIPTION": "Plugins",
"PLUGIN": "Plugin",
"PLUGIN_SUBTITLE": "Enhance your application with powerful extensions",
"VERSION": "Version",
"ADD_PLUGIN_INSTALLATION_DESCRIPTION": "Install plugins from local files or CDN. Local for custom/private plugins, CDN for public ones. Choose based on your needs.",
"ADD_PLUGIN_CDN_DESCRIPTION": "CDN installation allows for easy access to publicly available plugins.",
"ADD_PLUGIN_NPM_DESCRIPTION": "NPM installation allows for access to publicly and private available plugins.",
"CDN_URL": "CDN URL",
"PACKAGE_NAME": "NPM Package Name",
"PACKAGE_VERSION": "NPM Package Version",
"PACKAGE_NAME_REQUIRED": "Package name is required",
"REGISTRY": "NPM Registry",
"AUTH_TOKEN": "Authentication Token",
"PRIVATE_REGISTRY_URL": "Private Registry URL",
@@ -5212,6 +5220,23 @@
"SERVER_API_LOG": "API Server Log",
"COMING_SOON": "This page is coming soon!",
"PLUGIN": {
"BY": "by",
"DOWNLOADS": "downloads",
"FREE": "Free",
"NO_DESCRIPTION": "No description available",
"SUBSCRIPTION_REQUIRED": "Subscription Required",
"INSTALLING": "Installing...",
"UNINSTALLING": "Uninstalling...",
"TRIAL": "Trial",
"UPDATED": "Updated",
"LICENSE": "License",
"RELEASED": "Released",
"MORE_TAGS": "+{{ count }} more",
"INSTALL_LABEL": {
"INSTALLED": "Installed",
"SUBSCRIBE": "Subscribe & Install",
"INSTALL": "Install"
},
"VIDEO": {
"SINGLE": "Video",
"PLURAL": "Videos",
@@ -5254,6 +5279,14 @@
"UNEXPECTED_SHARING_ERROR": "Unexpected sharing error"
}
},
"UPLOAD_SELECTION": {
"TITLE": "Choose Upload Method",
"DESCRIPTION": "How would you like to proceed?",
"INSTALL_LOCAL": "Install Plugin Locally",
"INSTALL_LOCAL_DESC": "Install a plugin directly on your device for personal use",
"PUBLISH_MARKETPLACE": "Publish to Marketplace",
"PUBLISH_MARKETPLACE_DESC": "Share your plugin with the community on the Marketplace"
},
"FORM": {
"TITLE": "Upload Plugin",
"BASIC_INFO": "Basic Information",
@@ -5742,7 +5775,12 @@
"SETTINGS": "Plugin Settings",
"MANAGE_USERS": "Manage Users",
"EDIT": "Edit Plugin",
"MORE": "More Actions"
"MORE": "More Actions",
"VIEW_DETAILS": "View Details",
"MANAGE_SUBSCRIPTION": "Manage Subscription",
"HOMEPAGE": "Homepage",
"REPOSITORY": "Repository",
"DELETE": "Delete"
},
"SOURCE": {
"DELETE_CONFIRMATION": "Are you sure you want to delete this source?",