mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
Feature/marketplace redesign (#2395)
* wip * update marketplace categories styling * update logo icons * add sort pipe * update search component styling * clean up categories component * cleanup and remove unnecessary sort pipe * query packages in selected category * fix search styling * add reg icon and font, adjust category styles * fix build from rebasing integration/refactors * adjust marketplace types for icon with store data, plus formatting * formatting * update categories and search * hover styling for categories * category styling * refactor for category as a behavior subject * more category styling * base functionality with new marketplace components * styling cleanup * misc style fixes and fix category selection from package page * fixes from review feedback * add and style additional details * implement release notes modal * fix menu when on service show page mobile to display change marketplace * style and responsiveness fixes * rename header to sidebar * input icon config to sidebar * add mime type pipe and type fn * review feedback fixes * skeleton text, more abstraction * reorder categories, clean up a little * audit sidebar, categories, store-icon, marketplace-sidebar, search * finish code cleanup and fix few bugs * misc fixes and cleanup * fix broken styles and markdown * bump shared marketplace version * more cleanup * sync package lock * rename sidebar component to menu * wip preview sidebar * sync package lock * breakout package show elements into components * link to brochure in preview; custom taiga button styles * move marketplace preview component into ui; open preview when viewing service in marketplace * sync changes post file struture rename * further cleanup * create service for sidebar toggle and cleanup marketplace components * bump shared marketplace version * bump shared for new images needed for brochure marketplace * cleanup --------- Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
145
web/projects/marketplace/src/components/menu/menu.component.html
Normal file
145
web/projects/marketplace/src/components/menu/menu.component.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<header
|
||||
class="z-50 overflow-hidden w-full fixed sm:w-[34vw] md:w-[28vw] lg:w-[22vw] 2xl:w-[280px] sm:bg-zinc-700/90 sm:backdrop-blur-2xl sm:min-h-screen overflow-y-auto flex flex-col sm:py-6 sm:after:block sm:after:absolute sm:after:top-0 sm:after:bottom-0 sm:after:right-0 sm:after:w-0.5 after:h-[calc(100vh - 20px)] sm:after:bg-gradient-to-b from-zinc-700 to-zinc-400"
|
||||
>
|
||||
<ng-container *tuiLet="store$ | async as store">
|
||||
<div class="hidden sm:flex flex-col mx-6 pb-3 items-center">
|
||||
<div
|
||||
class="mb-3 rounded-full"
|
||||
[class.tui-skeleton]="!store"
|
||||
[class.tui-skeleton_rounded]="!store"
|
||||
>
|
||||
<store-icon
|
||||
size="64px"
|
||||
[url]="store?.url || ''"
|
||||
[marketplace]="iconConfig"
|
||||
></store-icon>
|
||||
</div>
|
||||
<h1
|
||||
class="font-semibold text-2xl text-zinc-100 text-center mb-3"
|
||||
[class.tui-skeleton]="!store"
|
||||
>
|
||||
{{ store?.info?.name || 'Loading store' }}
|
||||
</h1>
|
||||
<!-- change registry modal -->
|
||||
<ng-content select="[slot=desktop]"></ng-content>
|
||||
</div>
|
||||
<!-- mobile nav -->
|
||||
<div class="sm:hidden bg-zinc-700/90 backdrop-blur-3xl">
|
||||
<div class="flex justify-between items-center py-4 px-4 w-[100vw]">
|
||||
<!-- mobile search -->
|
||||
<marketplace-search
|
||||
class="max-w-fit"
|
||||
[(query)]="query"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
></marketplace-search>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="flat"
|
||||
[pseudoActive]="false"
|
||||
(click)="toggleMenu(true)"
|
||||
(tuiActiveZoneChange)="toggleMenu($event)"
|
||||
[style.--tui-padding]="'1rem'"
|
||||
>
|
||||
<store-icon
|
||||
size="48px"
|
||||
[url]="store?.url || ''"
|
||||
[marketplace]="iconConfig"
|
||||
class="rounded-full"
|
||||
[class.tui-skeleton]="!store"
|
||||
[class.tui-skeleton_rounded]="!store"
|
||||
></store-icon>
|
||||
<nav
|
||||
*tuiSidebar="open; direction: 'right'; autoWidth: true"
|
||||
class="bg-zinc-700/90 h-screen w-[70vw]"
|
||||
>
|
||||
<div class="flex flex-col divide-y divide-zinc-500 h-full">
|
||||
<div class="flex items-center p-4">
|
||||
<h1
|
||||
class="font-semibold text-xl text-zinc-200 flex-grow"
|
||||
[class.tui-skeleton]="!store"
|
||||
>
|
||||
{{ store?.info?.name }}
|
||||
</h1>
|
||||
<button
|
||||
[style.--tui-padding]="0"
|
||||
class="place-self-end"
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
icon="tuiIconClose"
|
||||
(tuiActiveZoneChange)="toggleMenu($event)"
|
||||
(click)="toggleMenu(false)"
|
||||
></button>
|
||||
</div>
|
||||
<!-- change registry modal -->
|
||||
<ng-content select="[slot=mobile]"></ng-content>
|
||||
<div
|
||||
class="flex flex-col justify-between divide-y divide-zinc-500 h-full"
|
||||
>
|
||||
<marketplace-categories
|
||||
[categories]="store?.info?.categories"
|
||||
[category]="query ? '' : category"
|
||||
(categoryChange)="onCategoryChange($event); toggleMenu(false)"
|
||||
class="grow pt-5 pl-5 pr-5"
|
||||
></marketplace-categories>
|
||||
<a
|
||||
class="flex relative gap-2 hover:no-underline p-5"
|
||||
target="_blank"
|
||||
href="https://github.com/Start9Labs/service-pipeline#readme"
|
||||
>
|
||||
<img
|
||||
alt="Launch icon"
|
||||
width="24"
|
||||
height="24"
|
||||
class="opacity-70 invert"
|
||||
src="svg/rocket-outline.svg"
|
||||
/>
|
||||
<span
|
||||
class="text-base text-zinc-50 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
Launch your project
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- desktop nav -->
|
||||
<nav class="hidden sm:flex grow flex-col py-3 px-4">
|
||||
<!-- desktop search -->
|
||||
<marketplace-search
|
||||
class="place-self-center md:pb-8"
|
||||
[query]="query"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
></marketplace-search>
|
||||
<div class="flex grow flex-col justify-between">
|
||||
<marketplace-categories
|
||||
[categories]="store?.info?.categories"
|
||||
[category]="query ? '' : category"
|
||||
(categoryChange)="onCategoryChange($event)"
|
||||
></marketplace-categories>
|
||||
<a
|
||||
class="flex gap-2 p-2 hover:no-underline sm:hover:bg-[#222428] hover:cursor-pointer sm:w-[120%] z-50 rounded-l-lg ease-in-out delay-75 duration-300"
|
||||
target="_blank"
|
||||
href="https://github.com/Start9Labs/service-pipeline#readme"
|
||||
>
|
||||
<img
|
||||
alt="Rocket Icon"
|
||||
width="24"
|
||||
height="24"
|
||||
class="opacity-70 invert"
|
||||
src="svg/rocket-outline.svg"
|
||||
/>
|
||||
<span
|
||||
class="text-base text-zinc-50 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
Launch your project
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</ng-container>
|
||||
</header>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
|
||||
import { MenuComponent } from './menu.component'
|
||||
import { TuiButtonModule, TuiLoaderModule } from '@taiga-ui/core'
|
||||
import { TuiActiveZoneModule, TuiLetModule } from '@taiga-ui/cdk'
|
||||
import { TuiSidebarModule } from '@taiga-ui/addon-mobile'
|
||||
import { SearchModule } from '../../pages/list/search/search.module'
|
||||
import { CategoriesModule } from '../../pages/list/categories/categories.module'
|
||||
import { StoreIconComponentModule } from '../store-icon/store-icon.component.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedPipesModule,
|
||||
SearchModule,
|
||||
CategoriesModule,
|
||||
TuiActiveZoneModule,
|
||||
TuiSidebarModule,
|
||||
TuiLoaderModule,
|
||||
TuiButtonModule,
|
||||
CategoriesModule,
|
||||
StoreIconComponentModule,
|
||||
TuiLetModule,
|
||||
],
|
||||
declarations: [MenuComponent],
|
||||
exports: [MenuComponent],
|
||||
})
|
||||
export class MenuModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { combineLatest, map, Subject, takeUntil } from 'rxjs'
|
||||
import { StoreIdentity } from '../../types'
|
||||
import { AbstractMarketplaceService } from '../../services/marketplace.service'
|
||||
import { AbstractCategoryService } from '../../services/category.service'
|
||||
import { Router } from '@angular/router'
|
||||
import { MarketplaceConfig } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'menu',
|
||||
templateUrl: './menu.component.html',
|
||||
styleUrls: ['./menu.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MenuComponent implements OnDestroy {
|
||||
@Input({ required: true })
|
||||
iconConfig!: MarketplaceConfig
|
||||
|
||||
constructor(private readonly router: Router) {}
|
||||
|
||||
private destroy$ = new Subject<void>()
|
||||
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
||||
private readonly categoryService = inject(AbstractCategoryService)
|
||||
readonly store$ = this.marketplaceService.getSelectedStoreWithCategories$()
|
||||
readonly alt$ = combineLatest([
|
||||
this.marketplaceService.getKnownHosts$(),
|
||||
this.marketplaceService.getSelectedHost$(),
|
||||
]).pipe(
|
||||
map(([stores, selected]) =>
|
||||
stores.filter(({ url }) => url != selected.url),
|
||||
),
|
||||
)
|
||||
private hosts?: StoreIdentity[]
|
||||
category = ''
|
||||
query = ''
|
||||
open = false
|
||||
|
||||
ngOnInit() {
|
||||
this.categoryService
|
||||
.getQuery$()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(val => {
|
||||
this.query = val
|
||||
})
|
||||
|
||||
this.categoryService
|
||||
.getCategory$()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(val => {
|
||||
this.category = val
|
||||
})
|
||||
|
||||
this.marketplaceService
|
||||
.getKnownHosts$()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(hosts => {
|
||||
this.hosts = hosts
|
||||
})
|
||||
}
|
||||
|
||||
onCategoryChange(category: string): void {
|
||||
this.category = category
|
||||
this.query = ''
|
||||
this.categoryService.resetQuery()
|
||||
this.categoryService.changeCategory(category)
|
||||
this.router.navigate(['/marketplace'], { replaceUrl: true })
|
||||
}
|
||||
|
||||
onQueryChange(query: string): void {
|
||||
this.query = query
|
||||
this.categoryService.setQuery(query)
|
||||
this.router.navigate(['/marketplace'], { replaceUrl: true })
|
||||
}
|
||||
|
||||
toggleMenu(open: boolean): void {
|
||||
this.open = open
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next()
|
||||
this.destroy$.complete()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<img
|
||||
*ngIf="icon; else noIcon"
|
||||
class="rounded-full"
|
||||
[style.max-width]="size || '100%'"
|
||||
[src]="icon"
|
||||
alt="Service Icon"
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
<ion-button
|
||||
*ngFor="let cat of categories"
|
||||
fill="clear"
|
||||
class="category"
|
||||
[class.category_selected]="cat === category"
|
||||
<button
|
||||
*ngFor="let cat of categories || ['', '', '', '', '', '']"
|
||||
class="flex relative gap-2 hover:no-underline hover:cursor-pointer sm:hover:bg-[#222428] sm:hover:opacity-90 sm:w-[120%] rounded-l-lg ease-in-out delay-75 duration-300 mb-5 sm:p-2 sm:mb-3 z-50"
|
||||
routerLink="/marketplace"
|
||||
(click)="switchCategory(cat)"
|
||||
[class.category_selected]="cat === category"
|
||||
>
|
||||
{{ cat }}
|
||||
</ion-button>
|
||||
<div
|
||||
class="relative flex"
|
||||
[class.tui-skeleton]="!categories"
|
||||
[class.tui-skeleton_rounded]="!categories"
|
||||
>
|
||||
<img
|
||||
alt="Category Icon"
|
||||
width="24"
|
||||
height="24"
|
||||
class="opacity-70 invert"
|
||||
src="/svg/{{ determineIcon(cat) }}.svg"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-base text-zinc-50 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
[class.tui-skeleton]="!categories"
|
||||
[class.tui-skeleton_rounded]="!categories"
|
||||
>
|
||||
{{ (cat | titlecase) || 'loading category...' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
.category_selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.category {
|
||||
font-weight: 300;
|
||||
color: var(--ion-color-dark-shade);
|
||||
|
||||
&_selected {
|
||||
font-weight: bold;
|
||||
font-size: 17px;
|
||||
color: var(--color);
|
||||
@media (min-width: 600px) {
|
||||
.category_selected {
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
background-color: #222428;
|
||||
opacity: 90;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,11 @@ import {
|
||||
selector: 'marketplace-categories',
|
||||
templateUrl: 'categories.component.html',
|
||||
styleUrls: ['categories.component.scss'],
|
||||
host: {
|
||||
class: 'hidden-scrollbar ion-text-center',
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CategoriesComponent {
|
||||
@Input()
|
||||
categories: readonly string[] = []
|
||||
categories?: string[]
|
||||
|
||||
@Input()
|
||||
category = ''
|
||||
@@ -29,4 +26,31 @@ export class CategoriesComponent {
|
||||
this.category = category
|
||||
this.categoryChange.emit(category)
|
||||
}
|
||||
|
||||
determineIcon(category: string): string {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'all':
|
||||
return 'apps-outline'
|
||||
case 'bitcoin':
|
||||
return 'logo-bitcoin'
|
||||
case 'communications':
|
||||
return 'chatbubbles-outline'
|
||||
case 'data':
|
||||
return 'document-outline'
|
||||
case 'developer tools':
|
||||
return 'code-slash-outline'
|
||||
case 'featured':
|
||||
return 'star-outline'
|
||||
case 'lightning':
|
||||
return 'flash-outline'
|
||||
case 'media':
|
||||
return 'play-outline'
|
||||
case 'networking':
|
||||
return 'globe-outline'
|
||||
case 'social':
|
||||
return 'people-outline'
|
||||
default:
|
||||
return 'cube-outline'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
<ion-item class="service-card" [routerLink]="['/marketplace', pkg.manifest.id]">
|
||||
<ion-thumbnail slot="start">
|
||||
<img alt="" [src]="pkg | mimeType | trustUrl" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2 class="montserrat">
|
||||
<strong>{{ pkg.manifest.title }}</strong>
|
||||
</h2>
|
||||
<h3>{{ pkg.manifest.description.short }}</h3>
|
||||
<ng-content></ng-content>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<div
|
||||
class="h-full relative rounded-3xl pt-20 pb-10 px-8 gap-4 shadow-lg hover:scale-90 transition duration-500 ease-in-out min-w-[300px]"
|
||||
>
|
||||
<!-- color background -->
|
||||
<div
|
||||
class="overflow-hidden absolute w-full h-full top-0 left-0 -z-50 rounded-3xl bg-zinc-800"
|
||||
>
|
||||
<img
|
||||
[src]="'data:image/png;base64,' + pkg.icon | trustUrl"
|
||||
class="absolute object-cover pointer-events-none w-[150%] h-[150%] max-w-[200%] blur-[100px]"
|
||||
alt="{{ pkg.manifest.title }} Icon"
|
||||
/>
|
||||
</div>
|
||||
<!-- darkening overlay -->
|
||||
<div
|
||||
class="overflow-hidden absolute w-full h-full top-0 left-0 -z-50 rounded-3xl bg-zinc-800 opacity-40"
|
||||
></div>
|
||||
<!-- icon -->
|
||||
<img
|
||||
[src]="'data:image/png;base64,' + pkg.icon | trustUrl"
|
||||
class="w-[5.5rem] h-[5.5rem] pointer-events-none absolute -top-10 rounded-full object-cover shadow-lg z-10"
|
||||
alt="{{ pkg.manifest.title }} Icon"
|
||||
style="transform: none"
|
||||
/>
|
||||
<div class="mt-3 text-zinc-5 mix-blend-plus-lighter">
|
||||
<span class="block text-2xl font-medium line-clamp-1 mb-1">
|
||||
{{ pkg.manifest.title }}
|
||||
</span>
|
||||
<span class="block text-base h-12 line-clamp-2">
|
||||
{{ pkg.manifest.description.short }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { ItemComponent } from './item.component'
|
||||
import { MimeTypePipeModule } from '../../../pipes/mime-type.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ItemComponent],
|
||||
exports: [ItemComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule,
|
||||
SharedPipesModule,
|
||||
MimeTypePipeModule,
|
||||
],
|
||||
imports: [CommonModule, RouterModule, SharedPipesModule],
|
||||
})
|
||||
export class ItemModule {}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col responsiveCol class="column" sizeSm="8" sizeLg="6">
|
||||
<ion-toolbar color="transparent" class="ion-text-left">
|
||||
<ion-searchbar
|
||||
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
|
||||
debounce="250"
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="onModelChange($event)"
|
||||
></ion-searchbar>
|
||||
</ion-toolbar>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
<div class="text-zinc-100 bg-zinc-500 px-3 rounded-full">
|
||||
<div class="relative flex items-center">
|
||||
<img
|
||||
alt="Search icon"
|
||||
width="24"
|
||||
height="24"
|
||||
class="invert"
|
||||
src="svg/search.svg"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
class="mt-1 bg-transparent placeholder-zinc-300 focus:outline-none px-3 py-2"
|
||||
placeholder="Search..."
|
||||
autocomplete="off"
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="onModelChange($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.column {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { THEME } from '@start9labs/shared'
|
||||
})
|
||||
export class SearchComponent {
|
||||
@Input()
|
||||
query = ''
|
||||
query?: string | null = ''
|
||||
|
||||
@Output()
|
||||
readonly queryChange = new EventEmitter<string>()
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ResponsiveColDirective } from '@start9labs/shared'
|
||||
|
||||
import { SearchComponent } from './search.component'
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { ResponsiveColDirective } from "@start9labs/shared";
|
||||
import { SearchComponent } from "./search.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [IonicModule, FormsModule, CommonModule, ResponsiveColDirective],
|
||||
imports: [FormsModule, CommonModule, ResponsiveColDirective],
|
||||
declarations: [SearchComponent],
|
||||
exports: [SearchComponent],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="hidden-scrollbar ion-text-center">
|
||||
<div class="">
|
||||
<ion-button *ngFor="let cat of ['', '', '', '', '', '', '']" fill="clear">
|
||||
<ion-skeleton-text
|
||||
animated
|
||||
@@ -6,17 +6,47 @@
|
||||
></ion-skeleton-text>
|
||||
</ion-button>
|
||||
</div>
|
||||
<div class="hidden sm:flex flex-col mx-6 pb-3 items-center">
|
||||
<div class="pb-3">
|
||||
<ion-skeleton-text style="border-radius: 100%" animated></ion-skeleton-text>
|
||||
</div>
|
||||
|
||||
<div class="divider" style="margin: 24px 0"></div>
|
||||
<ng-content select="[slot=desktop]"></ng-content>
|
||||
</div>
|
||||
<ion-row>
|
||||
<ion-col
|
||||
*ngFor="let cat of ['', '', '', '', '', '', '']"
|
||||
fill="clear"
|
||||
responsiveCol
|
||||
sizeXs="12"
|
||||
sizeSm="12"
|
||||
sizeMd="3"
|
||||
>
|
||||
<ion-item>
|
||||
<ion-thumbnail slot="start">
|
||||
<ion-skeleton-text
|
||||
style="border-radius: 100%"
|
||||
animated
|
||||
></ion-skeleton-text>
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<ion-skeleton-text
|
||||
animated
|
||||
style="width: 150px; height: 18px; margin-bottom: 8px"
|
||||
></ion-skeleton-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<ion-grid>
|
||||
<ion-grid class="p-20">
|
||||
<ion-row>
|
||||
<ion-col
|
||||
*ngFor="let pkg of ['', '', '', '']"
|
||||
responsiveCol
|
||||
sizeXs="12"
|
||||
sizeSm="12"
|
||||
sizeMd="6"
|
||||
sizeMd="3"
|
||||
>
|
||||
<ion-item>
|
||||
<ion-thumbnail slot="start">
|
||||
|
||||
@@ -1,33 +1,58 @@
|
||||
<ion-content class="with-widgets">
|
||||
<ng-container *ngIf="notes$ | async as notes; else loading">
|
||||
<div *ngFor="let note of notes | keyvalue : asIsOrder">
|
||||
<ion-button
|
||||
expand="full"
|
||||
color="light"
|
||||
class="version-button"
|
||||
[class.ion-activated]="isSelected(note.key)"
|
||||
(click)="setSelected(note.key)"
|
||||
<div
|
||||
class="rounded-xl bg-gradient-to-bl from-zinc-400/75 to-zinc-600 p-px shadow-lg shadow-zinc-400/10 mt-6"
|
||||
>
|
||||
<div class="bg-zinc-800 rounded-xl p-7 grid grid-flow-row items-center gap-6">
|
||||
<div class="block">
|
||||
<h3 class="text-lg font-bold small-caps">What's new</h3>
|
||||
<p
|
||||
*ngIf="pkg['published-at'] as published"
|
||||
class="text-base font-light mb-1 text-zinc-300"
|
||||
>
|
||||
<p class="version">{{ note.key | displayEmver }}</p>
|
||||
</ion-button>
|
||||
<ion-card
|
||||
tuiElement
|
||||
#element="elementRef"
|
||||
class="panel"
|
||||
color="light"
|
||||
[id]="note.key"
|
||||
[style.maxHeight.px]="getDocSize(note.key, element)"
|
||||
>
|
||||
<ion-text
|
||||
id="release-notes"
|
||||
safeLinks
|
||||
[innerHTML]="note.value | markdown | dompurify"
|
||||
></ion-text>
|
||||
</ion-card>
|
||||
<span class="small-caps">Latest Release</span>
|
||||
-
|
||||
<span class="text-sm">{{ published | date : 'medium' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-6">
|
||||
<div class="text-base">
|
||||
<p>Version {{ pkg.manifest.version }}</p>
|
||||
<p
|
||||
safeLinks
|
||||
class="flex-wrap mt-1"
|
||||
[innerHTML]="pkg.manifest['release-notes'] | markdown | dompurify"
|
||||
></p>
|
||||
</div>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="secondary"
|
||||
size="m"
|
||||
class="mt-3 place-self-end sm:place-self-start md:place-self-start lg:place-self-end"
|
||||
(click)="showReleaseNotes(template)"
|
||||
>
|
||||
Previous releases
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #template let-observer>
|
||||
<ng-container *ngIf="notes$ | async as notes; else loading">
|
||||
<tui-accordion
|
||||
class="max-w-lg"
|
||||
[closeOthers]="false"
|
||||
*ngFor="let note of notes | keyvalue : asIsOrder"
|
||||
>
|
||||
<tui-accordion-item class="my-1">
|
||||
{{ note.key | displayEmver }}
|
||||
<ng-template tuiAccordionItemContent>
|
||||
<p safeLinks [innerHTML]="note.value | markdown | dompurify"></p>
|
||||
</ng-template>
|
||||
</tui-accordion-item>
|
||||
</tui-accordion>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<text-spinner text="Loading Release Notes"></text-spinner>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
:host {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin: 0;
|
||||
padding: 0 24px;
|
||||
transition: max-height 0.2s ease-out;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 5px solid #4d4d4d;
|
||||
}
|
||||
|
||||
.version-button {
|
||||
height: 50px;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.version {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef } from '@angular/core'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { getPkgId } from '@start9labs/shared'
|
||||
import { AbstractMarketplaceService } from '../../services/marketplace.service'
|
||||
import { PolymorpheusContent } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
|
||||
import { MarketplacePkg } from '../../types'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'release-notes',
|
||||
@@ -13,27 +21,29 @@ export class ReleaseNotesComponent {
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly marketplaceService: AbstractMarketplaceService,
|
||||
@Inject(TuiDialogService) private readonly dialogs: TuiDialogService,
|
||||
) {}
|
||||
|
||||
private readonly pkgId = getPkgId(this.route)
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
private selected: string | null = null
|
||||
notes$!: Observable<Record<string, string>>
|
||||
|
||||
readonly notes$ = this.marketplaceService.fetchReleaseNotes$(this.pkgId)
|
||||
|
||||
isSelected(key: string): boolean {
|
||||
return this.selected === key
|
||||
}
|
||||
|
||||
setSelected(selected: string) {
|
||||
this.selected = this.isSelected(selected) ? null : selected
|
||||
}
|
||||
|
||||
getDocSize(key: string, { nativeElement }: ElementRef<HTMLElement>) {
|
||||
return this.isSelected(key) ? nativeElement.scrollHeight : 0
|
||||
ngOnChanges() {
|
||||
this.notes$ = this.marketplaceService.fetchReleaseNotes$(
|
||||
this.pkg.manifest.id,
|
||||
)
|
||||
}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
|
||||
async showReleaseNotes(content: PolymorpheusContent<TuiDialogContext>) {
|
||||
this.dialogs
|
||||
.open(content, {
|
||||
label: 'Previous Release Notes',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { NgModule } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
EmverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
SafeLinksDirective,
|
||||
TextSpinnerComponentModule,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiElementModule } from '@taiga-ui/cdk'
|
||||
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
|
||||
|
||||
import { ReleaseNotesComponent } from './release-notes.component'
|
||||
} from "@start9labs/shared";
|
||||
import { NgDompurifyModule } from "@tinkoff/ng-dompurify";
|
||||
import { ReleaseNotesComponent } from "./release-notes.component";
|
||||
import { TuiButtonModule } from "@taiga-ui/core";
|
||||
import { TuiAccordionModule } from "@taiga-ui/kit";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TextSpinnerComponentModule,
|
||||
EmverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
TuiElementModule,
|
||||
NgDompurifyModule,
|
||||
SafeLinksDirective,
|
||||
TuiButtonModule,
|
||||
TuiAccordionModule,
|
||||
],
|
||||
declarations: [ReleaseNotesComponent],
|
||||
exports: [ReleaseNotesComponent],
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
<!-- release notes -->
|
||||
<ion-item-divider>
|
||||
New in {{ pkg.manifest.version | displayEmver }}
|
||||
</ion-item-divider>
|
||||
<ion-item lines="none" color="transparent">
|
||||
<ion-label>
|
||||
<div
|
||||
safeLinks
|
||||
[innerHTML]="pkg.manifest['release-notes'] | markdown | dompurify"
|
||||
></div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-button routerLink="notes" fill="clear" strong>
|
||||
Past Release Notes
|
||||
<ion-icon slot="end" name="arrow-forward"></ion-icon>
|
||||
</ion-button>
|
||||
<!-- description -->
|
||||
<ion-item-divider>Description</ion-item-divider>
|
||||
<ion-item lines="none" color="transparent">
|
||||
<ion-label>
|
||||
<h2>{{ pkg.manifest.description.long }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<div
|
||||
*ngIf="pkg.manifest['marketing-site'] as url"
|
||||
style="padding: 4px 0 10px 14px"
|
||||
class="rounded-xl bg-gradient-to-bl from-zinc-400/75 to-zinc-600 p-px shadow-lg shadow-zinc-400/10"
|
||||
>
|
||||
<ion-button [href]="url" target="_blank" rel="noreferrer" color="tertiary">
|
||||
View website
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<div class="lg:col-span-5 xl:col-span-4 bg-zinc-800 rounded-xl p-7">
|
||||
<h2 class="text-lg font-bold small-caps pb-3">Description</h2>
|
||||
<p class="text-base mb-3">
|
||||
{{ pkg.manifest.description.long }}
|
||||
</p>
|
||||
<ng-container *ngIf="pkg.manifest.replaces as replaces">
|
||||
<div *ngIf="replaces.length">
|
||||
<h2 class="text-lg font-bold small-caps pb-3">Intended to replace</h2>
|
||||
<tui-tag
|
||||
*ngFor="let app; index as i; of: replaces"
|
||||
size="l"
|
||||
[class]="i > 0 ? 'ml-1.5 mt-2' : 'mt-2'"
|
||||
[value]="app"
|
||||
></tui-tag>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
EmverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
SafeLinksDirective,
|
||||
} from '@start9labs/shared'
|
||||
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
|
||||
|
||||
import { AboutComponent } from './about.component'
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { AboutComponent } from "./about.component";
|
||||
import { TuiTagModule } from "@taiga-ui/kit";
|
||||
import { NgDompurifyModule } from "@tinkoff/ng-dompurify";
|
||||
import { SafeLinksDirective } from "@start9labs/shared";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
IonicModule,
|
||||
MarkdownPipeModule,
|
||||
EmverPipesModule,
|
||||
TuiTagModule,
|
||||
NgDompurifyModule,
|
||||
SafeLinksDirective,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<a [href]="url" target="_blank" rel="noreferrer">
|
||||
<div class="flex justify-between items-center">
|
||||
<label [tuiLabel]="label" class="hover:cursor-pointer">
|
||||
<tui-line-clamp [content]="url" [linesLimit]="1"></tui-line-clamp>
|
||||
</label>
|
||||
<img
|
||||
alt="Open Icon"
|
||||
class="block opacity-70 invert w-4"
|
||||
src="svg/open-outline.svg"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { AdditionalLinkComponent } from './additional-link.component'
|
||||
import { TuiLabelModule } from '@taiga-ui/core'
|
||||
import { TuiLineClampModule } from '@taiga-ui/kit'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, TuiLabelModule, TuiLineClampModule],
|
||||
declarations: [AdditionalLinkComponent],
|
||||
exports: [AdditionalLinkComponent],
|
||||
})
|
||||
export class AdditionalLinkModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { Url } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-additional-link',
|
||||
templateUrl: 'additional-link.component.html',
|
||||
styleUrls: ['additional-link.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AdditionalLinkComponent {
|
||||
@Input({ required: true })
|
||||
url!: Url
|
||||
|
||||
@Input({ required: true })
|
||||
label!: string
|
||||
}
|
||||
@@ -1,131 +1,128 @@
|
||||
<ng-container *ngIf="pkg.manifest.replaces as replaces">
|
||||
<div *ngIf="replaces.length" class="ion-padding-bottom">
|
||||
<ion-item-divider>Intended to replace</ion-item-divider>
|
||||
<ul>
|
||||
<li *ngFor="let app of replaces">
|
||||
{{ app }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div
|
||||
class="rounded-xl bg-gradient-to-bl from-zinc-400/75 to-zinc-600 p-px shadow-lg shadow-zinc-400/10"
|
||||
>
|
||||
<div class="bg-zinc-800 rounded-xl p-7">
|
||||
<h2 class="text-lg font-bold small-caps pb-3">Information</h2>
|
||||
<div class="grid grid-flow-row divide-y divide-zinc-500">
|
||||
<!-- git hash -->
|
||||
<div
|
||||
*ngIf="pkg.manifest['git-hash'] as gitHash; else noHash"
|
||||
button
|
||||
detail="false"
|
||||
class="py-3 px-1 flex flex-wrap justify-between items-center"
|
||||
(click)="copyService.copy(gitHash)"
|
||||
>
|
||||
<label tuiLabel="Git Hash">{{ gitHash }}</label>
|
||||
<img
|
||||
alt="Copy Icon"
|
||||
class="block opacity-70 invert w-4 hover:cursor-copy"
|
||||
src="svg/copy-outline.svg"
|
||||
/>
|
||||
</div>
|
||||
<ng-template #noHash>
|
||||
<div class="py-3 px-1">
|
||||
<label tuiLabel="Git Hash">Unknown</label>
|
||||
</div>
|
||||
</ng-template>
|
||||
<!-- license -->
|
||||
<div
|
||||
class="py-3 px-1 hover:bg-zinc-500/10 hover:cursor-pointer"
|
||||
(click)="presentModalMd('License')"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between items-center">
|
||||
<label tuiLabel="License" class="hover:cursor-pointer">
|
||||
{{ pkg.manifest.license }}
|
||||
</label>
|
||||
<img
|
||||
alt="Open Icon"
|
||||
class="block opacity-70 invert w-4"
|
||||
src="svg/chevron-forward.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- instructions -->
|
||||
<div
|
||||
class="py-3 px-1 hover:bg-zinc-500/10 hover:cursor-pointer"
|
||||
(click)="presentModalMd('Instructions')"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between items-center">
|
||||
<label tuiLabel="Instructions" class="hover:cursor-pointer">
|
||||
Click to view instructions
|
||||
</label>
|
||||
<img
|
||||
alt="Open Icon"
|
||||
class="block opacity-70 invert w-4"
|
||||
src="svg/chevron-forward.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-item-divider>Additional Info</ion-item-divider>
|
||||
<ion-grid *ngIf="pkg.manifest as manifest">
|
||||
<ion-row>
|
||||
<ion-col responsiveCol sizeXs="12" sizeMd="6">
|
||||
<ion-item-group>
|
||||
<ion-item
|
||||
*ngIf="manifest['git-hash'] as gitHash; else noHash"
|
||||
button
|
||||
detail="false"
|
||||
(click)="copyService.copy(gitHash)"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Git Hash</h2>
|
||||
<p>{{ gitHash }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="copy-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ng-template #noHash>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Git Hash</h2>
|
||||
<p>Unknown</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<!-- versions -->
|
||||
<div
|
||||
(click)="presentAlertVersions(version)"
|
||||
class="py-3 px-1 hover:bg-zinc-500/10 hover:cursor-pointer"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between items-center">
|
||||
<label tuiLabel="Other versions" class="hover:cursor-pointer">
|
||||
Click to view other versions
|
||||
</label>
|
||||
<img
|
||||
alt="Open Icon"
|
||||
class="block opacity-70 invert w-4"
|
||||
src="svg/chevron-forward.svg"
|
||||
/>
|
||||
</div>
|
||||
<ng-template #version let-data="data" let-completeWith="completeWith">
|
||||
<tui-radio-list
|
||||
class="radio"
|
||||
size="l"
|
||||
[items]="data.items"
|
||||
[itemContent]="displayEmver | tuiStringifyContent"
|
||||
[(ngModel)]="data.value"
|
||||
></tui-radio-list>
|
||||
<footer class="buttons">
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
(click)="completeWith(null)"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
(click)="completeWith(data.value)"
|
||||
>
|
||||
Ok
|
||||
</button>
|
||||
</footer>
|
||||
</ng-template>
|
||||
<ion-item button detail="false" (click)="presentAlertVersions(version)">
|
||||
<ion-label>
|
||||
<h2>Other Versions</h2>
|
||||
<p>Click to view other versions</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
<ng-template #version let-data="data" let-completeWith="completeWith">
|
||||
<tui-radio-list
|
||||
class="radio"
|
||||
size="l"
|
||||
[items]="data.items"
|
||||
[itemContent]="displayEmver | tuiStringifyContent"
|
||||
[(ngModel)]="data.value"
|
||||
></tui-radio-list>
|
||||
<footer class="buttons">
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
(click)="completeWith(null)"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
(click)="completeWith(data.value)"
|
||||
>
|
||||
Ok
|
||||
</button>
|
||||
</footer>
|
||||
</ng-template>
|
||||
</ion-item>
|
||||
<ion-item button detail="false" (click)="presentModalMd('License')">
|
||||
<ion-label>
|
||||
<h2>License</h2>
|
||||
<p>{{ manifest.license }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
(click)="presentModalMd('Instructions')"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Instructions</h2>
|
||||
<p>Click to view instructions</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
<ion-col responsiveCol sizeXs="12" sizeMd="6">
|
||||
<ion-item-group>
|
||||
<ion-item
|
||||
[href]="manifest['upstream-repo']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Source Repository</h2>
|
||||
<p>{{ manifest['upstream-repo'] }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="manifest['wrapper-repo']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Wrapper Repository</h2>
|
||||
<p>{{ manifest['wrapper-repo'] }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="manifest['support-site']"
|
||||
[disabled]="!manifest['support-site']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Support Site</h2>
|
||||
<p>{{ manifest['support-site'] || 'Not provided' }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</div>
|
||||
<!-- links -->
|
||||
<marketplace-additional-link
|
||||
[url]="pkg.manifest['marketing-site']"
|
||||
*ngIf="pkg.manifest['marketing-site']"
|
||||
label="Marketing Site"
|
||||
class="py-3 px-1 hover:bg-zinc-500/10 hover:cursor-pointer"
|
||||
></marketplace-additional-link>
|
||||
<marketplace-additional-link
|
||||
[url]="pkg.manifest['upstream-repo']"
|
||||
*ngIf="pkg.manifest['upstream-repo']"
|
||||
label="Source Repository"
|
||||
class="py-3 px-1 hover:bg-zinc-500/10 hover:cursor-pointer"
|
||||
></marketplace-additional-link>
|
||||
<marketplace-additional-link
|
||||
[url]="pkg.manifest['wrapper-repo']"
|
||||
*ngIf="pkg.manifest['wrapper-repo']"
|
||||
label="Wrapper Repository"
|
||||
class="py-3 px-1 hover:bg-zinc-500/10 hover:cursor-pointer"
|
||||
></marketplace-additional-link>
|
||||
<marketplace-additional-link
|
||||
[url]="pkg.manifest['support-site']"
|
||||
*ngIf="pkg.manifest['support-site']"
|
||||
label="Support Site"
|
||||
class="py-3 px-1 hover:bg-zinc-500/10 hover:cursor-pointer"
|
||||
></marketplace-additional-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.radio {
|
||||
display: block;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
TemplateRef,
|
||||
@@ -38,13 +39,13 @@ export class AdditionalComponent {
|
||||
version = new EventEmitter<string>()
|
||||
|
||||
readonly displayEmver = displayEmver
|
||||
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
||||
|
||||
constructor(
|
||||
readonly copyService: CopyService,
|
||||
private readonly alerts: TuiAlertService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly emver: Emver,
|
||||
private readonly marketplaceService: AbstractMarketplaceService,
|
||||
private readonly route: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MarkdownModule, ResponsiveColDirective } from '@start9labs/shared'
|
||||
|
||||
import { AdditionalComponent } from './additional.component'
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { AdditionalComponent } from "./additional.component";
|
||||
import {
|
||||
TuiRadioListModule,
|
||||
TuiStringifyContentPipeModule,
|
||||
} from '@taiga-ui/kit'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
} from "@taiga-ui/kit";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { TuiButtonModule, TuiLabelModule } from "@taiga-ui/core";
|
||||
import { AdditionalLinkModule } from "./additional-link/additional-link.component.module";
|
||||
import { ResponsiveColDirective } from "@start9labs/shared";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
MarkdownModule,
|
||||
ResponsiveColDirective,
|
||||
TuiRadioListModule,
|
||||
FormsModule,
|
||||
TuiStringifyContentPipeModule,
|
||||
TuiButtonModule,
|
||||
TuiLabelModule,
|
||||
AdditionalLinkModule,
|
||||
],
|
||||
declarations: [AdditionalComponent],
|
||||
exports: [AdditionalComponent],
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
<ion-item-divider>Dependencies</ion-item-divider>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col
|
||||
*ngFor="let dep of pkg.manifest.dependencies | keyvalue"
|
||||
responsiveCol
|
||||
sizeSm="12"
|
||||
sizeMd="6"
|
||||
>
|
||||
<ion-item [routerLink]="['/marketplace', dep.key]">
|
||||
<ion-thumbnail slot="start">
|
||||
<img
|
||||
alt=""
|
||||
style="border-radius: 100%"
|
||||
[src]="getImg(dep.key) | trustUrl"
|
||||
/>
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2>
|
||||
{{ pkg['dependency-metadata'][dep.key].title }}
|
||||
<ng-container [ngSwitch]="dep.value.requirement.type">
|
||||
<span *ngSwitchCase="'required'">(required)</span>
|
||||
<span *ngSwitchCase="'opt-out'">(required by default)</span>
|
||||
<span *ngSwitchCase="'opt-in'">(optional)</span>
|
||||
</ng-container>
|
||||
</h2>
|
||||
<p>
|
||||
<small>{{ dep.value.version | displayEmver }}</small>
|
||||
</p>
|
||||
<p>{{ dep.value.description }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
<div
|
||||
class="bg-zinc-700/40 rounded-xl py-3 px-5 gap-2 drop-shadow-lg hover:bg-zinc-700/70"
|
||||
>
|
||||
<div class="flex items-center gap-6">
|
||||
<tui-avatar
|
||||
class="w-16 pointer-events-none rounded-full object-cover drop-shadow-lg"
|
||||
[src]="getImage(dep.key)"
|
||||
></tui-avatar>
|
||||
<div class="mt-3">
|
||||
<div class="flex flex-wrap items-center gap-1 mb-1">
|
||||
<span class="block text-base font-medium text-zinc-50/90 line-clamp-1">
|
||||
{{ getTitle(dep.key) }}
|
||||
</span>
|
||||
<p>
|
||||
<ng-container [ngSwitch]="dep.value.requirement.type">
|
||||
<span *ngSwitchCase="'required'">(required)</span>
|
||||
<span *ngSwitchCase="'opt-out'">(required by default)</span>
|
||||
<span *ngSwitchCase="'opt-in'">(optional)</span>
|
||||
</ng-container>
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-sm text-zinc-50/70 line-clamp-1">
|
||||
{{ dep.value.version | displayEmver }}
|
||||
</span>
|
||||
<span class="text-sm text-zinc-50/70 h-11 line-clamp-2">
|
||||
{{ dep.value.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
import { Dependency, MarketplacePkg } from '../../../types'
|
||||
import { KeyValue } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-dependencies',
|
||||
templateUrl: 'dependencies.component.html',
|
||||
styleUrls: ['./dependencies.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DependenciesComponent {
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
getImg(key: string): string {
|
||||
@Input({ required: true })
|
||||
dep!: KeyValue<string, Dependency>
|
||||
|
||||
getImage(key: string): string {
|
||||
const icon = this.pkg['dependency-metadata'][key]?.icon
|
||||
// @TODO fix when registry api is updated to include mimetype in icon url
|
||||
return 'data:image/png;base64,' + this.pkg['dependency-metadata'][key].icon
|
||||
return icon ? `data:image/png;base64,${icon}` : key.substring(0, 2)
|
||||
}
|
||||
|
||||
getTitle(key: string): string {
|
||||
return this.pkg['dependency-metadata'][key]?.title || key
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
EmverPipesModule,
|
||||
ResponsiveColDirective,
|
||||
SharedPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { EmverPipesModule, ResponsiveColDirective } from '@start9labs/shared'
|
||||
import { DependenciesComponent } from './dependencies.component'
|
||||
|
||||
import { TuiAvatarModule } from '@taiga-ui/experimental'
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
EmverPipesModule,
|
||||
ResponsiveColDirective,
|
||||
TuiAvatarModule,
|
||||
EmverPipesModule,
|
||||
],
|
||||
declarations: [DependenciesComponent],
|
||||
exports: [DependenciesComponent],
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
import { MimeTypePipeModule } from '../../../pipes/mime-type.pipe'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-package-hero',
|
||||
template: `
|
||||
<div class="flex justify-center mt-10 md:mt-0 z-0">
|
||||
<div
|
||||
class="flex flex-col w-full h-[32vh] xs:h-[26vh] md:min-h-[14rem] relative rounded-3xl pt-16 px-8 shadow-lg"
|
||||
>
|
||||
<!-- icon -->
|
||||
<img
|
||||
[src]="pkg | mimeType | trustUrl"
|
||||
class="w-24 h-24 pointer-events-none rounded-full object-cover shadow-lg absolute -top-9 left-7 z-10"
|
||||
alt="{{ pkg.manifest.title }} Icon"
|
||||
/>
|
||||
<!-- color background -->
|
||||
<div
|
||||
class="overflow-hidden absolute w-full h-full top-0 left-0 rounded-3xl bg-zinc-800"
|
||||
>
|
||||
<img
|
||||
[src]="pkg | mimeType | trustUrl"
|
||||
class="absolute object-cover pointer-events-none w-[200%] h-[200%] max-w-[200%] blur-[100px] saturate-150 rounded-full"
|
||||
alt="{{ pkg.manifest.title }} background image"
|
||||
/>
|
||||
</div>
|
||||
<!-- background darkening overlay -->
|
||||
<div
|
||||
class="overflow-hidden absolute w-full h-full top-0 left-0 rounded-3xl bg-zinc-700 opacity-70"
|
||||
></div>
|
||||
<div class="my-4 text-zinc-50 mix-blend-plus-lighter">
|
||||
<h2 class="text-2xl font-medium line-clamp-1 mb-1">
|
||||
{{ pkg.manifest.title }}
|
||||
</h2>
|
||||
<p class="block text-base line-clamp-2">
|
||||
{{ pkg.manifest.description.short }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- control buttons -->
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, SharedPipesModule, MimeTypePipeModule],
|
||||
})
|
||||
export class MarketplacePackageHeroComponent {
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<img class="logo" alt="" [src]="pkg | mimeType | trustUrl" />
|
||||
<div class="text">
|
||||
<h1 ticker class="title">{{ pkg.manifest.title }}</h1>
|
||||
<p class="version">{{ pkg.manifest.version | displayEmver }}</p>
|
||||
<p *ngIf="pkg['published-at'] as published" class="published">
|
||||
Released: {{ published | date : 'medium' }}
|
||||
</p>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
@@ -1,48 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
margin-right: 16px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0 -2px;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.published {
|
||||
margin: 0;
|
||||
padding: 4px 0 12px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.logo {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-package',
|
||||
templateUrl: 'package.component.html',
|
||||
styleUrls: ['package.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PackageComponent {
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
EmverPipesModule,
|
||||
SharedPipesModule,
|
||||
TickerModule,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { PackageComponent } from './package.component'
|
||||
import { MimeTypePipeModule } from '../../../pipes/mime-type.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [PackageComponent],
|
||||
exports: [PackageComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
EmverPipesModule,
|
||||
TickerModule,
|
||||
MimeTypePipeModule,
|
||||
],
|
||||
})
|
||||
export class PackageModule {}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiCarouselModule } from '@taiga-ui/kit'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
import { PolymorpheusContent } from '@tinkoff/ng-polymorpheus'
|
||||
import { isPlatform } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-package-screenshots',
|
||||
template: `
|
||||
<div
|
||||
*ngIf="pkg.screenshots"
|
||||
tuiCarouselButtons
|
||||
class="flex items-center content-center m-0 lg:-ml-14 lg:-mr-14 lg:min-h-80 lg:h-80 2xl:h-full"
|
||||
>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
icon="tuiIconChevronLeftLarge"
|
||||
title="Previous"
|
||||
type="button"
|
||||
(click)="carousel.prev()"
|
||||
></button>
|
||||
<tui-carousel
|
||||
#carousel
|
||||
[itemsCount]="isMobile ? 1 : 2"
|
||||
[(index)]="index"
|
||||
class="overflow-y-hidden overflow-x-scroll carousel overflow-hidden"
|
||||
>
|
||||
<ng-container *ngFor="let item of pkg.screenshots; let i = index">
|
||||
<div
|
||||
*tuiItem
|
||||
draggable="false"
|
||||
[class.item_active]="i === index + 1"
|
||||
class="object-cover overflow-hidden rounded-lg md:rounded-xl border border-zinc-400/30 hover:cursor-pointer shadow-lg shadow-zinc-400/10"
|
||||
>
|
||||
<img
|
||||
#template
|
||||
alt="Service screenshot"
|
||||
src="assets/img/temp/{{ item }}"
|
||||
class="w-full h-full rounded-lg md:rounded-xl"
|
||||
(click)="presentModalImg(dialogTemplate)"
|
||||
/>
|
||||
<ng-template #dialogTemplate let-observer>
|
||||
<img
|
||||
alt="Service screenshot"
|
||||
src="assets/img/temp/{{ item }}"
|
||||
class="rounded-none"
|
||||
/>
|
||||
</ng-template>
|
||||
</div>
|
||||
</ng-container>
|
||||
</tui-carousel>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
type="button"
|
||||
icon="tuiIconChevronRightLarge"
|
||||
title="Next"
|
||||
(click)="carousel.next()"
|
||||
></button>
|
||||
</div>
|
||||
`,
|
||||
styles: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiCarouselModule, TuiButtonModule],
|
||||
})
|
||||
export class MarketplacePackageScreenshotComponent {
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
constructor(
|
||||
@Inject(TuiDialogService) private readonly dialogs: TuiDialogService,
|
||||
) {}
|
||||
index = 0
|
||||
|
||||
isMobile = isPlatform(window, 'ios') || isPlatform(window, 'android')
|
||||
|
||||
presentModalImg(content: PolymorpheusContent<TuiDialogContext>) {
|
||||
this.dialogs
|
||||
.open(content, {
|
||||
size: 'l',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import Fuse from 'fuse.js'
|
||||
export class FilterPackagesPipe implements PipeTransform {
|
||||
transform(
|
||||
packages: MarketplacePkg[],
|
||||
query: string,
|
||||
category: string,
|
||||
query: string | null,
|
||||
category: string | null,
|
||||
): MarketplacePkg[] {
|
||||
// query
|
||||
if (query) {
|
||||
@@ -68,13 +68,14 @@ export class FilterPackagesPipe implements PipeTransform {
|
||||
|
||||
// category
|
||||
return packages
|
||||
.filter(p => category === 'all' || p.categories.includes(category))
|
||||
.filter(p => category === 'all' || p.categories.includes(category!))
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(b['published-at']).valueOf() -
|
||||
new Date(a['published-at']).valueOf()
|
||||
)
|
||||
})
|
||||
.map(a => ({ ...a }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ export * from './pages/list/item/item.component'
|
||||
export * from './pages/list/item/item.module'
|
||||
export * from './pages/list/search/search.component'
|
||||
export * from './pages/list/search/search.module'
|
||||
export * from './pages/list/skeleton/skeleton.component'
|
||||
export * from './pages/list/skeleton/skeleton.module'
|
||||
export * from './pages/release-notes/release-notes.component'
|
||||
export * from './pages/release-notes/release-notes.module'
|
||||
export * from './pages/show/about/about.component'
|
||||
@@ -18,8 +16,8 @@ export * from './pages/show/additional/additional.component'
|
||||
export * from './pages/show/additional/additional.module'
|
||||
export * from './pages/show/dependencies/dependencies.component'
|
||||
export * from './pages/show/dependencies/dependencies.module'
|
||||
export * from './pages/show/package/package.component'
|
||||
export * from './pages/show/package/package.module'
|
||||
export * from './pages/show/screenshots/screenshots.component'
|
||||
export * from './pages/show/hero/hero.component'
|
||||
|
||||
export * from './pipes/filter-packages.pipe'
|
||||
export * from './pipes/mime-type.pipe'
|
||||
@@ -27,7 +25,10 @@ export * from './pipes/mime-type.pipe'
|
||||
export * from './components/store-icon/store-icon.component'
|
||||
export * from './components/store-icon/store-icon.component.module'
|
||||
export * from './components/store-icon/store-icon.component'
|
||||
export * from './components/menu/menu.component.module'
|
||||
export * from './components/menu/menu.component'
|
||||
|
||||
export * from './services/marketplace.service'
|
||||
export * from './services/category.service'
|
||||
|
||||
export * from './types'
|
||||
|
||||
16
web/projects/marketplace/src/services/category.service.ts
Normal file
16
web/projects/marketplace/src/services/category.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BehaviorSubject, Observable } from 'rxjs'
|
||||
|
||||
export abstract class AbstractCategoryService {
|
||||
readonly category$ = new BehaviorSubject<string>('all')
|
||||
readonly query$ = new BehaviorSubject<string>('')
|
||||
|
||||
abstract getCategory$(): Observable<string>
|
||||
|
||||
abstract changeCategory(category: string): void
|
||||
|
||||
abstract setQuery(query: string): void
|
||||
|
||||
abstract getQuery$(): Observable<string>
|
||||
|
||||
abstract resetQuery(): void
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Observable } from 'rxjs'
|
||||
import { MarketplacePkg, Marketplace, StoreData, StoreIdentity } from '../types'
|
||||
import {
|
||||
Marketplace,
|
||||
MarketplacePkg,
|
||||
StoreData,
|
||||
StoreIdentity,
|
||||
StoreIdentityWithData,
|
||||
} from '../types'
|
||||
|
||||
export abstract class AbstractMarketplaceService {
|
||||
abstract getKnownHosts$(): Observable<StoreIdentity[]>
|
||||
@@ -10,6 +16,8 @@ export abstract class AbstractMarketplaceService {
|
||||
|
||||
abstract getSelectedStore$(): Observable<StoreData>
|
||||
|
||||
abstract getSelectedStoreWithCategories$(): Observable<StoreIdentityWithData>
|
||||
|
||||
abstract getPackage$(
|
||||
id: string,
|
||||
version: string,
|
||||
|
||||
@@ -19,9 +19,12 @@ export interface StoreInfo {
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
export type StoreIdentityWithData = StoreData & StoreIdentity
|
||||
|
||||
export interface MarketplacePkg {
|
||||
icon: Url
|
||||
license: Url
|
||||
screenshots?: string[]
|
||||
instructions: Url
|
||||
manifest: Manifest
|
||||
categories: string[]
|
||||
|
||||
Reference in New Issue
Block a user