fix: Mobile style fixes and other small ui fixes

This commit is contained in:
2026-05-18 23:14:16 +02:00
parent afb64520ed
commit 94428ed170
32 changed files with 808 additions and 239 deletions

View File

@@ -9,6 +9,7 @@ import {
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { ViewportService } from '../../../../core/platform';
import {
VoiceActivityService,
VoiceConnectionFacade,
@@ -38,9 +39,11 @@ export class DirectCallService {
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService);
private readonly viewport = inject(ViewportService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
@@ -65,6 +68,15 @@ export class DirectCallService {
});
readonly currentSession = signal<DirectCallSession | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
readonly mobileOverlaySession = computed(() => {
const callId = this.mobileOverlayCallId();
if (!callId) {
return null;
}
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null;
});
constructor() {
this.delivery.directCallEvents$.subscribe((event) => {
@@ -92,6 +104,12 @@ export class DirectCallService {
this.audio.stop(AppSound.Call);
});
effect(() => {
if (this.mobileOverlayCallId() && !this.mobileOverlaySession()) {
this.mobileOverlayCallId.set(null);
}
});
}
sessionById(callId: string | null | undefined): DirectCallSession | null {
@@ -155,7 +173,7 @@ export class DirectCallService {
this.currentSession.set(session);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.router.navigate(['/call', session.callId]);
await this.openCallView(session.callId);
return session;
}
@@ -186,6 +204,24 @@ export class DirectCallService {
this.currentSession.set(session);
}
async openCallView(callId: string): Promise<void> {
if (this.viewport.isMobile()) {
await this.openMobileCallOverlay(callId);
return;
}
await this.openCallView(callId);
}
async openMobileCallOverlay(callId: string): Promise<void> {
await this.openCall(callId);
this.mobileOverlayCallId.set(callId);
}
closeMobileCallOverlay(): void {
this.mobileOverlayCallId.set(null);
}
async answerIncomingCall(callId: string): Promise<void> {
const session = this.sessionById(callId);

View File

@@ -6,17 +6,39 @@
appThemeNode="dmChatHeader"
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
>
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerUser()?.avatarUrl"
[status]="peerUser()?.status"
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
</div>
@if (peerUser()) {
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 pr-2 text-left transition-colors hover:bg-secondary/60 focus:outline-none focus:ring-2 focus:ring-primary/50"
[attr.aria-label]="'Open profile for ' + peerName()"
[title]="'Open profile for ' + peerName()"
(click)="openHeaderProfileCard($event)"
>
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerUser()?.avatarUrl"
[status]="peerUser()?.status"
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">Direct Message</p>
</div>
</button>
} @else {
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerUser()?.avatarUrl"
[status]="peerUser()?.status"
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
</div>
}
@if (showCallButton() && conversation()) {
<button
type="button"

View File

@@ -16,7 +16,11 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent, UserAvatarComponent } from '../../../../shared';
import {
BottomSheetComponent,
ProfileCardService,
UserAvatarComponent
} from '../../../../shared';
import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
@@ -82,6 +86,7 @@ export class DmChatComponent {
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
private readonly profileCard = inject(ProfileCardService);
private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
@@ -309,6 +314,17 @@ export class DmChatComponent {
}
}
openHeaderProfileCard(event: MouseEvent): void {
const user = this.peerUser();
if (!user) {
return;
}
event.stopPropagation();
this.profileCard.open(event.currentTarget as HTMLElement, user, { editable: false });
}
setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message);
}

View File

@@ -3,7 +3,7 @@
<div class="group/server relative flex w-full justify-center">
<button
type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground"
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground md:h-10 md:w-10"
title="Direct Messages"
aria-label="Direct Messages"
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
@@ -12,7 +12,7 @@
>
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
class="h-[18px] w-[18px] md:h-4 md:w-4"
/>
@if (directMessages.totalUnreadCount() > 0) {
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
@@ -24,7 +24,7 @@
<div class="group/server relative flex w-full justify-center">
<button
type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card md:h-10 md:w-10"
[class.dm-rail-slide-in]="!item.isExiting"
[class.dm-rail-slide-out]="item.isExiting"
[class.pointer-events-none]="item.isExiting"

View File

@@ -1,6 +1,6 @@
<div
appThemeNode="dmConversationItem"
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
class="group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
[class.bg-primary/10]="isSelected()"
[class.text-foreground]="isSelected()"
[attr.aria-current]="isSelected() ? 'page' : null"

View File

@@ -4,7 +4,8 @@ import {
computed,
effect,
inject,
input
input,
output
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
@@ -48,6 +49,7 @@ export class DmConversationItemComponent {
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
readonly conversation = input.required<DirectMessageConversation>();
readonly conversationOpened = output<string>();
readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
@@ -71,6 +73,7 @@ export class DmConversationItemComponent {
}
openConversation(): void {
this.conversationOpened.emit(this.conversation().id);
void this.router.navigate(['/dm', this.conversation().id]);
}

View File

@@ -1,6 +1,6 @@
<aside
appThemeNode="dmConversationsPanel"
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
class="flex min-h-0 w-full min-w-0 overflow-hidden border-r border-border bg-card"
[ngStyle]="listPanelStyles()"
>
<section class="flex h-full w-full min-w-0 flex-col">
@@ -28,10 +28,12 @@
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else {
<div class="space-y-1">
<app-dm-conversation-item
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId"
[conversation]="conversation"
></app-dm-conversation-item>
@for (conversation of directMessages.conversations(); track trackConversationId($index, conversation)) {
<app-dm-conversation-item
[conversation]="conversation"
(conversationOpened)="conversationSelected.emit($event)"
/>
}
</div>
}
</div>

View File

@@ -2,7 +2,8 @@
import {
Component,
computed,
inject
inject,
output
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -31,6 +32,7 @@ export class DmConversationsPanelComponent {
private readonly theme = inject(ThemeService);
readonly directMessages = inject(DirectMessageService);
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly conversationSelected = output<string>();
trackConversationId(index: number, conversation: DirectMessageConversation): string {
return conversation.id;

View File

@@ -13,7 +13,10 @@
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<app-dm-conversations-panel class="block h-full w-full" />
<app-dm-conversations-panel
(conversationSelected)="setMobilePage('chat')"
class="block h-full w-full"
/>
</div>
</div>
</swiper-slide>
@@ -32,7 +35,21 @@
class="h-5 w-5"
/>
</button>
<p class="truncate text-sm font-semibold text-foreground">Direct messages</p>
<p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">Direct messages</p>
@if (activeCall()) {
<button
type="button"
(click)="openActiveCall()"
class="grid h-11 w-11 place-items-center rounded-lg text-emerald-600 transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
aria-label="Return to call"
title="Return to call"
>
<ng-icon
name="lucidePhoneCall"
class="h-5 w-5"
/>
</button>
}
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<app-dm-chat-panel class="block h-full w-full" />
@@ -50,4 +67,3 @@
<app-dm-chat-panel />
</div>
}

View File

@@ -16,10 +16,11 @@ import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronLeft } from '@ng-icons/lucide';
import { lucideChevronLeft, lucidePhoneCall } from '@ng-icons/lucide';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { ThemeService } from '../../../theme';
import { DirectCallService } from '../../../direct-call';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmChatPanelComponent } from './dm-chat-panel.component';
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
@@ -47,7 +48,7 @@ interface SwiperElement extends HTMLElement {
DmConversationsPanelComponent,
ServersRailComponent
],
viewProviders: [provideIcons({ lucideChevronLeft })],
viewProviders: [provideIcons({ lucideChevronLeft, lucidePhoneCall })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './dm-workspace.component.html'
})
@@ -57,6 +58,7 @@ export class DmWorkspaceComponent implements OnDestroy {
private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService);
private readonly zone = inject(NgZone);
private readonly directCalls = inject(DirectCallService);
private lastSeenConversationId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null;
readonly directMessages = inject(DirectMessageService);
@@ -66,6 +68,12 @@ export class DmWorkspaceComponent implements OnDestroy {
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
readonly activeCall = computed(() => {
const currentSession = this.directCalls.currentSession();
const visibleSessions = this.directCalls.visibleActiveSessions();
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
});
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
@@ -150,6 +158,14 @@ export class DmWorkspaceComponent implements OnDestroy {
this.mobilePage.set(page);
}
openActiveCall(): void {
const call = this.activeCall();
if (call) {
void this.directCalls.openCallView(call.callId);
}
}
ngOnDestroy(): void {
this.directMessages.closeConversationView(this.routeConversationId());
}

View File

@@ -3,6 +3,9 @@ import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../../server-directory';
import { resolveRoomPermission } from '../../../access-control';
import { AttachmentFacade } from '../../../attachment';
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
import type {
Channel,
@@ -28,6 +31,7 @@ import type {
PluginApiAvatarUpdate,
PluginApiActionContext,
PluginApiActionSource,
PluginApiAttachmentImportRequest,
PluginApiChannelRequest,
PluginApiCustomStreamRequest,
PluginApiMessageAsPluginUserRequest,
@@ -44,11 +48,13 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
@Injectable({ providedIn: 'root' })
export class PluginClientApiService {
private readonly attachments = inject(AttachmentFacade);
private readonly capabilities = inject(PluginCapabilityService);
private readonly db = inject(DatabaseService);
private readonly logger = inject(PluginLoggerService);
private readonly messageBus = inject(PluginMessageBusService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly storage = inject(PluginStorageService);
private readonly uiRegistry = inject(PluginUiRegistryService);
@@ -73,6 +79,10 @@ export class PluginClientApiService {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
},
addTextChannel: (request) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'text') }));
},
addVideoChannel: (request) => {
requireCapability('channels.manage');
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
@@ -143,6 +153,15 @@ export class PluginClientApiService {
await this.storage.writeClientData(pluginId, key, value);
}
},
attachments: {
import: async (request: PluginApiAttachmentImportRequest) => {
requireCapability('messages.sync');
const roomId = this.requireRoomId();
this.attachments.rememberMessageRoom(request.messageId, roomId);
await this.attachments.publishAttachments(request.messageId, request.files, this.currentUser()?.id);
}
},
media: {
addCustomAudioStream: async (request) => {
requireCapability('media.addAudioStream');
@@ -190,6 +209,10 @@ export class PluginClientApiService {
requireCapability('messages.send');
this.receivePluginUserMessage(pluginId, request);
},
import: async (messages) => {
requireCapability('messages.sync');
await this.importPluginMessages(pluginId, messages);
},
setTyping: (isTyping, channelId) => {
requireCapability('messages.send');
this.setTyping(pluginId, isTyping, channelId);
@@ -301,6 +324,58 @@ export class PluginClientApiService {
return userId;
},
updateIcon: async (icon) => {
requireCapability('server.manage');
const room = this.currentRoom();
const currentUser = this.currentUser();
if (!room) {
throw new Error('Room not found');
}
if (!currentUser) {
throw new Error('Not logged in');
}
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const isServerAdmin = currentUser.role === 'admin' || currentUser.role === 'host';
const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon');
if (!isOwner && !isServerAdmin && !canByRole) {
throw new Error('Permission denied');
}
const iconUpdatedAt = Date.now();
await this.db.updateRoom(room.id, { icon, iconUpdatedAt });
this.store.dispatch(RoomsActions.updateServerIconSuccess({ roomId: room.id, icon, iconUpdatedAt }));
this.realtime.broadcastMessage({
type: 'server-icon-update',
roomId: room.id,
icon,
iconUpdatedAt
});
this.realtime.sendRawMessage({
type: 'server_icon_available',
serverId: room.id,
iconUpdatedAt
});
this.serverDirectory.updateServer(room.id, {
actingRole: isOwner ? 'host' : undefined,
currentOwnerId: currentUser.id,
icon,
iconUpdatedAt
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
},
updatePermissions: (permissions) => {
requireCapability('server.manage');
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
@@ -648,6 +723,29 @@ export class PluginClientApiService {
});
}
private async importPluginMessages(pluginId: string, messages: Message[]): Promise<void> {
const roomId = this.requireRoomId();
const normalizedMessages = messages
.filter((message) => message.roomId === roomId)
.map((message) => ({
...message,
channelId: message.channelId ?? this.activeChannelId() ?? 'general',
isDeleted: message.isDeleted === true,
reactions: message.reactions ?? []
}));
if (normalizedMessages.length === 0) {
return;
}
for (const message of normalizedMessages) {
await this.db.saveMessage(message);
}
this.store.dispatch(MessagesActions.syncMessages({ messages: normalizedMessages }));
this.logger.info(pluginId, 'Historical messages imported', { count: normalizedMessages.length });
}
private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void {
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);

View File

@@ -74,6 +74,11 @@ export interface PluginApiAudioClipRequest {
url: string;
}
export interface PluginApiAttachmentImportRequest {
files: File[];
messageId: string;
}
export interface PluginApiCustomStreamRequest {
label?: string;
stream: MediaStream;
@@ -195,6 +200,7 @@ export interface PluginApiUiContributionMap {
export interface TojuClientPluginApi {
readonly channels: {
addAudioChannel: (request: PluginApiChannelRequest) => void;
addTextChannel: (request: PluginApiChannelRequest) => void;
addVideoChannel: (request: PluginApiChannelRequest) => void;
list: () => Channel[];
remove: (channelId: string) => void;
@@ -221,6 +227,9 @@ export interface TojuClientPluginApi {
remove: (key: string) => Promise<void>;
write: (key: string, value: unknown) => Promise<void>;
};
readonly attachments: {
import: (request: PluginApiAttachmentImportRequest) => Promise<void>;
};
readonly media: {
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
@@ -235,6 +244,7 @@ export interface TojuClientPluginApi {
readCurrent: () => Message[];
send: (content: string, channelId?: string) => Message;
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
import: (messages: Message[]) => Promise<void>;
setTyping: (isTyping: boolean, channelId?: string) => void;
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
sync: (messages: Message[]) => void;
@@ -261,6 +271,7 @@ export interface TojuClientPluginApi {
readonly server: {
getCurrent: () => Room | null;
registerPluginUser: (request: PluginApiPluginUserRequest) => string;
updateIcon: (icon: string) => Promise<void>;
updatePermissions: (permissions: Partial<RoomPermissions>) => void;
updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
};

View File

@@ -1,13 +1,13 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<section
class="flex h-full min-h-0 flex-col bg-background text-foreground"
class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
data-testid="plugin-manager"
>
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex min-w-0 items-center gap-3">
<header class="flex flex-col gap-3 border-b border-border px-3 py-3 md:flex-row md:items-center md:justify-between md:px-4">
<div class="flex min-w-0 items-center gap-3 md:flex-1">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
class="inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground md:h-8 md:w-8"
aria-label="Back to settings"
(click)="close()"
>
@@ -21,38 +21,40 @@
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
</div>
</div>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyAll()"
(click)="activateAll()"
>
<ng-icon
name="lucidePlay"
size="16"
/>
Activate ready plugins
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted"
(click)="openStore()"
>
<ng-icon
name="lucideStore"
size="16"
/>
Open Plugin Store
</button>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 md:flex md:flex-shrink-0 md:items-center">
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyAll()"
(click)="activateAll()"
>
<ng-icon
name="lucidePlay"
size="16"
/>
Activate ready plugins
</button>
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="openStore()"
>
<ng-icon
name="lucideStore"
size="16"
/>
Open Plugin Store
</button>
</div>
</header>
<nav
class="flex gap-2 border-b border-border px-4 py-2"
class="no-scrollbar flex gap-2 overflow-x-auto border-b border-border px-3 py-2 md:px-4"
aria-label="Plugin manager sections"
>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'installed'"
(click)="setTab('installed')"
>
@@ -64,7 +66,7 @@
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'extensions'"
(click)="setTab('extensions')"
>
@@ -76,7 +78,7 @@
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'requirements'"
(click)="setTab('requirements')"
>
@@ -88,7 +90,7 @@
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'settings'"
(click)="setTab('settings')"
>
@@ -100,7 +102,7 @@
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'docs'"
(click)="setTab('docs')"
>
@@ -112,7 +114,7 @@
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'logs'"
(click)="setTab('logs')"
>
@@ -124,7 +126,7 @@
</button>
</nav>
<div class="min-h-0 flex-1 overflow-auto p-4">
<div class="min-h-0 flex-1 overflow-auto p-3 md:p-4">
@switch (activeTab()) {
@case ('extensions') {
<div class="space-y-4">
@@ -216,7 +218,7 @@
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
class="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
@@ -224,7 +226,7 @@
</button>
}
</div>
<section class="rounded-lg border border-border bg-card p-4">
<section class="rounded-lg border border-border bg-card p-3 md:p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
@if (selectedSettingsPages().length > 0) {
@@ -255,7 +257,7 @@
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
class="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
@@ -263,14 +265,14 @@
</button>
}
</div>
<section class="rounded-lg border border-border bg-card p-4">
<section class="rounded-lg border border-border bg-card p-3 md:p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
<div class="mt-4 flex flex-wrap gap-2">
@for (doc of selectedDocs(); track doc.label) {
<a
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted"
class="inline-flex min-h-11 items-center rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted md:min-h-0"
[href]="doc.url"
target="_blank"
rel="noreferrer"
@@ -292,7 +294,7 @@
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted"
class="min-h-11 rounded-md border border-border px-3 py-1 text-sm hover:bg-muted md:min-h-0"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
@@ -323,7 +325,7 @@
<div class="space-y-3">
@if (entries().length === 0) {
<div
class="rounded-lg border border-dashed border-border p-8 text-center"
class="rounded-lg border border-dashed border-border p-5 text-center md:p-8"
data-testid="plugin-empty-state"
>
<ng-icon
@@ -337,7 +339,7 @@
} @else {
@for (entry of entries(); track trackEntry($index, entry)) {
<article
class="rounded-lg border border-border bg-card p-4"
class="rounded-lg border border-border bg-card p-3 md:p-4"
[class.ring-2]="isSelected(entry)"
[class.ring-primary]="isSelected(entry)"
>
@@ -351,17 +353,17 @@
<p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p>
<p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap">
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="selectPlugin(entry.manifest.id)"
>
Select
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="setEnabled(entry, !entry.enabled)"
>
<ng-icon
@@ -372,7 +374,7 @@
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
(click)="activate(entry)"
>
@@ -384,7 +386,7 @@
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="reload(entry)"
>
@@ -396,7 +398,7 @@
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="unload(entry)"
>
@@ -416,7 +418,7 @@
}
</div>
<aside class="rounded-lg border border-border bg-card p-4">
<aside class="rounded-lg border border-border bg-card p-3 md:p-4">
@if (selectedPlugin(); as plugin) {
<div class="flex items-center gap-2">
<ng-icon
@@ -430,14 +432,14 @@
} @else {
<button
type="button"
class="mt-3 h-8 rounded-md border border-border px-3 text-sm hover:bg-muted"
class="mt-3 min-h-11 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="grantAll(plugin)"
>
Grant all requested
</button>
<div class="mt-3 space-y-2">
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
<label class="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<label class="flex min-h-11 items-center gap-2 rounded-md border border-border px-3 py-2 text-sm md:min-h-0">
<input
type="checkbox"
class="h-4 w-4"