From b5331eddecbd67a751c046a2602f9d0b1ff36253 Mon Sep 17 00:00:00 2001 From: Mariotaku Date: Thu, 22 May 2025 00:07:34 +0900 Subject: [PATCH 1/9] changes style for installed apps --- src/app/apps/apps.component.html | 15 +--- src/app/apps/apps.component.ts | 55 +------------ .../apps/installed/installed.component.html | 80 +++++++++++-------- src/app/apps/installed/installed.component.ts | 42 ++++------ src/styles/shared.scss | 1 + 5 files changed, 71 insertions(+), 122 deletions(-) diff --git a/src/app/apps/apps.component.html b/src/app/apps/apps.component.html index 50209559..f4a99542 100644 --- a/src/app/apps/apps.component.html +++ b/src/app/apps/apps.component.html @@ -1,23 +1,17 @@
-
-
- -
+
diff --git a/src/app/apps/apps.component.ts b/src/app/apps/apps.component.ts index f5e3d565..6592ce2f 100644 --- a/src/app/apps/apps.component.ts +++ b/src/app/apps/apps.component.ts @@ -1,17 +1,15 @@ -import {Component, Injector, OnDestroy, OnInit, ViewChild} from '@angular/core'; +import {Component, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; -import {noop, Observable, Subscription} from 'rxjs'; -import {Device, PackageInfo, RawPackageInfo} from '../types'; -import {AppManagerService, DeviceManagerService, RepositoryItem} from '../core/services'; +import {noop, Subscription} from 'rxjs'; +import {Device, RawPackageInfo} from '../types'; +import {AppManagerService, RepositoryItem} from '../core/services'; import {MessageDialogComponent} from '../shared/components/message-dialog/message-dialog.component'; import {ProgressDialogComponent} from '../shared/components/progress-dialog/progress-dialog.component'; -import {keyBy} from 'lodash'; import {open as showOpenDialog} from '@tauri-apps/plugin-dialog'; import {basename, downloadDir} from "@tauri-apps/api/path"; import {APP_ID_HBCHANNEL} from "../shared/constants"; import {HbchannelRemoveComponent} from "./hbchannel-remove/hbchannel-remove.component"; import {StatStorageInfoComponent} from "../shared/components/stat-storage-info/stat-storage-info.component"; -import {DetailsComponent} from "./details/details.component"; @Component({ selector: 'app-apps', @@ -20,10 +18,7 @@ import {DetailsComponent} from "./details/details.component"; }) export class AppsComponent implements OnInit, OnDestroy { - packages$?: Observable; - instPackages?: Record; device: Device | null = null; - devices$?: Observable; tabId: string = 'installed'; @ViewChild('storageInfo') storageInfo?: StatStorageInfoComponent; @@ -32,24 +27,12 @@ export class AppsComponent implements OnInit, OnDestroy { private packagesSubscription?: Subscription; constructor( - public deviceManager: DeviceManagerService, private modalService: NgbModal, private appManager: AppManagerService, ) { } ngOnInit(): void { - this.devices$ = this.deviceManager.devices$; - this.deviceSubscription = this.deviceManager.selected$.subscribe((device) => { - this.device = device; - if (device) { - this.loadPackages(); - } else { - this.packages$ = undefined; - this.packagesSubscription?.unsubscribe(); - this.packagesSubscription = undefined; - } - }); } ngOnDestroy(): void { @@ -58,21 +41,6 @@ export class AppsComponent implements OnInit, OnDestroy { this.packagesSubscription = undefined; } - loadPackages(): void { - const device = this.device; - if (!device) return; - this.packagesSubscription?.unsubscribe(); - this.packages$ = this.appManager.packages$(device); - this.packagesSubscription = this.packages$.subscribe({ - next: (pkgs) => { - if (pkgs?.length) { - this.instPackages = keyBy(pkgs, (pkg) => pkg.id); - } - }, error: noop - }); - this.appManager.load(device).catch(noop); - } - async openInstallChooser(): Promise { if (!this.device) return; const path = await showOpenDialog({ @@ -189,21 +157,6 @@ export class AppsComponent implements OnInit, OnDestroy { } } - openDetails(item: RepositoryItem): void { - const modalRef = this.modalService.open(DetailsComponent, { - size: 'lg', - scrollable: true, - injector: Injector.create({ - providers: [ - {provide: RepositoryItem, useValue: item}, - {provide: 'device', useValue: this.device}, - ], - }), - }); - const component = modalRef.componentInstance as DetailsComponent; - component.parent = this; - } - private handleInstallationError(name: string, e: Error) { MessageDialogComponent.open(this.modalService, { title: `Failed to install ${name}`, diff --git a/src/app/apps/installed/installed.component.html b/src/app/apps/installed/installed.component.html index 62aa3c8e..b7c25df6 100644 --- a/src/app/apps/installed/installed.component.html +++ b/src/app/apps/installed/installed.component.html @@ -1,35 +1,49 @@ -@let installed = installed$ | async; -@if (installedError) { - - -} @else if (installed) { -
    - @for (pkg of installed; track pkg.id) { -
  • -
    - - -
    -
    {{ pkg.title }}
    -
    - v{{ pkg.version }} - @let rpkg = repoPackages && repoPackages[pkg.id]; - @if (rpkg && rpkg.manifest?.hasUpdate(pkg.version)) { -  › v{{ rpkg.manifest?.version }} - } +
    + +
    + +
    +
    +
    + @let installed = installed$ | async; + @if (installedError) { + + + } @else if (installed) { +
      + @for (pkg of installed; track pkg.id) { +
    • +
      + + +
      +
      {{ pkg.title }}
      +
      + v{{ pkg.version }} + @let rpkg = repoPackages && repoPackages[pkg.id]; + @if (rpkg && rpkg.manifest?.hasUpdate(pkg.version)) { +  › v{{ rpkg.manifest?.version }} + } +
      -
    - @if (repoPackages?.[pkg.id]?.manifest?.hasUpdate(pkg.version)) { - - } -
  • - } -
-} @else { - -} + @if (repoPackages?.[pkg.id]?.manifest?.hasUpdate(pkg.version)) { + + } + + } + + } @else { + + } + diff --git a/src/app/apps/installed/installed.component.ts b/src/app/apps/installed/installed.component.ts index 82463024..01af434a 100644 --- a/src/app/apps/installed/installed.component.ts +++ b/src/app/apps/installed/installed.component.ts @@ -1,49 +1,37 @@ -import {Component, Host, Input, OnDestroy} from '@angular/core'; +import {Component, Host, OnDestroy, OnInit} from '@angular/core'; import {AppsComponent} from '../apps.component'; import {Device, PackageInfo} from "../../types"; import {Observable, Subscription} from "rxjs"; -import {AppsRepoService, RepositoryItem} from "../../core/services"; +import {AppManagerService, DeviceManagerService, RepositoryItem} from "../../core/services"; +import {fromPromise} from "rxjs/internal/observable/innerFrom"; @Component({ selector: 'app-installed', templateUrl: './installed.component.html', styleUrls: ['./installed.component.scss'] }) -export class InstalledComponent implements OnDestroy { +export class InstalledComponent implements OnInit, OnDestroy { - @Input() device: Device | null = null; + devices$?: Observable; + installed$: Observable | undefined; installedError?: Error; repoPackages?: Record; private subscription?: Subscription; - private installedField?: Observable; - constructor(@Host() public parent: AppsComponent, private appsRepo: AppsRepoService) { + constructor(@Host() public parent: AppsComponent, public deviceManager: DeviceManagerService, + private appManager: AppManagerService) { } - @Input() - set installed$(value: Observable | undefined) { - this.subscription?.unsubscribe(); - this.subscription = value?.subscribe({ - next: (pkgs) => { - this.installedError = undefined; - - const strings: string[] = pkgs?.map((pkg) => pkg.id) ?? []; - this.appsRepo.showApps(...strings).then(apps => this.repoPackages = apps); - }, - error: (error) => { - console.log('installed apps', error); - return this.installedError = error; - } + ngOnInit(): void { + this.devices$ = this.deviceManager.devices$; + this.subscription = this.devices$.subscribe(devices => { + this.device = devices?.find(d => d.default) ?? null; + this.loadPackages(); }); - this.installedField = value; - } - - get installed$(): Observable | undefined { - return this.installedField; } ngOnDestroy(): void { @@ -51,7 +39,9 @@ export class InstalledComponent implements OnDestroy { } loadPackages(): void { + const device = this.device; + if (!device) return; this.installedError = undefined; - this.parent.loadPackages(); + this.installed$ = fromPromise(this.appManager.load(device)); } } diff --git a/src/styles/shared.scss b/src/styles/shared.scss index ac8bad63..b67c3281 100644 --- a/src/styles/shared.scss +++ b/src/styles/shared.scss @@ -14,6 +14,7 @@ .storage-info-bar { width: 25%; + height: 15px; max-width: 110px; } From 941b305d893452a263b87ce38dab6160893ddcea Mon Sep 17 00:00:00 2001 From: Mariotaku Date: Fri, 23 May 2025 01:01:15 +0900 Subject: [PATCH 2/9] installed app details wip --- src/app/apps/channel/channel.component.html | 52 ++++++++++--------- .../installed/details/details.component.html | 29 +++++++++++ .../installed/details/details.component.scss | 8 +++ .../details/details.component.spec.ts | 23 ++++++++ .../installed/details/details.component.ts | 35 +++++++++++++ .../apps/installed/installed.component.html | 2 +- src/app/apps/installed/installed.component.ts | 19 ++++++- src/app/core/services/app-manager.service.ts | 44 +++++++++++++++- src/app/files/files.module.ts | 24 +++++---- src/app/shared/pipes/filesize.pipe.ts | 7 +-- src/app/shared/shared.module.ts | 4 +- src/app/types/index.ts | 5 ++ 12 files changed, 206 insertions(+), 46 deletions(-) create mode 100644 src/app/apps/installed/details/details.component.html create mode 100644 src/app/apps/installed/details/details.component.scss create mode 100644 src/app/apps/installed/details/details.component.spec.ts create mode 100644 src/app/apps/installed/details/details.component.ts diff --git a/src/app/apps/channel/channel.component.html b/src/app/apps/channel/channel.component.html index 9e6fd842..358680b2 100644 --- a/src/app/apps/channel/channel.component.html +++ b/src/app/apps/channel/channel.component.html @@ -1,26 +1,28 @@ -@let repoPage = repoPage$ | async; -@if (repoPage) { -
    -
  • - @let manifest = item.manifest; - @if (manifest) { -
    - -
    -
    {{ item.title }}
    -
    {{ manifest.appDescription }}
    +
    + @let repoPage = repoPage$ | async; + @if (repoPage) { +
      +
    • + @let manifest = item.manifest; + @if (manifest) { +
      + +
      +
      {{ item.title }}
      +
      {{ manifest.appDescription }}
      +
      -
    - - } -
  • -
-
- - -
-} @else { - -} + + } + + +
+ + +
+ } @else { + + } + diff --git a/src/app/apps/installed/details/details.component.html b/src/app/apps/installed/details/details.component.html new file mode 100644 index 00000000..3bc8cae9 --- /dev/null +++ b/src/app/apps/installed/details/details.component.html @@ -0,0 +1,29 @@ + diff --git a/src/app/apps/installed/details/details.component.scss b/src/app/apps/installed/details/details.component.scss new file mode 100644 index 00000000..d20d912e --- /dev/null +++ b/src/app/apps/installed/details/details.component.scss @@ -0,0 +1,8 @@ +.app-header { + min-height: 64px; +} + +.app-icon { + width: 64px; + height: 64px; +} diff --git a/src/app/apps/installed/details/details.component.spec.ts b/src/app/apps/installed/details/details.component.spec.ts new file mode 100644 index 00000000..82e1a058 --- /dev/null +++ b/src/app/apps/installed/details/details.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DetailsComponent } from './details.component'; + +describe('DetailsComponent', () => { + let component: DetailsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DetailsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/apps/installed/details/details.component.ts b/src/app/apps/installed/details/details.component.ts new file mode 100644 index 00000000..35694908 --- /dev/null +++ b/src/app/apps/installed/details/details.component.ts @@ -0,0 +1,35 @@ +import {Component, Inject} from '@angular/core'; +import {AsyncResult, Device, PackageInfo} from "../../../types"; +import {AppManagerService, PackageDiskUsage} from "../../../core/services"; +import {fromPromise} from "rxjs/internal/observable/innerFrom"; +import {Observable} from "rxjs"; +import {AsyncPipe} from "@angular/common"; +import {SharedModule} from "../../../shared/shared.module"; +import {FilesizePipe} from "../../../shared/pipes/filesize.pipe"; +import {FileSizeOptions} from "filesize"; + +@Component({ + selector: 'app-details', + standalone: true, + imports: [ + AsyncPipe, + SharedModule, + FilesizePipe + ], + templateUrl: './details.component.html', + styleUrl: './details.component.scss' +}) +export class DetailsComponent { + + diskUsage$: Observable>; + sizeOptions: FileSizeOptions = {base: 2, standard: 'jedec'}; + + constructor( + @Inject('device') public device: Device, + @Inject('package') public pkg: PackageInfo, + appManager: AppManagerService + ) { + this.diskUsage$ = fromPromise(appManager.appDiskUsage(device, pkg.folderPath) + .then((result) => ({result})).catch((error) => ({error}))); + } +} diff --git a/src/app/apps/installed/installed.component.html b/src/app/apps/installed/installed.component.html index b7c25df6..923a476c 100644 --- a/src/app/apps/installed/installed.component.html +++ b/src/app/apps/installed/installed.component.html @@ -19,7 +19,7 @@ } @else if (installed) {
    @for (pkg of installed; track pkg.id) { -
  • +
  • diff --git a/src/app/apps/installed/installed.component.ts b/src/app/apps/installed/installed.component.ts index 01af434a..531a7167 100644 --- a/src/app/apps/installed/installed.component.ts +++ b/src/app/apps/installed/installed.component.ts @@ -1,9 +1,11 @@ -import {Component, Host, OnDestroy, OnInit} from '@angular/core'; +import {Component, Host, Injector, OnDestroy, OnInit} from '@angular/core'; import {AppsComponent} from '../apps.component'; import {Device, PackageInfo} from "../../types"; import {Observable, Subscription} from "rxjs"; import {AppManagerService, DeviceManagerService, RepositoryItem} from "../../core/services"; import {fromPromise} from "rxjs/internal/observable/innerFrom"; +import {DetailsComponent as InstalledDetailsComponent} from "./details/details.component"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; @Component({ selector: 'app-installed', @@ -23,7 +25,7 @@ export class InstalledComponent implements OnInit, OnDestroy { private subscription?: Subscription; constructor(@Host() public parent: AppsComponent, public deviceManager: DeviceManagerService, - private appManager: AppManagerService) { + private appManager: AppManagerService, private modals: NgbModal) { } ngOnInit(): void { @@ -44,4 +46,17 @@ export class InstalledComponent implements OnInit, OnDestroy { this.installedError = undefined; this.installed$ = fromPromise(this.appManager.load(device)); } + + openDetails(pkg: PackageInfo) { + this.modals.open(InstalledDetailsComponent, { + size: 'lg', + scrollable: true, + injector: Injector.create({ + providers: [ + {provide: 'package', useValue: pkg}, + {provide: 'device', useValue: this.device}, + ] + }) + }); + } } diff --git a/src/app/core/services/app-manager.service.ts b/src/app/core/services/app-manager.service.ts index 4148307f..9084abc7 100644 --- a/src/app/core/services/app-manager.service.ts +++ b/src/app/core/services/app-manager.service.ts @@ -1,6 +1,6 @@ import {Injectable} from '@angular/core'; import {BehaviorSubject, catchError, firstValueFrom, lastValueFrom, mergeMap, noop, Observable, Subject} from 'rxjs'; -import {Device, PackageInfo, RawPackageInfo} from '../../types'; +import {Device, DeviceLike, PackageInfo, RawPackageInfo} from '../../types'; import { LunaResponse, LunaResponseError, @@ -279,6 +279,37 @@ export class AppManagerService { ); } + async appDiskUsage(device: DeviceLike, appDir: string): Promise { + const appIndex = appDir.indexOf('/usr/palm/applications/'); + if (appIndex < 0) { + throw new Error('Not accepted appDir format: ' + appDir); + } + const installBase = appDir.substring(0, appIndex); + const appId = appDir.substring(appIndex + 23); + const pkgInfo = JSON.parse(await this.file.read(device, `${installBase}/usr/palm/packages/${appId}/packageinfo.json`, undefined, 'utf-8')) as PkgInfo; + const dirs = [appDir]; + pkgInfo.services?.forEach(service => { + const serviceDir = `${installBase}/usr/palm/services/${service}`; + dirs.push(serviceDir); + }); + return await this.cmd.exec(device, `xargs du -d 0 -c`, 'utf-8', dirs.join('\n')) + .then(stdout => (Object.fromEntries(stdout.split('\n').map(line => line.match(/(\d+)\t(.+)/)) + .map((match): [keyof PackageDiskUsage, number] | null => { + if (!match) { + return null; + } + const size = parseInt(match[1] ?? '0'); + const path = match[2] ?? ''; + if (path === 'total') { + return ['total', size]; + } else if (path.includes('/usr/palm/applications')) { + return ['application', size]; + } else if (path.includes('/usr/palm/services')) { + return [path.substring(path.lastIndexOf('/')), size]; + } + return null; + }).filter(v => v) as Iterable<[keyof PackageDiskUsage, number]>) as unknown as PackageDiskUsage)); + } } function mapAppinstalldResponse(v: LunaResponse, expectResult: string | RegExp): boolean { @@ -301,6 +332,10 @@ function mapAppinstalldResponse(v: LunaResponse, expectResult: string | RegExp): return false; } +interface PkgInfo { + services?: string[]; +} + export interface InstallProgressHandler { (progress?: number, statusText?: string): void; } @@ -314,3 +349,10 @@ export class InstallError extends Error { return new InstallError('Can\'t install because of insufficient space', details); } } + +export interface PackageDiskUsage { + application: number; + total: number; + + [service: string]: number; +} diff --git a/src/app/files/files.module.ts b/src/app/files/files.module.ts index 53296b53..9813047f 100644 --- a/src/app/files/files.module.ts +++ b/src/app/files/files.module.ts @@ -15,6 +15,7 @@ import {FilesTableComponent} from './files-table/files-table.component'; import {SharedModule} from "../shared/shared.module"; import {CreateDirectoryMessageComponent} from './create-directory-message/create-directory-message.component'; import {ReactiveFormsModule} from "@angular/forms"; +import {FilesizePipe} from "../shared/pipes/filesize.pipe"; @NgModule({ @@ -24,17 +25,18 @@ import {ReactiveFormsModule} from "@angular/forms"; FilesTableComponent, CreateDirectoryMessageComponent, ], - imports: [ - CommonModule, - FilesRoutingModule, - NgbTooltipModule, - SharedModule, - NgbDropdown, - NgbDropdownItem, - NgbDropdownMenu, - NgbDropdownToggle, - ReactiveFormsModule, - ] + imports: [ + CommonModule, + FilesRoutingModule, + NgbTooltipModule, + SharedModule, + NgbDropdown, + NgbDropdownItem, + NgbDropdownMenu, + NgbDropdownToggle, + ReactiveFormsModule, + FilesizePipe, + ] }) export class FilesModule { } diff --git a/src/app/shared/pipes/filesize.pipe.ts b/src/app/shared/pipes/filesize.pipe.ts index dd4e7b05..34bc576b 100644 --- a/src/app/shared/pipes/filesize.pipe.ts +++ b/src/app/shared/pipes/filesize.pipe.ts @@ -1,12 +1,13 @@ import {Pipe, PipeTransform} from "@angular/core"; -import {filesize, FileSizeOptionsBase} from 'filesize'; +import {filesize, FileSizeOptions} from 'filesize'; @Pipe({ - name: 'filesize' + name: 'filesize', + standalone: true }) export class FilesizePipe implements PipeTransform { - transform(bytes: number, options: Partial): string { + transform(bytes: number, options: Partial): string { return filesize(bytes, {output: "string", ...options}); } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index a95a83d4..45be2a06 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -17,7 +17,6 @@ import {FilesizePipe} from "./pipes/filesize.pipe"; declarations: [ PageNotFoundComponent, TrustUriPipe, - FilesizePipe, MessageDialogComponent, ProgressDialogComponent, MessageTraceComponent, @@ -26,11 +25,10 @@ import {FilesizePipe} from "./pipes/filesize.pipe"; StatStorageInfoComponent, ], imports: [CommonModule, FormsModule, NgbModule, - ExternalLinkDirective], + ExternalLinkDirective, FilesizePipe], exports: [ PageNotFoundComponent, TrustUriPipe, - FilesizePipe, MessageDialogComponent, ProgressDialogComponent, MessageTraceComponent, diff --git a/src/app/types/index.ts b/src/app/types/index.ts index 76b77faf..c908fe04 100644 --- a/src/app/types/index.ts +++ b/src/app/types/index.ts @@ -1,3 +1,8 @@ export * from './device-manager'; export * from './file-session'; export * from './device'; + +export interface AsyncResult { + result?: T; + error?: E; +} From e4d3750a9893eb145a4267131571237549d18279 Mon Sep 17 00:00:00 2001 From: Mariotaku Date: Wed, 2 Jul 2025 22:31:30 +0900 Subject: [PATCH 3/9] channel app details wip --- src/app/apps/channel/channel.component.html | 4 +- src/app/apps/channel/channel.component.ts | 51 +++++++++++++-------- src/app/apps/details/details.component.ts | 17 +++---- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/app/apps/channel/channel.component.html b/src/app/apps/channel/channel.component.html index 358680b2..e033e8ac 100644 --- a/src/app/apps/channel/channel.component.html +++ b/src/app/apps/channel/channel.component.html @@ -13,7 +13,9 @@
    {{ manifest.appDescription }}
    - + }
diff --git a/src/app/apps/channel/channel.component.ts b/src/app/apps/channel/channel.component.ts index 41e9dcf0..a979d5c4 100644 --- a/src/app/apps/channel/channel.component.ts +++ b/src/app/apps/channel/channel.component.ts @@ -1,32 +1,45 @@ -import {Component, Host, Input, OnInit} from '@angular/core'; +import {Component, Host, Injector, Input, OnInit} from '@angular/core'; import {Observable} from 'rxjs'; -import {AppsRepoService, RepositoryPage} from '../../core/services'; +import {AppsRepoService, RepositoryItem, RepositoryPage} from '../../core/services'; import {AppsComponent} from '../apps.component'; import {RawPackageInfo} from "../../types"; +import {DetailsComponent} from "../details/details.component"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; @Component({ - selector: 'app-channel', - templateUrl: './channel.component.html', - styleUrls: ['./channel.component.scss'] + selector: 'app-channel', + templateUrl: './channel.component.html', + styleUrls: ['./channel.component.scss'] }) export class ChannelComponent implements OnInit { - page = 1; - repoPage$?: Observable; + page = 1; + repoPage$?: Observable; - @Input() - installed?: Record; + @Input() + installed?: Record; - constructor( - @Host() public parent: AppsComponent, - private appsRepo: AppsRepoService) { - } + constructor( + @Host() public parent: AppsComponent, + private appsRepo: AppsRepoService, + private modals: NgbModal) { + } - ngOnInit(): void { - this.loadPage(1); - } + ngOnInit(): void { + this.loadPage(1); + } - loadPage(page: number): void { - this.repoPage$ = this.appsRepo.allApps$(page); - } + loadPage(page: number): void { + this.repoPage$ = this.appsRepo.allApps$(page); + } + + openDetails(item: RepositoryItem) { + this.modals.open(DetailsComponent, { + injector: Injector.create({ + providers: [ + {provide: RepositoryItem, useValue: item}, + ] + }) + }); + } } diff --git a/src/app/apps/details/details.component.ts b/src/app/apps/details/details.component.ts index cca8aae3..381ad79f 100644 --- a/src/app/apps/details/details.component.ts +++ b/src/app/apps/details/details.component.ts @@ -1,7 +1,7 @@ -import {Component, ElementRef, Inject, OnDestroy, OnInit, Renderer2, ViewChild, ViewEncapsulation} from '@angular/core'; +import {Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild, ViewEncapsulation} from '@angular/core'; import {AppManagerService, IncompatibleReason, PackageManifest, RepositoryItem} from "../../core/services"; import {noop, Observable, of} from "rxjs"; -import {AsyncPipe, NgForOf, NgIf, NgOptimizedImage, NgSwitch, NgSwitchCase, NgSwitchDefault} from "@angular/common"; +import {AsyncPipe, NgForOf, NgIf, NgOptimizedImage, NgSwitch, NgSwitchCase} from "@angular/common"; import {open as openPath} from "@tauri-apps/plugin-shell"; import { NgbActiveModal, @@ -11,7 +11,7 @@ import { NgbDropdownToggle } from "@ng-bootstrap/ng-bootstrap"; import {SharedModule} from "../../shared/shared.module"; -import {Device, PackageInfo} from "../../types"; +import {PackageInfo} from "../../types"; import {fromPromise} from "rxjs/internal/observable/innerFrom"; import {AppsComponent} from "../apps.component"; import {ExternalLinkDirective} from "../../shared/directives"; @@ -29,9 +29,7 @@ import {ExternalLinkDirective} from "../../shared/directives"; NgbDropdownMenu, NgbDropdownToggle, SharedModule, - NgSwitch, - NgSwitchDefault, - NgForOf, + NgSwitch,NgForOf, ExternalLinkDirective ], templateUrl: './details.component.html', @@ -43,7 +41,7 @@ export class DetailsComponent implements OnInit, OnDestroy { fullDescriptionHtml$: Observable; installedInfo$?: Observable; - incompatible$: Observable; + incompatible$!: Observable; @ViewChild('fullDescription', {static: true}) fullDescription!: ElementRef; @@ -56,12 +54,11 @@ export class DetailsComponent implements OnInit, OnDestroy { constructor( public modal: NgbActiveModal, public item: RepositoryItem, - @Inject('device') public device: Device, private appManager: AppManagerService, private renderer2: Renderer2 ) { this.manifest = item.manifest!; - this.incompatible$ = fromPromise(this.appManager.checkIncompatibility(device, item)); + // this.incompatible$ = fromPromise(this.appManager.checkIncompatibility(device, item)); this.fullDescriptionHtml$ = item.fullDescriptionUrl ? fromPromise(fetch(item.fullDescriptionUrl) .then(resp => resp.text())) : of(''); this.reloadInstalledInfo(); @@ -89,6 +86,6 @@ export class DetailsComponent implements OnInit, OnDestroy { } private reloadInstalledInfo(): void { - this.installedInfo$ = fromPromise(this.appManager.info(this.device, this.item.id)); + // this.installedInfo$ = fromPromise(this.appManager.info(this.device, this.item.id)); } } From cab5c7b5978584bd338fccedb14938c8ec39c4aa Mon Sep 17 00:00:00 2001 From: Mariotaku Date: Fri, 22 May 2026 21:42:04 +0900 Subject: [PATCH 4/9] finish apps section redesign - lift device selection up from InstalledComponent to AppsComponent so install, launch, uninstall and update actions all see a real device - populate repoPackages in InstalledComponent so the Update button on the installed list and the Update row in the installed-details modal render when a newer version is available - wire the installed-details modal: inject parent + repoPackage, add Launch / Uninstall / Update click handlers, close modal on success, add header close button - wire the channel-details modal: inject device + parent, uncomment incompatible$ and reloadInstalledInfo so the dialog shows compatibility status and the installed version - add drag-and-drop .ipk install on desktop via the Tauri webview drag-drop event and a drop overlay; android/ios are skipped - add per-install target device selector in the channel-details modal when multiple devices are registered; AppsComponent.installPackage now accepts an optional device override - expand installed-details spec with launch / uninstall / hasUpdate coverage Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/apps/apps.component.html | 26 ++++- src/app/apps/apps.component.scss | 19 ++++ src/app/apps/apps.component.ts | 59 +++++++++-- src/app/apps/channel/channel.component.ts | 3 + src/app/apps/details/details.component.html | 14 ++- src/app/apps/details/details.component.ts | 41 ++++++-- .../installed/details/details.component.html | 18 +++- .../details/details.component.spec.ts | 99 +++++++++++++++---- .../installed/details/details.component.ts | 31 +++++- .../apps/installed/installed.component.html | 13 --- src/app/apps/installed/installed.component.ts | 46 +++++---- 11 files changed, 289 insertions(+), 80 deletions(-) diff --git a/src/app/apps/apps.component.html b/src/app/apps/apps.component.html index f4a99542..41994352 100644 --- a/src/app/apps/apps.component.html +++ b/src/app/apps/apps.component.html @@ -1,11 +1,12 @@ -
+
+ @if (dragOver) { +
+
+ Drop .ipk to install +
+
+ }
diff --git a/src/app/apps/apps.component.scss b/src/app/apps/apps.component.scss index e69de29b..738ac7dc 100644 --- a/src/app/apps/apps.component.scss +++ b/src/app/apps/apps.component.scss @@ -0,0 +1,19 @@ +.drop-overlay { + position: absolute; + inset: 0; + background: rgba(13, 110, 253, 0.15); + border: 3px dashed rgba(13, 110, 253, 0.75); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 1050; +} + +.drop-overlay-inner { + padding: 1rem 1.5rem; + background: var(--bs-body-bg); + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + font-size: 1.25rem; +} diff --git a/src/app/apps/apps.component.ts b/src/app/apps/apps.component.ts index 6592ce2f..554db1e2 100644 --- a/src/app/apps/apps.component.ts +++ b/src/app/apps/apps.component.ts @@ -1,16 +1,20 @@ -import {Component, OnDestroy, OnInit, ViewChild} from '@angular/core'; +import {Component, NgZone, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; -import {noop, Subscription} from 'rxjs'; +import {noop, Observable, Subscription} from 'rxjs'; import {Device, RawPackageInfo} from '../types'; -import {AppManagerService, RepositoryItem} from '../core/services'; +import {AppManagerService, DeviceManagerService, RepositoryItem} from '../core/services'; import {MessageDialogComponent} from '../shared/components/message-dialog/message-dialog.component'; import {ProgressDialogComponent} from '../shared/components/progress-dialog/progress-dialog.component'; import {open as showOpenDialog} from '@tauri-apps/plugin-dialog'; import {basename, downloadDir} from "@tauri-apps/api/path"; +import * as os from "@tauri-apps/plugin-os"; +import {getCurrentWebview} from "@tauri-apps/api/webview"; import {APP_ID_HBCHANNEL} from "../shared/constants"; import {HbchannelRemoveComponent} from "./hbchannel-remove/hbchannel-remove.component"; import {StatStorageInfoComponent} from "../shared/components/stat-storage-info/stat-storage-info.component"; +type UnlistenFn = () => void; + @Component({ selector: 'app-apps', templateUrl: './apps.component.html', @@ -19,26 +23,59 @@ import {StatStorageInfoComponent} from "../shared/components/stat-storage-info/s export class AppsComponent implements OnInit, OnDestroy { device: Device | null = null; + devices$?: Observable; tabId: string = 'installed'; + dragOver = false; @ViewChild('storageInfo') storageInfo?: StatStorageInfoComponent; private deviceSubscription?: Subscription; - private packagesSubscription?: Subscription; + private unlistenDragDrop?: UnlistenFn; constructor( private modalService: NgbModal, private appManager: AppManagerService, + public deviceManager: DeviceManagerService, + private zone: NgZone, ) { } ngOnInit(): void { + this.devices$ = this.deviceManager.devices$; + this.deviceSubscription = this.devices$.subscribe(devices => { + this.device = devices?.find(d => d.default) ?? null; + }); + this.setupDragDrop().catch(e => console.warn('Drag-drop listener failed:', e)); } ngOnDestroy(): void { this.deviceSubscription?.unsubscribe(); - this.packagesSubscription?.unsubscribe(); - this.packagesSubscription = undefined; + this.unlistenDragDrop?.(); + } + + private async setupDragDrop(): Promise { + if (os.type() === 'android' || os.type() === 'ios') return; + const webview = getCurrentWebview(); + this.unlistenDragDrop = await webview.onDragDropEvent(event => { + this.zone.run(() => { + switch (event.payload.type) { + case 'over': + case 'enter': + this.dragOver = true; + break; + case 'leave': + this.dragOver = false; + break; + case 'drop': + this.dragOver = false; + const ipks = event.payload.paths.filter(p => p.toLowerCase().endsWith('.ipk')); + for (const path of ipks) { + this.installFromPath(path).catch(noop); + } + break; + } + }); + }); } async openInstallChooser(): Promise { @@ -51,11 +88,17 @@ export class AppsComponent implements OnInit, OnDestroy { if (!path) { return; } + await this.installFromPath(path); + } + + private async installFromPath(path: string): Promise { + if (!this.device) return; const progress = ProgressDialogComponent.open(this.modalService); const component = progress.componentInstance as ProgressDialogComponent; try { await this.appManager.installByPath(this.device, path, (progress, statusText) => component.update(statusText, progress)); + this.storageInfo?.refresh(); } catch (e) { console.warn(e); this.handleInstallationError(await basename(path), e as Error); @@ -107,8 +150,8 @@ export class AppsComponent implements OnInit, OnDestroy { } } - async installPackage(item: RepositoryItem, channel: 'stable' | 'beta' = 'stable'): Promise { - const device = this.device; + async installPackage(item: RepositoryItem, channel: 'stable' | 'beta' = 'stable', deviceOverride?: Device): Promise { + const device = deviceOverride ?? this.device; if (!device) return false; const progress = ProgressDialogComponent.open(this.modalService); try { diff --git a/src/app/apps/channel/channel.component.ts b/src/app/apps/channel/channel.component.ts index a979d5c4..a61162fb 100644 --- a/src/app/apps/channel/channel.component.ts +++ b/src/app/apps/channel/channel.component.ts @@ -34,10 +34,13 @@ export class ChannelComponent implements OnInit { } openDetails(item: RepositoryItem) { + if (!this.parent.device) return; this.modals.open(DetailsComponent, { injector: Injector.create({ providers: [ {provide: RepositoryItem, useValue: item}, + {provide: 'device', useValue: this.parent.device}, + {provide: 'parent', useValue: this.parent}, ] }) }); diff --git a/src/app/apps/details/details.component.html b/src/app/apps/details/details.component.html index eb3fa1e7..2e52663e 100644 --- a/src/app/apps/details/details.component.html +++ b/src/app/apps/details/details.component.html @@ -19,11 +19,23 @@

{{ item.title }}

+ @let allDevices = devices$ | async; + @if (allDevices && allDevices.length > 1) { + + }
@switch (installed && manifest.hasUpdate(installed.version)) { @case (false) { - } diff --git a/src/app/apps/details/details.component.ts b/src/app/apps/details/details.component.ts index 381ad79f..8b0bc2e1 100644 --- a/src/app/apps/details/details.component.ts +++ b/src/app/apps/details/details.component.ts @@ -1,5 +1,5 @@ -import {Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild, ViewEncapsulation} from '@angular/core'; -import {AppManagerService, IncompatibleReason, PackageManifest, RepositoryItem} from "../../core/services"; +import {Component, ElementRef, Inject, OnDestroy, OnInit, Renderer2, ViewChild, ViewEncapsulation} from '@angular/core'; +import {AppManagerService, DeviceManagerService, IncompatibleReason, PackageManifest, RepositoryItem} from "../../core/services"; import {noop, Observable, of} from "rxjs"; import {AsyncPipe, NgForOf, NgIf, NgOptimizedImage, NgSwitch, NgSwitchCase} from "@angular/common"; import {open as openPath} from "@tauri-apps/plugin-shell"; @@ -11,7 +11,7 @@ import { NgbDropdownToggle } from "@ng-bootstrap/ng-bootstrap"; import {SharedModule} from "../../shared/shared.module"; -import {PackageInfo} from "../../types"; +import {Device, PackageInfo} from "../../types"; import {fromPromise} from "rxjs/internal/observable/innerFrom"; import {AppsComponent} from "../apps.component"; import {ExternalLinkDirective} from "../../shared/directives"; @@ -42,25 +42,41 @@ export class DetailsComponent implements OnInit, OnDestroy { fullDescriptionHtml$: Observable; installedInfo$?: Observable; incompatible$!: Observable; + devices$: Observable; + selectedDevice: Device; @ViewChild('fullDescription', {static: true}) fullDescription!: ElementRef; - parent?: AppsComponent; - - private unsubscribeClickListener!: () => void; constructor( public modal: NgbActiveModal, public item: RepositoryItem, + @Inject('device') public device: Device, + @Inject('parent') private parent: AppsComponent, private appManager: AppManagerService, + private deviceManager: DeviceManagerService, private renderer2: Renderer2 ) { this.manifest = item.manifest!; - // this.incompatible$ = fromPromise(this.appManager.checkIncompatibility(device, item)); + this.selectedDevice = device; + this.devices$ = this.deviceManager.devices$; this.fullDescriptionHtml$ = item.fullDescriptionUrl ? fromPromise(fetch(item.fullDescriptionUrl) .then(resp => resp.text())) : of(''); + this.refreshForDevice(); + } + + onDeviceChange(name: string): void { + let next: Device | undefined; + this.devices$.subscribe(devices => next = devices?.find(d => d.name === name)).unsubscribe(); + if (!next) return; + this.selectedDevice = next; + this.refreshForDevice(); + } + + private refreshForDevice(): void { + this.incompatible$ = fromPromise(this.appManager.checkIncompatibility(this.selectedDevice, this.item)); this.reloadInstalledInfo(); } @@ -77,15 +93,20 @@ export class DetailsComponent implements OnInit, OnDestroy { this.unsubscribeClickListener(); } + launchApp(id: string) { + this.parent.launchApp(id); + } + installPackage(item: RepositoryItem, channel: 'stable' | 'beta' = 'stable') { - this.parent?.installPackage(item, channel).then((installed) => installed && this.reloadInstalledInfo()); + this.parent.installPackage(item, channel, this.selectedDevice) + .then((installed) => installed && this.reloadInstalledInfo()); } removePackage(item: PackageInfo) { - this.parent?.removePackage(item).then((removed) => removed && this.reloadInstalledInfo()); + this.parent.removePackage(item).then((removed) => removed && this.reloadInstalledInfo()); } private reloadInstalledInfo(): void { - // this.installedInfo$ = fromPromise(this.appManager.info(this.device, this.item.id)); + this.installedInfo$ = fromPromise(this.appManager.info(this.selectedDevice, this.item.id)); } } diff --git a/src/app/apps/installed/details/details.component.html b/src/app/apps/installed/details/details.component.html index 3bc8cae9..63c28380 100644 --- a/src/app/apps/installed/details/details.component.html +++ b/src/app/apps/installed/details/details.component.html @@ -1,4 +1,7 @@ - +
+ @if (pkg.appDescription) { +

{{ pkg.appDescription }}

} - -
  • -
    Space used
    -
    +
    +
    App ID
    +
    {{ pkg.id }}
    + @if (pkg.vendor) { +
    Vendor
    +
    {{ pkg.vendor }}
    + } + @if (pkg.type) { +
    Type
    +
    {{ pkg.type }}
    + } +
    Path
    +
    {{ pkg.folderPath }}
    +
    Disk usage
    +
    @let du = diskUsage$ | async; - @if (!du) { - Calculating... + @if (!du || (!du.result && !du.error)) { + Calculating… } @else if (du.error) { - Failed to calculate + Unavailable } @else if (du.result) { {{ du.result.total * 1024 | filesize:sizeOptions }} } -
    -
  • - + + @if (sourceUrl) { +
    Website
    +
    + {{ sourceUrl }} +
    + } + +
    diff --git a/src/app/apps/installed/details/details.component.scss b/src/app/apps/installed/details/details.component.scss index d20d912e..d063d4a3 100644 --- a/src/app/apps/installed/details/details.component.scss +++ b/src/app/apps/installed/details/details.component.scss @@ -1,8 +1,10 @@ -.app-header { - min-height: 64px; +:host { + display: block; + height: 100%; } -.app-icon { +.detail-icon { width: 64px; height: 64px; + border-radius: 0.75rem; } diff --git a/src/app/apps/installed/details/details.component.spec.ts b/src/app/apps/installed/details/details.component.spec.ts index b85bfc67..f21123aa 100644 --- a/src/app/apps/installed/details/details.component.spec.ts +++ b/src/app/apps/installed/details/details.component.spec.ts @@ -1,6 +1,4 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; -import {EMPTY, of} from 'rxjs'; import {DetailsComponent} from './details.component'; import {AppManagerService, PackageManifest, RepositoryItem} from '../../../core/services'; @@ -11,7 +9,6 @@ describe('InstalledDetailsComponent', () => { let component: DetailsComponent; let fixture: ComponentFixture; let parentSpy: jasmine.SpyObj; - let modalSpy: jasmine.SpyObj; const device: Device = { name: 'test', host: '192.168.1.1', port: 22, username: 'prisoner', @@ -27,7 +24,6 @@ describe('InstalledDetailsComponent', () => { parentSpy = jasmine.createSpyObj('AppsComponent', ['launchApp', 'removePackage', 'installPackage']); parentSpy.removePackage.and.resolveTo(true); parentSpy.installPackage.and.resolveTo(true); - modalSpy = jasmine.createSpyObj('NgbActiveModal', ['close', 'dismiss']); const appManagerStub = { appDiskUsage: () => Promise.reject(new Error('not under test')), } as Partial; @@ -35,17 +31,20 @@ describe('InstalledDetailsComponent', () => { TestBed.configureTestingModule({ imports: [DetailsComponent], providers: [ - {provide: 'device', useValue: device}, - {provide: 'package', useValue: pkg}, - {provide: 'parent', useValue: parentSpy}, - {provide: 'repoPackage', useValue: repoPackage}, - {provide: NgbActiveModal, useValue: modalSpy}, {provide: AppManagerService, useValue: appManagerStub}, ], }); fixture = TestBed.createComponent(DetailsComponent); component = fixture.componentInstance; + component.pkg = pkg; + component.device = device; + component.parent = parentSpy; + component.repoPackage = repoPackage; + component.ngOnChanges({ + pkg: {currentValue: pkg, previousValue: undefined, firstChange: true, isFirstChange: () => true}, + device: {currentValue: device, previousValue: undefined, firstChange: true, isFirstChange: () => true}, + }); fixture.detectChanges(); } @@ -54,25 +53,32 @@ describe('InstalledDetailsComponent', () => { expect(component).toBeTruthy(); }); - it('Launch calls parent.launchApp and closes the modal', () => { + it('Launch calls parent.launchApp', () => { setup(); component.launch(); expect(parentSpy.launchApp).toHaveBeenCalledWith(pkg.id); - expect(modalSpy.close).toHaveBeenCalled(); }); - it('Uninstall calls parent.removePackage and closes the modal on success', async () => { + it('Uninstall calls parent.removePackage with the package', async () => { setup(); - await component.uninstall(); + const removed = await component.uninstall(); expect(parentSpy.removePackage).toHaveBeenCalledWith(pkg); - expect(modalSpy.close).toHaveBeenCalled(); + expect(removed).toBeTrue(); }); - it('Uninstall keeps the modal open when removePackage returns false', async () => { - setup(); - parentSpy.removePackage.and.resolveTo(false); - await component.uninstall(); - expect(modalSpy.close).not.toHaveBeenCalled(); + it('Update calls parent.installPackage when a repo package is available', async () => { + const newer = new RepositoryItem({manifest: new PackageManifest({version: '2.0.0'})}, ''); + setup(newer); + const installed = await component.update(); + expect(parentSpy.installPackage).toHaveBeenCalledWith(newer); + expect(installed).toBeTrue(); + }); + + it('Update is a no-op without a repo package', async () => { + setup(null); + const installed = await component.update(); + expect(installed).toBeFalse(); + expect(parentSpy.installPackage).not.toHaveBeenCalled(); }); it('hasUpdate is false when no repo package is provided', () => { diff --git a/src/app/apps/installed/details/details.component.ts b/src/app/apps/installed/details/details.component.ts index 3319f721..0ae9fad5 100644 --- a/src/app/apps/installed/details/details.component.ts +++ b/src/app/apps/installed/details/details.component.ts @@ -1,17 +1,16 @@ -import {Component, Inject} from '@angular/core'; +import {Component, Input, OnChanges, SimpleChanges} from '@angular/core'; import {AsyncResult, Device, PackageInfo} from "../../../types"; import {AppManagerService, PackageDiskUsage, RepositoryItem} from "../../../core/services"; import {fromPromise} from "rxjs/internal/observable/innerFrom"; -import {Observable} from "rxjs"; +import {Observable, of} from "rxjs"; import {AsyncPipe} from "@angular/common"; -import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; import {SharedModule} from "../../../shared/shared.module"; import {FilesizePipe} from "../../../shared/pipes/filesize.pipe"; import {FileSizeOptions} from "filesize"; import {AppsComponent} from "../../apps.component"; @Component({ - selector: 'app-details', + selector: 'app-installed-details', standalone: true, imports: [ AsyncPipe, @@ -21,21 +20,26 @@ import {AppsComponent} from "../../apps.component"; templateUrl: './details.component.html', styleUrl: './details.component.scss' }) -export class DetailsComponent { +export class DetailsComponent implements OnChanges { - diskUsage$: Observable>; + @Input() pkg!: PackageInfo; + @Input() device!: Device; + @Input() parent!: AppsComponent; + @Input() repoPackage: RepositoryItem | null = null; + + diskUsage$: Observable> = of({}); sizeOptions: FileSizeOptions = {base: 2, standard: 'jedec'}; - constructor( - @Inject('device') public device: Device, - @Inject('package') public pkg: PackageInfo, - @Inject('parent') private parent: AppsComponent, - @Inject('repoPackage') public repoPackage: RepositoryItem | null, - public modal: NgbActiveModal, - appManager: AppManagerService - ) { - this.diskUsage$ = fromPromise(appManager.appDiskUsage(device, pkg.folderPath) - .then((result) => ({result})).catch((error) => ({error}))); + constructor(private appManager: AppManagerService) { + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['pkg'] || changes['device']) { + this.diskUsage$ = this.pkg && this.device + ? fromPromise(this.appManager.appDiskUsage(this.device, this.pkg.folderPath) + .then((result) => ({result})).catch((error) => ({error}))) + : of({}); + } } get hasUpdate(): boolean { @@ -44,21 +48,18 @@ export class DetailsComponent { launch(): void { this.parent.launchApp(this.pkg.id); - this.modal.close(); } - async uninstall(): Promise { - const removed = await this.parent.removePackage(this.pkg); - if (removed) { - this.modal.close(); - } + uninstall(): Promise { + return this.parent.removePackage(this.pkg); } - async update(): Promise { - if (!this.repoPackage) return; - const installed = await this.parent.installPackage(this.repoPackage); - if (installed) { - this.modal.close(); - } + update(): Promise { + if (!this.repoPackage) return Promise.resolve(false); + return this.parent.installPackage(this.repoPackage); + } + + get sourceUrl(): string | undefined { + return this.repoPackage?.manifest?.sourceUrl; } } diff --git a/src/app/apps/installed/installed.component.html b/src/app/apps/installed/installed.component.html index f1f848c5..92ef34d9 100644 --- a/src/app/apps/installed/installed.component.html +++ b/src/app/apps/installed/installed.component.html @@ -1,39 +1,56 @@ -
    - @let installed = installed$ | async; - @if (installedError) { - - - } @else if (installed) { -
      - @for (pkg of installed; track pkg.id) { -
    • -
      - - -
      -
      {{ pkg.title }}
      -
      - v{{ pkg.version }} - @let rpkg = repoPackages && repoPackages[pkg.id]; - @if (rpkg && rpkg.manifest?.hasUpdate(pkg.version)) { -  › v{{ rpkg.manifest?.version }} - } +
      +
      - @if (repoPackages?.[pkg.id]?.manifest?.hasUpdate(pkg.version)) { - +
    • } - - } -
    + } + + } @else { + + } +
    + +
    + @if (selectedPkg && device) { + } @else { - +
    + +
    Select an installed app to see details
    +
    } -
    + +
    - @for (dev of allDevices; track dev.name) { - - } - - } - @switch (installed && manifest.hasUpdate(installed.version)) { - @case (false) { - - } - @case (true) { - - } - @default { - - } - } - @if (item.manifestBeta || installed) { -
    - - -
    + (Installed v{{ installed.version }}) }
    - - Website - + +
    Root required
    +
    Root is optional
    +
    + + +
    + +
    About
    +

    {{ item.shortDescription }}

    +
    + +
    +
    Links
    + + + {{ url }} + +
    -
    - + diff --git a/src/app/apps/details/details.component.scss b/src/app/apps/details/details.component.scss index 5e537a6b..f75fd044 100644 --- a/src/app/apps/details/details.component.scss +++ b/src/app/apps/details/details.component.scss @@ -1,12 +1,21 @@ +app-channel-app-details { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + .full-description { img { max-width: 100%; } } -.app-details-icon { - max-width: 25vw; - max-height: 25vw; - height: auto; - object-fit: contain; +.hero-icon { + border-radius: 0.75rem; + flex: 0 0 auto; +} + +.detail-footer { + flex: 0 0 auto; } diff --git a/src/app/apps/details/details.component.ts b/src/app/apps/details/details.component.ts index 8b0bc2e1..941560c5 100644 --- a/src/app/apps/details/details.component.ts +++ b/src/app/apps/details/details.component.ts @@ -4,7 +4,7 @@ import {noop, Observable, of} from "rxjs"; import {AsyncPipe, NgForOf, NgIf, NgOptimizedImage, NgSwitch, NgSwitchCase} from "@angular/common"; import {open as openPath} from "@tauri-apps/plugin-shell"; import { - NgbActiveModal, + NgbActiveOffcanvas, NgbDropdown, NgbDropdownItem, NgbDropdownMenu, @@ -51,7 +51,7 @@ export class DetailsComponent implements OnInit, OnDestroy { private unsubscribeClickListener!: () => void; constructor( - public modal: NgbActiveModal, + public offcanvas: NgbActiveOffcanvas, public item: RepositoryItem, @Inject('device') public device: Device, @Inject('parent') private parent: AppsComponent, diff --git a/src/styles.scss b/src/styles.scss index 124bd7a7..21b05453 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -40,3 +40,13 @@ app-apps .tab-content > .tab-pane.active { display: flex; flex-direction: column; } + +.offcanvas.app-detail-offcanvas { + --bs-offcanvas-width: 480px; +} + +@media (max-width: 575.98px) { + .offcanvas.app-detail-offcanvas { + --bs-offcanvas-width: 100%; + } +} From 214063aa24417c01e4be8bf72bd87219c018664e Mon Sep 17 00:00:00 2001 From: Mariotaku Date: Fri, 22 May 2026 23:52:14 +0900 Subject: [PATCH 9/9] restyle channel grid with featured row and corner state badges Channel tab: - featured section now renders a real hierarchy: one hero card spanning two rows alongside two stacked small cards, drawn from the first page items - all-apps grid switched from icon-on-top centered cards to wider icon-left cards (260 px min) with title, version and shortDescription on the right - state indicator moved to the top-right corner of each card as a Bootstrap badge (Installed / Update); the default Install state has no badge Channel app details (offcanvas): - hero shows installed-version line plus a stock Installed badge when applicable - root-required / root-optional rendered with a shield icon instead of bold inline text - About + Links section headings use text-body-secondary Installed details panel: - header gains an "Update available" badge when a newer repo version exists; the inline version arrow still indicates the target version - body groups content into About and Details sections with stock text-body-secondary subheaders Installed list row: - meta line uses text-body-secondary; Update badge stays as stock text-bg-warning Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/apps/channel/channel.component.html | 65 +++++++---- src/app/apps/channel/channel.component.scss | 109 +++++++++--------- src/app/apps/details/details.component.html | 15 ++- .../installed/details/details.component.html | 31 ++--- .../apps/installed/installed.component.html | 2 +- 5 files changed, 125 insertions(+), 97 deletions(-) diff --git a/src/app/apps/channel/channel.component.html b/src/app/apps/channel/channel.component.html index 04ed6448..5a669889 100644 --- a/src/app/apps/channel/channel.component.html +++ b/src/app/apps/channel/channel.component.html @@ -1,23 +1,38 @@
    @let repoPage = repoPage$ | async; @if (repoPage) { - @if (page === 1 && repoPage.packages.length > 0) { + @if (page === 1 && repoPage.packages.length > 2) { }
    -
    All apps
    +
    {{ page === 1 ? 'All apps' : ('Page ' + page) }}
    @for (item of repoPage.packages; track item.id) { @if (item.manifest) { } }
    -
    +
    diff --git a/src/app/apps/channel/channel.component.scss b/src/app/apps/channel/channel.component.scss index 8f565357..b6c64d03 100644 --- a/src/app/apps/channel/channel.component.scss +++ b/src/app/apps/channel/channel.component.scss @@ -5,95 +5,96 @@ overflow: auto; } -.featured-heading { - font-size: 0.75rem; - letter-spacing: 0.05em; +.featured-grid { + display: grid; + grid-template-columns: 2fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 0.75rem; } -.featured-grid { +.app-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; } -.featured-card { +.app-card { + position: relative; display: flex; flex-direction: row; - align-items: center; + align-items: flex-start; gap: 0.75rem; padding: 0.75rem; - background: var(--bs-tertiary-bg); + background: transparent; border: 1px solid var(--bs-border-color); - border-radius: 0.5rem; + border-radius: var(--bs-border-radius); text-align: left; - transition: transform 0.12s ease, box-shadow 0.12s ease; + transition: background 0.12s ease, border-color 0.12s ease; &:hover { - transform: translateY(-1px); - box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15); + background: var(--bs-tertiary-bg); + border-color: var(--bs-border-color-translucent); } } -.featured-icon { - flex: 0 0 auto; - border-radius: 0.5rem; -} - -.featured-text { - display: flex; +.app-card-hero { + grid-row: 1 / 3; flex-direction: column; - min-width: 0; -} + align-items: flex-start; + padding: 1rem; + gap: 0.75rem; -.featured-title { - font-weight: 600; - font-size: 1rem; -} + .app-card-icon { + width: 80px; + height: 80px; + } -.featured-desc { - font-size: 0.875rem; -} + .app-card-title { + font-size: 1.1rem; + } -.app-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); - gap: 1rem; + .app-card-desc { + -webkit-line-clamp: 3; + } } -.app-card { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.25rem; - padding: 0.75rem 0.5rem 1rem; - background: var(--bs-body-bg); - border: 1px solid var(--bs-border-color); +.app-card-icon { + width: 48px; + height: 48px; border-radius: 0.5rem; - text-align: center; - transition: transform 0.12s ease, box-shadow 0.12s ease; - - &:hover { - transform: translateY(-1px); - box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15); - } + flex: 0 0 auto; } -.app-card-icon { - border-radius: 0.75rem; - margin-bottom: 0.25rem; +.app-card-text { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; } .app-card-title { font-weight: 600; font-size: 0.95rem; - max-width: 100%; + line-height: 1.2; } .app-card-version { font-size: 0.8rem; + color: var(--bs-secondary-color); + margin-top: 0.15rem; +} + +.app-card-desc { + font-size: 0.85rem; + color: var(--bs-secondary-color); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -.app-card-pill { - margin-top: 0.5rem; +.app-card-tag { + position: absolute; + top: 0.5rem; + right: 0.5rem; } diff --git a/src/app/apps/details/details.component.html b/src/app/apps/details/details.component.html index a61fdbc4..d03328fd 100644 --- a/src/app/apps/details/details.component.html +++ b/src/app/apps/details/details.component.html @@ -8,15 +8,18 @@
    {{ item.title }}
    App icon

    {{ item.title }}

    -
    +
    v{{ manifest.version }} @if (installed) { - (Installed v{{ installed.version }}) + ยท installed v{{ installed.version }} }
    + @if (installed) { + Installed + } -
    Root required
    -
    Root is optional
    +
    Root required
    +
    Root optional
    @@ -31,12 +34,12 @@

    {{ item.title }}

    -
    About
    +
    About

    {{ item.shortDescription }}


    -
    Links
    +
    Links
    {{ url }} diff --git a/src/app/apps/installed/details/details.component.html b/src/app/apps/installed/details/details.component.html index e19f6e48..6dbae694 100644 --- a/src/app/apps/installed/details/details.component.html +++ b/src/app/apps/installed/details/details.component.html @@ -2,15 +2,18 @@