feat: Theme studio v2
This commit is contained in:
26
package-lock.json
generated
26
package-lock.json
generated
@@ -15,8 +15,10 @@
|
|||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.41.0",
|
||||||
@@ -2731,6 +2733,19 @@
|
|||||||
"@lezer/common": "^1.1.0"
|
"@lezer/common": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/lang-css": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.0.2",
|
||||||
|
"@lezer/css": "^1.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/lang-json": {
|
"node_modules/@codemirror/lang-json": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
@@ -5791,6 +5806,17 @@
|
|||||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/css": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@lezer/highlight": {
|
"node_modules/@lezer/highlight": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
|||||||
@@ -65,8 +65,10 @@
|
|||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.41.0",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<div class="chat-layout relative h-full">
|
<div
|
||||||
|
appThemeNode="chatSurface"
|
||||||
|
class="chat-layout relative h-full"
|
||||||
|
>
|
||||||
<app-chat-message-list
|
<app-chat-message-list
|
||||||
[allMessages]="allMessages()"
|
[allMessages]="allMessages()"
|
||||||
[channelMessages]="channelMessages()"
|
[channelMessages]="channelMessages()"
|
||||||
@@ -19,7 +22,10 @@
|
|||||||
(embedRemoved)="handleEmbedRemoved($event)"
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
<div
|
||||||
|
appThemeNode="chatComposerBar"
|
||||||
|
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
|
||||||
|
>
|
||||||
<app-chat-message-composer
|
<app-chat-message-composer
|
||||||
[replyTo]="replyTo()"
|
[replyTo]="replyTo()"
|
||||||
[showKlipyGifPicker]="showKlipyGifPicker()"
|
[showKlipyGifPicker]="showKlipyGifPicker()"
|
||||||
@@ -47,6 +53,7 @@
|
|||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
<div class="pointer-events-none fixed inset-0 z-[90]">
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatGifPickerSurface"
|
||||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||||
[style.bottom.px]="composerBottomPadding() + 8"
|
[style.bottom.px]="composerBottomPadding() + 8"
|
||||||
[style.right.px]="klipyGifPickerAnchorRight()"
|
[style.right.px]="klipyGifPickerAnchorRight()"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
||||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { Message } from '../../../../shared-kernel';
|
import { Message } from '../../../../shared-kernel';
|
||||||
|
import { ThemeNodeDirective } from '../../../theme';
|
||||||
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
||||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||||
@@ -43,7 +44,8 @@ import {
|
|||||||
ChatMessageComposerComponent,
|
ChatMessageComposerComponent,
|
||||||
KlipyGifPickerComponent,
|
KlipyGifPickerComponent,
|
||||||
ChatMessageListComponent,
|
ChatMessageListComponent,
|
||||||
ChatMessageOverlaysComponent
|
ChatMessageOverlaysComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
templateUrl: './chat-messages.component.html',
|
templateUrl: './chat-messages.component.html',
|
||||||
styleUrl: './chat-messages.component.scss'
|
styleUrl: './chat-messages.component.scss'
|
||||||
@@ -70,16 +72,10 @@ export class ChatMessagesComponent {
|
|||||||
const channelId = this.activeChannelId();
|
const channelId = this.activeChannelId();
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
|
|
||||||
return this.allMessages().filter(
|
return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
|
||||||
(message) =>
|
|
||||||
message.roomId === roomId &&
|
|
||||||
(message.channelId || 'general') === channelId
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly conversationKey = computed(
|
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
||||||
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
|
|
||||||
);
|
|
||||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
||||||
readonly composerBottomPadding = signal(140);
|
readonly composerBottomPadding = signal(140);
|
||||||
readonly klipyGifPickerAnchorRight = signal(16);
|
readonly klipyGifPickerAnchorRight = signal(16);
|
||||||
@@ -176,9 +172,7 @@ export class ChatMessagesComponent {
|
|||||||
if (!message || !currentUserId)
|
if (!message || !currentUserId)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const hasReacted = message.reactions.some(
|
const hasReacted = message.reactions.some((reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId);
|
||||||
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasReacted) {
|
if (hasReacted) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
@@ -243,9 +237,7 @@ export class ChatMessagesComponent {
|
|||||||
const minRight = 16;
|
const minRight = 16;
|
||||||
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
||||||
|
|
||||||
this.klipyGifPickerAnchorRight.set(
|
this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
|
||||||
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
||||||
@@ -290,10 +282,7 @@ export class ChatMessagesComponent {
|
|||||||
|
|
||||||
if (blob) {
|
if (blob) {
|
||||||
try {
|
try {
|
||||||
const result = await electronApi.saveFileAs(
|
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
||||||
attachment.filename,
|
|
||||||
await this.blobToBase64(blob)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.saved || result.cancelled)
|
if (result.saved || result.cancelled)
|
||||||
return;
|
return;
|
||||||
@@ -416,12 +405,7 @@ export class ChatMessagesComponent {
|
|||||||
|
|
||||||
const message = [...this.channelMessages()]
|
const message = [...this.channelMessages()]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find(
|
.find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
|
||||||
(entry) =>
|
|
||||||
entry.senderId === currentUserId &&
|
|
||||||
entry.content === content &&
|
|
||||||
!entry.isDeleted
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||||
<div #composerRoot>
|
<div
|
||||||
|
#composerRoot
|
||||||
|
appThemeNode="chatComposerBar"
|
||||||
|
>
|
||||||
@if (replyTo()) {
|
@if (replyTo()) {
|
||||||
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
<div
|
||||||
|
appThemeNode="chatComposerReplyBar"
|
||||||
|
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
|
||||||
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideReply"
|
name="lucideReply"
|
||||||
class="h-4 w-4 text-muted-foreground"
|
class="h-4 w-4 text-muted-foreground"
|
||||||
@@ -31,6 +37,7 @@
|
|||||||
(mouseleave)="onToolbarMouseLeave()"
|
(mouseleave)="onToolbarMouseLeave()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatComposerToolbar"
|
||||||
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
|
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -124,6 +131,7 @@
|
|||||||
|
|
||||||
<div class="border-border p-4">
|
<div class="border-border p-4">
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatComposerInput"
|
||||||
class="chat-input-wrapper relative"
|
class="chat-input-wrapper relative"
|
||||||
(mouseenter)="inputHovered.set(true)"
|
(mouseenter)="inputHovered.set(true)"
|
||||||
(mouseleave)="inputHovered.set(false)"
|
(mouseleave)="inputHovered.set(false)"
|
||||||
@@ -156,6 +164,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
appThemeNode="chatComposerSendButton"
|
||||||
type="button"
|
type="button"
|
||||||
(click)="sendMessage()"
|
(click)="sendMessage()"
|
||||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
|
|||||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
||||||
import { Message } from '../../../../../../shared-kernel';
|
import { Message } from '../../../../../../shared-kernel';
|
||||||
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
||||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||||
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||||
@@ -43,7 +44,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ChatImageProxyFallbackDirective,
|
ChatImageProxyFallbackDirective,
|
||||||
TypingIndicatorComponent
|
TypingIndicatorComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -415,11 +417,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasPotentialFilePayload(
|
private hasPotentialFilePayload(dataTransfer: DataTransfer | null, treatMissingTypesAsPotentialFile = true): boolean {
|
||||||
dataTransfer: DataTransfer | null,
|
|
||||||
treatMissingTypesAsPotentialFile = true
|
|
||||||
): boolean {
|
|
||||||
|
|
||||||
if (!dataTransfer)
|
if (!dataTransfer)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
@let msg = message();
|
@let msg = message();
|
||||||
@let attachmentsList = attachmentViewModels();
|
@let attachmentsList = attachmentViewModels();
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatMessageBubble"
|
||||||
[attr.data-message-id]="msg.id"
|
[attr.data-message-id]="msg.id"
|
||||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||||
[class.opacity-50]="msg.isDeleted"
|
[class.opacity-50]="msg.isDeleted"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatMessageAvatar"
|
||||||
class="flex-shrink-0 cursor-pointer"
|
class="flex-shrink-0 cursor-pointer"
|
||||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@@ -17,7 +19,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div
|
||||||
|
appThemeNode="chatMessageContent"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
>
|
||||||
@if (msg.replyToId) {
|
@if (msg.replyToId) {
|
||||||
@let reply = repliedMessage();
|
@let reply = repliedMessage();
|
||||||
<div
|
<div
|
||||||
@@ -150,7 +155,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
} @else if ((att.receivedBytes || 0) > 0) {
|
||||||
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
|
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -172,7 +180,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -220,7 +231,10 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
} @else if ((att.receivedBytes || 0) > 0) {
|
||||||
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
|
||||||
|
>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||||
@@ -247,7 +261,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
|
||||||
|
>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
||||||
@@ -271,7 +288,10 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<div class="rounded-md border border-border bg-secondary/40 p-2">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="rounded-md border border-border bg-secondary/40 p-2"
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||||
@@ -339,6 +359,7 @@
|
|||||||
<div class="mt-2 flex flex-wrap gap-1">
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
@for (reaction of getGroupedReactions(); track reaction.emoji) {
|
@for (reaction of getGroupedReactions(); track reaction.emoji) {
|
||||||
<button
|
<button
|
||||||
|
appThemeNode="chatReactionPill"
|
||||||
(click)="toggleReaction(reaction.emoji)"
|
(click)="toggleReaction(reaction.emoji)"
|
||||||
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
|
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
|
||||||
[class.ring-1]="reaction.hasCurrentUser"
|
[class.ring-1]="reaction.hasCurrentUser"
|
||||||
@@ -354,6 +375,7 @@
|
|||||||
|
|
||||||
@if (!msg.isDeleted) {
|
@if (!msg.isDeleted) {
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatMessageActions"
|
||||||
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
|
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
Message,
|
Message,
|
||||||
User
|
User
|
||||||
} from '../../../../../../shared-kernel';
|
} from '../../../../../../shared-kernel';
|
||||||
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
@@ -96,7 +97,8 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
|||||||
ChatVideoPlayerComponent,
|
ChatVideoPlayerComponent,
|
||||||
ChatMessageMarkdownComponent,
|
ChatMessageMarkdownComponent,
|
||||||
ChatLinkEmbedComponent,
|
ChatLinkEmbedComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -150,15 +152,17 @@ export class ChatMessageItemComponent {
|
|||||||
const msg = this.message();
|
const msg = this.message();
|
||||||
const found = this.userLookup().get(msg.senderId);
|
const found = this.userLookup().get(msg.senderId);
|
||||||
|
|
||||||
return found ?? {
|
return (
|
||||||
id: msg.senderId,
|
found ?? {
|
||||||
oderId: msg.senderId,
|
id: msg.senderId,
|
||||||
username: msg.senderName,
|
oderId: msg.senderId,
|
||||||
displayName: msg.senderName,
|
username: msg.senderName,
|
||||||
status: 'disconnected',
|
displayName: msg.senderName,
|
||||||
role: 'member',
|
status: 'disconnected',
|
||||||
joinedAt: 0
|
role: 'member',
|
||||||
};
|
joinedAt: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
editContent = '';
|
editContent = '';
|
||||||
@@ -175,9 +179,7 @@ export class ChatMessageItemComponent {
|
|||||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||||
void this.attachmentVersion();
|
void this.attachmentVersion();
|
||||||
|
|
||||||
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
|
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
|
||||||
this.buildAttachmentViewModel(attachment)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
private readonly syncAttachmentVersion = effect(() => {
|
private readonly syncAttachmentVersion = effect(() => {
|
||||||
const version = this.attachmentsSvc.updated();
|
const version = this.attachmentsSvc.updated();
|
||||||
@@ -320,8 +322,7 @@ export class ChatMessageItemComponent {
|
|||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
const toDay = (value: Date) =>
|
const toDay = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
|
||||||
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
|
|
||||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (dayDiff === 0)
|
if (dayDiff === 0)
|
||||||
@@ -331,11 +332,7 @@ export class ChatMessageItemComponent {
|
|||||||
return 'Yesterday ' + time;
|
return 'Yesterday ' + time;
|
||||||
|
|
||||||
if (dayDiff < 7) {
|
if (dayDiff < 7) {
|
||||||
return (
|
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||||
date.toLocaleDateString([], { weekday: 'short' }) +
|
|
||||||
' ' +
|
|
||||||
time
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -402,10 +399,7 @@ export class ChatMessageItemComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
|
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
|
||||||
return (
|
return (this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||||
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
|
|
||||||
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaAttachmentStatusText(attachment: Attachment): string {
|
getMediaAttachmentStatusText(attachment: Attachment): string {
|
||||||
@@ -418,9 +412,7 @@ export class ChatMessageItemComponent {
|
|||||||
: 'Large audio file. Accept the download to play it in chat.';
|
: 'Large audio file. Accept the download to play it in chat.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.isVideoAttachment(attachment)
|
return this.isVideoAttachment(attachment) ? 'Waiting for video source...' : 'Waiting for audio source...';
|
||||||
? 'Waiting for video source...'
|
|
||||||
: 'Waiting for audio source...';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaAttachmentActionLabel(attachment: Attachment): string {
|
getMediaAttachmentActionLabel(attachment: Attachment): string {
|
||||||
@@ -484,8 +476,7 @@ export class ChatMessageItemComponent {
|
|||||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||||
const isVideo = this.isVideoAttachment(attachment);
|
const isVideo = this.isVideoAttachment(attachment);
|
||||||
const isAudio = this.isAudioAttachment(attachment);
|
const isAudio = this.isAudioAttachment(attachment);
|
||||||
const requiresMediaDownloadAcceptance =
|
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||||
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...attachment,
|
...attachment,
|
||||||
@@ -493,8 +484,12 @@ export class ChatMessageItemComponent {
|
|||||||
isUploader: this.isUploader(attachment),
|
isUploader: this.isUploader(attachment),
|
||||||
isVideo,
|
isVideo,
|
||||||
mediaActionLabel: requiresMediaDownloadAcceptance
|
mediaActionLabel: requiresMediaDownloadAcceptance
|
||||||
? attachment.requestError ? 'Retry download' : 'Accept download'
|
? attachment.requestError
|
||||||
: attachment.requestError ? 'Retry' : 'Request',
|
? 'Retry download'
|
||||||
|
: 'Accept download'
|
||||||
|
: attachment.requestError
|
||||||
|
? 'Retry'
|
||||||
|
: 'Request',
|
||||||
mediaStatusText: attachment.requestError
|
mediaStatusText: attachment.requestError
|
||||||
? attachment.requestError
|
? attachment.requestError
|
||||||
: requiresMediaDownloadAcceptance
|
: requiresMediaDownloadAcceptance
|
||||||
@@ -504,15 +499,11 @@ export class ChatMessageItemComponent {
|
|||||||
: isVideo
|
: isVideo
|
||||||
? 'Waiting for video source...'
|
? 'Waiting for video source...'
|
||||||
: 'Waiting for audio source...',
|
: 'Waiting for audio source...',
|
||||||
progressPercent: attachment.size > 0
|
progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0
|
||||||
? ((attachment.receivedBytes || 0) * 100) / attachment.size
|
|
||||||
: 0
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLiveAttachment(attachmentId: string): Attachment | undefined {
|
private getLiveAttachment(attachmentId: string): Attachment | undefined {
|
||||||
return this.attachmentsSvc
|
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
|
||||||
.getForMessage(this.message().id)
|
|
||||||
.find((attachment) => attachment.id === attachmentId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<div
|
<div
|
||||||
#messagesContainer
|
#messagesContainer
|
||||||
|
appThemeNode="chatMessageList"
|
||||||
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
|
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
|
||||||
[style.padding-bottom.px]="bottomPadding()"
|
[style.padding-bottom.px]="bottomPadding()"
|
||||||
(scroll)="onScroll()"
|
(scroll)="onScroll()"
|
||||||
@@ -39,7 +40,10 @@
|
|||||||
|
|
||||||
@for (message of messages(); track message.id; let index = $index) {
|
@for (message of messages(); track message.id; let index = $index) {
|
||||||
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
||||||
<div class="flex items-center gap-3 py-1">
|
<div
|
||||||
|
appThemeNode="chatDateSeparator"
|
||||||
|
class="flex items-center gap-3 py-1"
|
||||||
|
>
|
||||||
<div class="h-px flex-1 bg-border"></div>
|
<div class="h-px flex-1 bg-border"></div>
|
||||||
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
|
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
|
||||||
{{ separatorLabel }}
|
{{ separatorLabel }}
|
||||||
@@ -70,7 +74,10 @@
|
|||||||
|
|
||||||
@if (showNewMessagesBar()) {
|
@if (showNewMessagesBar()) {
|
||||||
<div class="pointer-events-none sticky bottom-4 flex justify-center">
|
<div class="pointer-events-none sticky bottom-4 flex justify-center">
|
||||||
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
|
<div
|
||||||
|
appThemeNode="chatNewMessagesBar"
|
||||||
|
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
|
||||||
|
>
|
||||||
<span class="text-sm text-muted-foreground">New messages</span>
|
<span class="text-sm text-muted-foreground">New messages</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from '../../models/chat-messages.model';
|
} from '../../models/chat-messages.model';
|
||||||
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||||
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
||||||
|
|
||||||
interface PrismGlobal {
|
interface PrismGlobal {
|
||||||
@@ -41,7 +42,11 @@ declare global {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-message-list',
|
selector: 'app-chat-message-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ChatMessageItemComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ChatMessageItemComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
|
],
|
||||||
templateUrl: './chat-message-list.component.html',
|
templateUrl: './chat-message-list.component.html',
|
||||||
host: {
|
host: {
|
||||||
style: 'display: contents;'
|
style: 'display: contents;'
|
||||||
@@ -94,9 +99,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return all.slice(all.length - limit);
|
return all.slice(all.length - limit);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly hasMoreMessages = computed(
|
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
|
||||||
() => this.channelMessages().length > this.displayLimit()
|
|
||||||
);
|
|
||||||
|
|
||||||
readonly dateSeparatorLabels = computed(() => {
|
readonly dateSeparatorLabels = computed(() => {
|
||||||
const labels = new Map<number, string>();
|
const labels = new Map<number, string>();
|
||||||
@@ -165,8 +168,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromBottom =
|
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
||||||
const newMessages = currentCount > this.lastMessageCount;
|
const newMessages = currentCount > this.lastMessageCount;
|
||||||
|
|
||||||
if (newMessages) {
|
if (newMessages) {
|
||||||
@@ -228,8 +230,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
if (!element || this.isAutoScrolling)
|
if (!element || this.isAutoScrolling)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const distanceFromBottom =
|
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
||||||
const shouldStickToBottom = distanceFromBottom <= 300;
|
const shouldStickToBottom = distanceFromBottom <= 300;
|
||||||
|
|
||||||
if (shouldStickToBottom) {
|
if (shouldStickToBottom) {
|
||||||
@@ -386,11 +387,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||||
this.messagesContainer.nativeElement.removeEventListener(
|
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
||||||
'load',
|
|
||||||
this.boundOnImageLoad,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
this.boundOnImageLoad = null;
|
this.boundOnImageLoad = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<section class="chat-layout relative h-full bg-background">
|
<section
|
||||||
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4">
|
appThemeNode="dmChatSurface"
|
||||||
|
class="chat-layout relative h-full bg-background"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
appThemeNode="dmChatHeader"
|
||||||
|
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
|
||||||
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="peerName()"
|
[name]="peerName()"
|
||||||
[avatarUrl]="peerUser()?.avatarUrl"
|
[avatarUrl]="peerUser()?.avatarUrl"
|
||||||
@@ -14,7 +20,10 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (conversation()) {
|
@if (conversation()) {
|
||||||
<div class="absolute inset-x-0 bottom-0 top-14">
|
<div
|
||||||
|
appThemeNode="dmMessageRegion"
|
||||||
|
class="absolute inset-x-0 bottom-0 top-14"
|
||||||
|
>
|
||||||
<app-chat-message-list
|
<app-chat-message-list
|
||||||
[allMessages]="chatMessages()"
|
[allMessages]="chatMessages()"
|
||||||
[channelMessages]="chatMessages()"
|
[channelMessages]="chatMessages()"
|
||||||
@@ -45,7 +54,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md">
|
<div
|
||||||
|
appThemeNode="chatComposerBar"
|
||||||
|
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
|
||||||
|
>
|
||||||
<app-chat-message-composer
|
<app-chat-message-composer
|
||||||
[replyTo]="replyTo()"
|
[replyTo]="replyTo()"
|
||||||
[showKlipyGifPicker]="showGifPicker()"
|
[showKlipyGifPicker]="showGifPicker()"
|
||||||
@@ -72,6 +84,7 @@
|
|||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
<div class="pointer-events-none fixed inset-0 z-[90]">
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatGifPickerSurface"
|
||||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||||
[style.bottom.px]="composerBottomPadding() + 8"
|
[style.bottom.px]="composerBottomPadding() + 8"
|
||||||
[style.right.px]="gifPickerAnchorRight()"
|
[style.right.px]="gifPickerAnchorRight()"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { map } from 'rxjs';
|
|||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { UserAvatarComponent } from '../../../../shared';
|
import { UserAvatarComponent } from '../../../../shared';
|
||||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||||
|
import { ThemeNodeDirective } from '../../../theme';
|
||||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +57,7 @@ interface DmStatusLabel {
|
|||||||
ChatMessageListComponent,
|
ChatMessageListComponent,
|
||||||
ChatMessageOverlaysComponent,
|
ChatMessageOverlaysComponent,
|
||||||
KlipyGifPickerComponent,
|
KlipyGifPickerComponent,
|
||||||
|
ThemeNodeDirective,
|
||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
],
|
],
|
||||||
templateUrl: './dm-chat.component.html',
|
templateUrl: './dm-chat.component.html',
|
||||||
@@ -106,21 +108,23 @@ export class DmChatComponent {
|
|||||||
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId);
|
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId);
|
||||||
const participant = conversation.participantProfiles[participantId];
|
const participant = conversation.participantProfiles[participantId];
|
||||||
|
|
||||||
return knownUser ?? {
|
return (
|
||||||
id: participantId,
|
knownUser ?? {
|
||||||
oderId: participantId,
|
id: participantId,
|
||||||
username: participant?.username || participant?.displayName || participantId,
|
oderId: participantId,
|
||||||
displayName: participant?.displayName || participant?.username || participantId,
|
username: participant?.username || participant?.displayName || participantId,
|
||||||
description: participant?.description,
|
displayName: participant?.displayName || participant?.username || participantId,
|
||||||
profileUpdatedAt: participant?.profileUpdatedAt,
|
description: participant?.description,
|
||||||
avatarUrl: participant?.avatarUrl,
|
profileUpdatedAt: participant?.profileUpdatedAt,
|
||||||
avatarHash: participant?.avatarHash,
|
avatarUrl: participant?.avatarUrl,
|
||||||
avatarMime: participant?.avatarMime,
|
avatarHash: participant?.avatarHash,
|
||||||
avatarUpdatedAt: participant?.avatarUpdatedAt,
|
avatarMime: participant?.avatarMime,
|
||||||
status: 'disconnected',
|
avatarUpdatedAt: participant?.avatarUpdatedAt,
|
||||||
role: 'member',
|
status: 'disconnected',
|
||||||
joinedAt: 0
|
role: 'member',
|
||||||
};
|
joinedAt: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
|
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
|
||||||
@@ -218,14 +222,13 @@ export class DmChatComponent {
|
|||||||
|
|
||||||
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
|
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
|
||||||
|
|
||||||
void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id)
|
void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id).then((message) => {
|
||||||
.then((message) => {
|
this.replyTo.set(null);
|
||||||
this.replyTo.set(null);
|
|
||||||
|
|
||||||
if (event.pendingFiles.length > 0) {
|
if (event.pendingFiles.length > 0) {
|
||||||
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
|
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setReplyTo(message: ChatMessageReplyEvent): void {
|
setReplyTo(message: ChatMessageReplyEvent): void {
|
||||||
@@ -388,8 +391,7 @@ export class DmChatComponent {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls = this.linkMetadata.extractUrls(message.content)
|
const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url));
|
||||||
.filter((url) => !hasDedicatedChatEmbed(url));
|
|
||||||
|
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
[ngStyle]="listPanelStyles()"
|
[ngStyle]="listPanelStyles()"
|
||||||
>
|
>
|
||||||
<section class="flex h-full w-full min-w-0 flex-col">
|
<section class="flex h-full w-full min-w-0 flex-col">
|
||||||
<header class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3">
|
<header
|
||||||
|
appThemeNode="dmConversationsHeader"
|
||||||
|
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
|
||||||
|
>
|
||||||
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
|
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideMessageCircle"
|
name="lucideMessageCircle"
|
||||||
@@ -21,13 +24,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-2">
|
<div
|
||||||
|
appThemeNode="dmConversationList"
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto p-2"
|
||||||
|
>
|
||||||
@if (directMessages.conversations().length === 0) {
|
@if (directMessages.conversations().length === 0) {
|
||||||
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (conversation of directMessages.conversations(); track conversation.id) {
|
@for (conversation of directMessages.conversations(); track conversation.id) {
|
||||||
<div
|
<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 items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
|
||||||
[class.bg-primary/10]="isSelectedConversation(conversation)"
|
[class.bg-primary/10]="isSelectedConversation(conversation)"
|
||||||
[class.text-foreground]="isSelectedConversation(conversation)"
|
[class.text-foreground]="isSelectedConversation(conversation)"
|
||||||
@@ -72,7 +79,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-border px-2 py-3">
|
<div
|
||||||
|
appThemeNode="dmVoiceControlsArea"
|
||||||
|
class="border-t border-border px-2 py-3"
|
||||||
|
>
|
||||||
<app-voice-controls />
|
<app-voice-controls />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class DmWorkspaceComponent implements OnDestroy {
|
|||||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||||
});
|
});
|
||||||
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
|
||||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||||
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export class ElementPickerService {
|
|||||||
this.hoveredKey.set(null);
|
this.hoveredKey.set(null);
|
||||||
this.isPicking.set(false);
|
this.isPicking.set(false);
|
||||||
|
|
||||||
|
if (this.resumePage === 'theme') {
|
||||||
|
this.modal.openThemeStudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.resumePage) {
|
if (this.resumePage) {
|
||||||
this.modal.open(this.resumePage);
|
this.modal.open(this.resumePage);
|
||||||
}
|
}
|
||||||
@@ -124,8 +129,6 @@ export class ElementPickerService {
|
|||||||
const key = themedElement?.dataset['themeKey'] ?? null;
|
const key = themedElement?.dataset['themeKey'] ?? null;
|
||||||
const definition = this.registry.getDefinition(key);
|
const definition = this.registry.getDefinition(key);
|
||||||
|
|
||||||
return definition?.pickerVisible
|
return definition?.pickerVisible ? key : null;
|
||||||
? key
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ThemeGridEditorItem,
|
ThemeGridEditorItem,
|
||||||
ThemeGridRect
|
ThemeGridRect
|
||||||
} from '../../domain/models/theme.model';
|
} from '../../domain/models/theme.model';
|
||||||
import { createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
|
import { createDefaultThemeDocument, createDefaultThemeLayout } from '../../domain/logic/theme-defaults.logic';
|
||||||
import { ThemeRegistryService } from './theme-registry.service';
|
import { ThemeRegistryService } from './theme-registry.service';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
@@ -26,37 +26,46 @@ export class LayoutSyncService {
|
|||||||
|
|
||||||
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
|
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
|
||||||
const draftTheme = this.theme.draftTheme();
|
const draftTheme = this.theme.draftTheme();
|
||||||
const defaults = createDefaultThemeDocument();
|
const defaults = createDefaultThemeLayout();
|
||||||
|
|
||||||
return this.registry.entries()
|
return this.registry
|
||||||
|
.entries()
|
||||||
.filter((entry) => entry.layoutEditable && entry.container === containerKey)
|
.filter((entry) => entry.layoutEditable && entry.container === containerKey)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
key: entry.key,
|
key: entry.key,
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
description: entry.description,
|
description: entry.description,
|
||||||
grid: draftTheme.layout[entry.key]?.grid ?? defaults.layout[entry.key].grid
|
grid: draftTheme.layout[entry.key]?.grid ?? defaults[entry.key].grid
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateGrid(key: string, grid: ThemeGridRect): void {
|
updateGrid(key: string, grid: ThemeGridRect): void {
|
||||||
this.theme.ensureLayoutEntry(key);
|
this.theme.ensureLayoutEntry(key);
|
||||||
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
this.theme.updateStructuredDraft(
|
||||||
draft.layout[key] = {
|
(draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
||||||
...draft.layout[key],
|
draft.layout[key] = {
|
||||||
grid
|
...draft.layout[key],
|
||||||
};
|
grid
|
||||||
}, true, `${key} layout updated.`);
|
};
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
`${key} layout updated.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetContainer(containerKey: ThemeContainerKey): void {
|
resetContainer(containerKey: ThemeContainerKey): void {
|
||||||
const defaults = createDefaultThemeDocument();
|
const defaults = createDefaultThemeLayout();
|
||||||
|
|
||||||
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
this.theme.updateStructuredDraft(
|
||||||
for (const entry of this.registry.entries()) {
|
(draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
||||||
if (entry.container === containerKey && entry.layoutEditable) {
|
for (const entry of this.registry.entries()) {
|
||||||
draft.layout[entry.key] = defaults.layout[entry.key];
|
if (entry.container === containerKey && entry.layoutEditable) {
|
||||||
|
draft.layout[entry.key] = defaults[entry.key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, true, `${containerKey} restored to its default layout.`);
|
true,
|
||||||
|
`${containerKey} restored to its default layout.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
|
||||||
|
|
||||||
|
import { DEFAULT_THEME_JSON, createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
|
||||||
|
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
describe('ThemeService theme application', () => {
|
||||||
|
let injector: EnvironmentInjector;
|
||||||
|
let styleElements: TestStyleElement[];
|
||||||
|
let service: ThemeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
installLocalStorageMock();
|
||||||
|
styleElements = [];
|
||||||
|
injector = createEnvironmentInjector([
|
||||||
|
ThemeService,
|
||||||
|
{
|
||||||
|
provide: DOCUMENT,
|
||||||
|
useValue: createDocumentStub(styleElements)
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
service = injector.get(ThemeService);
|
||||||
|
service.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
injector.destroy();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the compact Toju dark theme as the built-in default JSON', () => {
|
||||||
|
expect(JSON.parse(DEFAULT_THEME_JSON) as unknown).toEqual({
|
||||||
|
meta: {
|
||||||
|
name: 'Toju Default Dark',
|
||||||
|
version: '2.0.0',
|
||||||
|
description: 'Built-in dark glass theme for the full Toju app shell.'
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
colors: {
|
||||||
|
background: '224 28% 7%',
|
||||||
|
foreground: '210 40% 96%',
|
||||||
|
card: '224 25% 10%',
|
||||||
|
cardForeground: '210 40% 96%',
|
||||||
|
popover: '224 26% 9%',
|
||||||
|
popoverForeground: '210 40% 96%',
|
||||||
|
primary: '193 95% 68%',
|
||||||
|
primaryForeground: '222 47% 11%',
|
||||||
|
secondary: '223 19% 16%',
|
||||||
|
secondaryForeground: '210 40% 96%',
|
||||||
|
muted: '223 18% 14%',
|
||||||
|
mutedForeground: '215 20% 70%',
|
||||||
|
accent: '218 22% 18%',
|
||||||
|
accentForeground: '210 40% 98%',
|
||||||
|
destructive: '0 72% 55%',
|
||||||
|
destructiveForeground: '0 0% 100%',
|
||||||
|
border: '222 18% 22%',
|
||||||
|
input: '222 18% 22%',
|
||||||
|
ring: '193 95% 68%',
|
||||||
|
railBackground: '226 33% 8%',
|
||||||
|
workspaceBackground: '224 30% 9%',
|
||||||
|
panelBackground: '224 24% 11%',
|
||||||
|
panelBackgroundAlt: '222 22% 13%',
|
||||||
|
titleBarBackground: '226 34% 7%',
|
||||||
|
surfaceHighlight: '193 95% 68%',
|
||||||
|
surfaceHighlightAlt: '261 82% 72%'
|
||||||
|
},
|
||||||
|
spacing: {},
|
||||||
|
radii: {
|
||||||
|
radius: '0.875rem',
|
||||||
|
surface: '1.35rem',
|
||||||
|
pill: '999px'
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
|
||||||
|
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
|
||||||
|
glassBlur: 'blur(18px) saturate(135%)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
serversRail: {
|
||||||
|
container: 'appShell',
|
||||||
|
grid: { x: 0, y: 0, w: 1, h: 1 }
|
||||||
|
},
|
||||||
|
appWorkspace: {
|
||||||
|
container: 'appShell',
|
||||||
|
grid: { x: 1, y: 0, w: 19, h: 1 }
|
||||||
|
},
|
||||||
|
chatRoomChannelsPanel: {
|
||||||
|
container: 'roomLayout',
|
||||||
|
grid: { x: 0, y: 0, w: 4, h: 12 }
|
||||||
|
},
|
||||||
|
chatRoomMainPanel: {
|
||||||
|
container: 'roomLayout',
|
||||||
|
grid: { x: 4, y: 0, w: 12, h: 12 }
|
||||||
|
},
|
||||||
|
chatRoomMembersPanel: {
|
||||||
|
container: 'roomLayout',
|
||||||
|
grid: { x: 16, y: 0, w: 4, h: 12 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a JSON theme with tokens, layout, backgrounds, effects, metadata, and animations', () => {
|
||||||
|
const theme = createCompleteThemeDocument();
|
||||||
|
const loaded = service.loadThemeText(JSON.stringify(theme), 'apply', 'Theme applied.', 'complete JSON theme');
|
||||||
|
|
||||||
|
expect(loaded).toBe(true);
|
||||||
|
expect(service.activeThemeName()).toBe('Complete Theme Fixture');
|
||||||
|
expect(service.getTextOverride('titleBar')).toBe('MetoYou Lab');
|
||||||
|
expect(service.getIcon('titleBar')).toBe('MT');
|
||||||
|
expect(service.getLink('titleBar')).toBe('https://example.com/theme');
|
||||||
|
expect(service.getAnimationClass('titleBar')).toBe('themePulse');
|
||||||
|
|
||||||
|
expect(service.getHostStyles('appRoot')).toMatchObject({
|
||||||
|
'--background': '210 22% 8%',
|
||||||
|
'--theme-spacing-panel-gap': '14px',
|
||||||
|
'--radius': '1rem',
|
||||||
|
'--theme-radius-surface': '1.25rem',
|
||||||
|
'--theme-effect-glass-blur': 'blur(20px) saturate(140%)'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getHostStyles('titleBar')).toMatchObject({
|
||||||
|
width: 'min(100%, 64rem)',
|
||||||
|
height: '4rem',
|
||||||
|
minWidth: '18rem',
|
||||||
|
minHeight: '3rem',
|
||||||
|
maxWidth: '72rem',
|
||||||
|
maxHeight: '5rem',
|
||||||
|
position: 'sticky',
|
||||||
|
top: '0',
|
||||||
|
right: '0',
|
||||||
|
bottom: 'auto',
|
||||||
|
left: '0',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
margin: '0 auto',
|
||||||
|
border: '1px solid hsl(var(--border) / 0.75)',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
backgroundColor: 'rgba(7, 11, 20, 0.88)',
|
||||||
|
color: '#f8fafc',
|
||||||
|
backgroundImage: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent), url("/themes/city.png")',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)',
|
||||||
|
opacity: '0.82'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getLayoutContainerStyles('dmLayout')).toMatchObject({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 4.25rem) repeat(16, minmax(0, 1fr))'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
|
||||||
|
gridColumn: '7 / span 14',
|
||||||
|
gridRow: '2 / span 10'
|
||||||
|
});
|
||||||
|
|
||||||
|
const animationStylesheet = styleElements.find((styleElement) => styleElement.textContent?.includes('@keyframes themePulse'));
|
||||||
|
|
||||||
|
expect(animationStylesheet?.textContent).toContain('@keyframes themePulse');
|
||||||
|
expect(animationStylesheet?.textContent).toContain('animation-direction: alternate-reverse;');
|
||||||
|
expect(animationStylesheet?.textContent).toContain('transform: scale(1);');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies raw CSS from the theme JSON', () => {
|
||||||
|
const loaded = service.loadThemeText(
|
||||||
|
JSON.stringify({
|
||||||
|
meta: {
|
||||||
|
name: 'CSS Theme',
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
css: '.theme-css-probe { color: hsl(var(--primary)); }'
|
||||||
|
}),
|
||||||
|
'apply',
|
||||||
|
'Theme applied.',
|
||||||
|
'CSS JSON theme'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loaded).toBe(true);
|
||||||
|
expect(styleElements.some((styleElement) => styleElement.textContent === '.theme-css-probe { color: hsl(var(--primary)); }')).toBe(true);
|
||||||
|
expect(service.activeThemeText()).toContain('"css"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a CSS-only theme over the default JSON theme', () => {
|
||||||
|
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
|
||||||
|
|
||||||
|
expect(applied).toBe(true);
|
||||||
|
expect(service.activeThemeName()).toBe('Toju Default Dark');
|
||||||
|
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
|
||||||
|
gridColumn: '5 / span 16',
|
||||||
|
gridRow: '1 / span 12'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getHostStyles('appRoot')).toMatchObject({
|
||||||
|
'--background': '224 28% 7%'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies CSS over the current JSON draft theme', () => {
|
||||||
|
const draftTheme = createDefaultThemeDocument();
|
||||||
|
|
||||||
|
draftTheme.meta = {
|
||||||
|
name: 'Draft Base Theme',
|
||||||
|
version: '1.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
draftTheme.tokens.colors['background'] = '200 50% 10%';
|
||||||
|
draftTheme.layout['dmChatPanel'] = {
|
||||||
|
container: 'dmLayout',
|
||||||
|
grid: { x: 6, y: 0, w: 14, h: 12 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
|
||||||
|
const applied = service.applyCssOnlyTheme('.draft-base-theme { color: hsl(var(--foreground)); }');
|
||||||
|
|
||||||
|
expect(loadedDraft).toBe(true);
|
||||||
|
expect(applied).toBe(true);
|
||||||
|
expect(service.activeThemeName()).toBe('Draft Base Theme');
|
||||||
|
expect(service.getHostStyles('appRoot')).toMatchObject({
|
||||||
|
'--background': '200 50% 10%'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
|
||||||
|
gridColumn: '7 / span 14',
|
||||||
|
gridRow: '1 / span 12'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(styleElements.some((styleElement) => styleElement.textContent === '.draft-base-theme { color: hsl(var(--foreground)); }')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds exportable JSON with CSS over the current draft without applying it', () => {
|
||||||
|
const draftTheme = createDefaultThemeDocument();
|
||||||
|
|
||||||
|
draftTheme.meta = {
|
||||||
|
name: 'Export Base Theme',
|
||||||
|
version: '1.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
draftTheme.tokens.colors['background'] = '180 40% 12%';
|
||||||
|
|
||||||
|
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
|
||||||
|
const exportText = service.buildDraftTextWithCss('.export-base-theme { color: hsl(var(--foreground)); }');
|
||||||
|
|
||||||
|
expect(loadedDraft).toBe(true);
|
||||||
|
expect(exportText).not.toBeNull();
|
||||||
|
expect(JSON.parse(exportText ?? '{}')).toMatchObject({
|
||||||
|
meta: {
|
||||||
|
name: 'Export Base Theme'
|
||||||
|
},
|
||||||
|
css: '.export-base-theme { color: hsl(var(--foreground)); }',
|
||||||
|
tokens: {
|
||||||
|
colors: {
|
||||||
|
background: '180 40% 12%'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.activeThemeName()).toBe('Toju Default Dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates the dedicated DM workspace layout container', () => {
|
||||||
|
const theme = createDefaultThemeDocument();
|
||||||
|
|
||||||
|
theme.layout['dmConversationsPanel'] = {
|
||||||
|
container: 'dmLayout',
|
||||||
|
grid: { x: 0, y: 0, w: 5, h: 12 }
|
||||||
|
};
|
||||||
|
|
||||||
|
theme.layout['dmChatPanel'] = {
|
||||||
|
container: 'dmLayout',
|
||||||
|
grid: { x: 5, y: 0, w: 15, h: 12 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateThemeDocument(theme);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.value?.layout['dmChatPanel'].container).toBe('dmLayout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows compact JSON themes with omitted element sections', () => {
|
||||||
|
const loaded = service.loadThemeText(
|
||||||
|
JSON.stringify({
|
||||||
|
meta: {
|
||||||
|
name: 'Compact Theme',
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
titleBar: {
|
||||||
|
backgroundImage: '/themes/city.png',
|
||||||
|
backgroundSize: 'cover'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'apply',
|
||||||
|
'Theme applied.',
|
||||||
|
'compact JSON theme'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loaded).toBe(true);
|
||||||
|
expect(service.getHostStyles('titleBar')).toMatchObject({
|
||||||
|
backgroundImage: 'url("/themes/city.png")',
|
||||||
|
backgroundSize: 'cover'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getHostStyles('voiceWorkspace')).toEqual({});
|
||||||
|
expect(service.activeThemeText()).not.toContain('voiceWorkspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty element stubs when formatting themes', () => {
|
||||||
|
const loaded = service.loadThemeText(
|
||||||
|
JSON.stringify({
|
||||||
|
meta: {
|
||||||
|
name: 'Theme With Empty Elements',
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
titleBar: {},
|
||||||
|
voiceWorkspace: {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'apply',
|
||||||
|
'Theme applied.',
|
||||||
|
'theme with empty elements'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loaded).toBe(true);
|
||||||
|
expect(service.activeThemeText()).not.toContain('"elements"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only non-empty element overrides when formatting themes', () => {
|
||||||
|
const loaded = service.loadThemeText(
|
||||||
|
JSON.stringify({
|
||||||
|
meta: {
|
||||||
|
name: 'Theme With Mixed Elements',
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
titleBar: {},
|
||||||
|
chatRoomMainPanel: {
|
||||||
|
backgroundImage: '/themes/city.png'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'apply',
|
||||||
|
'Theme applied.',
|
||||||
|
'theme with mixed elements'
|
||||||
|
);
|
||||||
|
const activeTheme = JSON.parse(service.activeThemeText()) as { elements?: Record<string, unknown> };
|
||||||
|
|
||||||
|
expect(loaded).toBe(true);
|
||||||
|
expect(activeTheme.elements).toEqual({
|
||||||
|
chatRoomMainPanel: {
|
||||||
|
backgroundImage: '/themes/city.png'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows removing the entire elements section', () => {
|
||||||
|
const loaded = service.loadThemeText(
|
||||||
|
JSON.stringify({
|
||||||
|
meta: {
|
||||||
|
name: 'No Elements Theme',
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'apply',
|
||||||
|
'Theme applied.',
|
||||||
|
'compact JSON theme'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loaded).toBe(true);
|
||||||
|
expect(service.getHostStyles('titleBar')).toEqual({});
|
||||||
|
expect(service.activeThemeText()).not.toContain('"elements"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TestStyleElement {
|
||||||
|
textContent: string | null;
|
||||||
|
setAttribute(name: string, value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCompleteThemeDocument() {
|
||||||
|
const theme = createDefaultThemeDocument();
|
||||||
|
|
||||||
|
theme.meta = {
|
||||||
|
name: 'Complete Theme Fixture',
|
||||||
|
version: '9.9.9',
|
||||||
|
description: 'Exercises every supported applied theme surface.'
|
||||||
|
};
|
||||||
|
|
||||||
|
theme.tokens.colors['background'] = '210 22% 8%';
|
||||||
|
theme.tokens.spacing['panelGap'] = '14px';
|
||||||
|
theme.tokens.radii['radius'] = '1rem';
|
||||||
|
theme.tokens.radii['surface'] = '1.25rem';
|
||||||
|
theme.tokens.effects['glassBlur'] = 'blur(20px) saturate(140%)';
|
||||||
|
theme.layout['dmChatPanel'] = {
|
||||||
|
container: 'dmLayout',
|
||||||
|
grid: { x: 6, y: 1, w: 14, h: 10 }
|
||||||
|
};
|
||||||
|
|
||||||
|
theme.elements['titleBar'] = {
|
||||||
|
width: 'min(100%, 64rem)',
|
||||||
|
height: '4rem',
|
||||||
|
minWidth: '18rem',
|
||||||
|
minHeight: '3rem',
|
||||||
|
maxWidth: '72rem',
|
||||||
|
maxHeight: '5rem',
|
||||||
|
position: 'sticky',
|
||||||
|
top: '0',
|
||||||
|
right: '0',
|
||||||
|
bottom: 'auto',
|
||||||
|
left: '0',
|
||||||
|
opacity: 0.82,
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
margin: '0 auto',
|
||||||
|
border: '1px solid hsl(var(--border) / 0.75)',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
backgroundColor: 'rgba(7, 11, 20, 0.88)',
|
||||||
|
color: '#f8fafc',
|
||||||
|
backgroundImage: '/themes/city.png',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
gradient: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent)',
|
||||||
|
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)',
|
||||||
|
icon: 'MT',
|
||||||
|
textOverride: 'MetoYou Lab',
|
||||||
|
link: 'https://example.com/theme',
|
||||||
|
animationClass: 'themePulse'
|
||||||
|
};
|
||||||
|
|
||||||
|
theme.animations['themePulse'] = {
|
||||||
|
duration: '600ms',
|
||||||
|
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
delay: '50ms',
|
||||||
|
iterationCount: 'infinite',
|
||||||
|
fillMode: 'both',
|
||||||
|
direction: 'alternate-reverse',
|
||||||
|
keyframes: {
|
||||||
|
from: {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'scale(0.98)'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'scale(1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDocumentStub(styleElements: TestStyleElement[]): Document {
|
||||||
|
return {
|
||||||
|
createElement: () => {
|
||||||
|
const styleElement: TestStyleElement = {
|
||||||
|
textContent: null,
|
||||||
|
setAttribute: () => undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
return styleElement;
|
||||||
|
},
|
||||||
|
head: {
|
||||||
|
appendChild: (styleElement: TestStyleElement) => {
|
||||||
|
styleElements.push(styleElement);
|
||||||
|
return styleElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as Document;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installLocalStorageMock(): void {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
clear: () => values.clear()
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_THEME_JSON,
|
DEFAULT_THEME_JSON,
|
||||||
createDefaultThemeDocument,
|
createDefaultThemeDocument,
|
||||||
|
createDefaultThemeLayout,
|
||||||
isLegacyDefaultThemeDocument
|
isLegacyDefaultThemeDocument
|
||||||
} from '../../domain/logic/theme-defaults.logic';
|
} from '../../domain/logic/theme-defaults.logic';
|
||||||
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
|
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
|
||||||
@@ -35,14 +36,68 @@ function toKebabCase(value: string): string {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEmptyRecord(value: Record<string, unknown>): boolean {
|
||||||
|
return Object.keys(value).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTokenOverrides(document: ThemeDocument): boolean {
|
||||||
|
return (
|
||||||
|
!isEmptyRecord(document.tokens.colors) ||
|
||||||
|
!isEmptyRecord(document.tokens.spacing) ||
|
||||||
|
!isEmptyRecord(document.tokens.radii) ||
|
||||||
|
!isEmptyRecord(document.tokens.effects)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactThemeElements(elements: ThemeDocument['elements']): ThemeDocument['elements'] {
|
||||||
|
return Object.fromEntries(Object.entries(elements).filter(([_key, styles]) => !isEmptyRecord(styles as Record<string, unknown>)));
|
||||||
|
}
|
||||||
|
|
||||||
function stringifyTheme(document: ThemeDocument): string {
|
function stringifyTheme(document: ThemeDocument): string {
|
||||||
return JSON.stringify(document, null, 2);
|
const jsonDocument: Partial<ThemeDocument> = {
|
||||||
|
meta: document.meta
|
||||||
|
};
|
||||||
|
const compactElements = compactThemeElements(document.elements);
|
||||||
|
|
||||||
|
if (document.css.trim().length > 0) {
|
||||||
|
jsonDocument.css = document.css;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTokenOverrides(document)) {
|
||||||
|
jsonDocument.tokens = document.tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmptyRecord(document.layout)) {
|
||||||
|
jsonDocument.layout = document.layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmptyRecord(compactElements)) {
|
||||||
|
jsonDocument.elements = compactElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmptyRecord(document.animations)) {
|
||||||
|
jsonDocument.animations = document.animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(jsonDocument, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeImageReference(value: string): boolean {
|
||||||
|
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBackgroundImageLayer(value: string): string {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
|
||||||
|
if (!looksLikeImageReference(trimmedValue)) {
|
||||||
|
return trimmedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `url("${trimmedValue.replace(/"/g, '\\"')}")`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
|
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
|
||||||
return isLegacyDefaultThemeDocument(document)
|
return isLegacyDefaultThemeDocument(document) ? createDefaultThemeDocument() : document;
|
||||||
? createDefaultThemeDocument()
|
|
||||||
: document;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostStylePropertyKeys = [
|
const hostStylePropertyKeys = [
|
||||||
@@ -96,6 +151,7 @@ export class ThemeService {
|
|||||||
private initialized = false;
|
private initialized = false;
|
||||||
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
private animationStyleElement: HTMLStyleElement | null = null;
|
private animationStyleElement: HTMLStyleElement | null = null;
|
||||||
|
private cssStyleElement: HTMLStyleElement | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.activeTheme = this.activeThemeInternal.asReadonly();
|
this.activeTheme = this.activeThemeInternal.asReadonly();
|
||||||
@@ -159,6 +215,7 @@ export class ThemeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.syncAnimationStylesheet();
|
this.syncAnimationStylesheet();
|
||||||
|
this.syncCssStylesheet();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDraftText(text: string): void {
|
updateDraftText(text: string): void {
|
||||||
@@ -203,12 +260,26 @@ export class ThemeService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadThemeText(
|
buildDraftTextWithCss(css: string): string | null {
|
||||||
text: string,
|
const theme = this.composeDraftThemeWithCss(css);
|
||||||
mode: 'draft' | 'apply',
|
|
||||||
successMessage: string,
|
return theme ? stringifyTheme(theme) : null;
|
||||||
sourceLabel = 'theme'
|
}
|
||||||
): boolean {
|
|
||||||
|
applyCssOnlyTheme(css: string): boolean {
|
||||||
|
const theme = this.composeDraftThemeWithCss(css);
|
||||||
|
|
||||||
|
if (!theme) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = stringifyTheme(theme);
|
||||||
|
|
||||||
|
this.commitTheme(theme, formatted, 'CSS applied over the JSON theme.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThemeText(text: string, mode: 'draft' | 'apply', successMessage: string, sourceLabel = 'theme'): boolean {
|
||||||
const result = this.parseAndValidateTheme(text, sourceLabel);
|
const result = this.parseAndValidateTheme(text, sourceLabel);
|
||||||
|
|
||||||
if (!result.valid || !result.value) {
|
if (!result.valid || !result.value) {
|
||||||
@@ -250,9 +321,8 @@ export class ThemeService {
|
|||||||
saveActiveThemeText(defaultText);
|
saveActiveThemeText(defaultText);
|
||||||
saveDraftThemeText(defaultText);
|
saveDraftThemeText(defaultText);
|
||||||
this.syncAnimationStylesheet();
|
this.syncAnimationStylesheet();
|
||||||
this.setStatusMessage(reason === 'shortcut'
|
this.syncCssStylesheet();
|
||||||
? 'Theme reset to the default preset by shortcut.'
|
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
|
||||||
: 'Theme reset to the default preset.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGlobalShortcut(event: KeyboardEvent): boolean {
|
handleGlobalShortcut(event: KeyboardEvent): boolean {
|
||||||
@@ -268,41 +338,71 @@ export class ThemeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureElementEntry(key: string): void {
|
ensureElementEntry(key: string): void {
|
||||||
this.updateStructuredDraft((draft) => {
|
if (!this.draftIsValidInternal()) {
|
||||||
draft.elements[key] = draft.elements[key] ?? {};
|
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
|
||||||
}, false, `Prepared ${key} in the theme draft.`);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftJson = JSON.parse(this.draftTextInternal()) as Record<string, unknown>;
|
||||||
|
const existingElements = draftJson['elements'];
|
||||||
|
const elements =
|
||||||
|
existingElements && typeof existingElements === 'object' && !Array.isArray(existingElements)
|
||||||
|
? { ...(existingElements as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
elements[key] = elements[key] ?? {};
|
||||||
|
draftJson['elements'] = elements;
|
||||||
|
|
||||||
|
const result = validateThemeDocument(draftJson);
|
||||||
|
|
||||||
|
if (!result.valid || !result.value) {
|
||||||
|
this.setStatusMessage('The structured change could not be validated.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = JSON.stringify(draftJson, null, 2);
|
||||||
|
|
||||||
|
this.draftThemeInternal.set(result.value);
|
||||||
|
this.draftTextInternal.set(formatted);
|
||||||
|
this.draftIsValidInternal.set(true);
|
||||||
|
this.draftErrorsInternal.set([]);
|
||||||
|
saveDraftThemeText(formatted);
|
||||||
|
this.setStatusMessage(`Prepared ${key} in the theme draft.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureLayoutEntry(key: string): void {
|
ensureLayoutEntry(key: string): void {
|
||||||
this.updateStructuredDraft((draft) => {
|
this.updateStructuredDraft(
|
||||||
const defaults = createDefaultThemeDocument();
|
(draft) => {
|
||||||
|
const defaults = createDefaultThemeLayout();
|
||||||
|
|
||||||
draft.layout[key] = draft.layout[key] ?? defaults.layout[key];
|
draft.layout[key] = draft.layout[key] ?? defaults[key];
|
||||||
}, false, `Prepared ${key} layout in the theme draft.`);
|
},
|
||||||
|
false,
|
||||||
|
`Prepared ${key} layout in the theme draft.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setElementStyle(
|
setElementStyle(key: string, property: ThemeElementStyleProperty, value: string | number, applyImmediately = true): void {
|
||||||
key: string,
|
this.updateStructuredDraft(
|
||||||
property: ThemeElementStyleProperty,
|
(draft) => {
|
||||||
value: string | number,
|
draft.elements[key] = {
|
||||||
applyImmediately = true
|
...draft.elements[key],
|
||||||
): void {
|
[property]: value
|
||||||
this.updateStructuredDraft((draft) => {
|
};
|
||||||
draft.elements[key] = {
|
},
|
||||||
...draft.elements[key],
|
applyImmediately,
|
||||||
[property]: value
|
`${key} updated.`
|
||||||
};
|
);
|
||||||
}, applyImmediately, `${key} updated.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAnimation(
|
setAnimation(key: string, definition: ThemeAnimationDefinition = createAnimationStarterDefinition(), applyImmediately = true): void {
|
||||||
key: string,
|
this.updateStructuredDraft(
|
||||||
definition: ThemeAnimationDefinition = createAnimationStarterDefinition(),
|
(draft) => {
|
||||||
applyImmediately = true
|
draft.animations[key] = definition;
|
||||||
): void {
|
},
|
||||||
this.updateStructuredDraft((draft) => {
|
applyImmediately,
|
||||||
draft.animations[key] = definition;
|
`Animation ${key} updated.`
|
||||||
}, applyImmediately, `Animation ${key} updated.`);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getHostStyles(key: string): Record<string, string> {
|
getHostStyles(key: string): Record<string, string> {
|
||||||
@@ -314,7 +414,8 @@ export class ThemeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
|
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
|
||||||
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
|
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0)
|
||||||
|
.map((layer) => normalizeBackgroundImageLayer(layer));
|
||||||
|
|
||||||
if (backgroundLayers.length > 0) {
|
if (backgroundLayers.length > 0) {
|
||||||
styles['backgroundImage'] = backgroundLayers.join(', ');
|
styles['backgroundImage'] = backgroundLayers.join(', ');
|
||||||
@@ -338,9 +439,7 @@ export class ThemeService {
|
|||||||
getAnimationClass(key: string): string | null {
|
getAnimationClass(key: string): string | null {
|
||||||
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
|
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
|
||||||
|
|
||||||
return animationClass && animationClass.length > 0
|
return animationClass && animationClass.length > 0 ? animationClass : null;
|
||||||
? animationClass
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getLink(key: string): string | null {
|
getLink(key: string): string | null {
|
||||||
@@ -367,17 +466,15 @@ export class ThemeService {
|
|||||||
return {
|
return {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
|
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
|
||||||
gridTemplateRows: container.templateRows ?? (container.rows === 1
|
gridTemplateRows: container.templateRows ?? (container.rows === 1 ? 'minmax(0, 1fr)' : `repeat(${container.rows}, minmax(0, 1fr))`),
|
||||||
? 'minmax(0, 1fr)'
|
|
||||||
: `repeat(${container.rows}, minmax(0, 1fr))`),
|
|
||||||
minHeight: '0',
|
minHeight: '0',
|
||||||
minWidth: '0'
|
minWidth: '0'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getLayoutItemStyles(key: string): Record<string, string> {
|
getLayoutItemStyles(key: string): Record<string, string> {
|
||||||
const defaults = createDefaultThemeDocument();
|
const defaults = createDefaultThemeLayout();
|
||||||
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults.layout[key];
|
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults[key];
|
||||||
|
|
||||||
if (!layoutEntry) {
|
if (!layoutEntry) {
|
||||||
return {};
|
return {};
|
||||||
@@ -391,11 +488,7 @@ export class ThemeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStructuredDraft(
|
updateStructuredDraft(mutator: (draft: ThemeDocument) => void, applyImmediately: boolean, successMessage: string): void {
|
||||||
mutator: (draft: ThemeDocument) => void,
|
|
||||||
applyImmediately: boolean,
|
|
||||||
successMessage: string
|
|
||||||
): void {
|
|
||||||
if (!this.draftIsValidInternal()) {
|
if (!this.draftIsValidInternal()) {
|
||||||
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
|
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
|
||||||
return;
|
return;
|
||||||
@@ -438,9 +531,30 @@ export class ThemeService {
|
|||||||
saveActiveThemeText(text);
|
saveActiveThemeText(text);
|
||||||
saveDraftThemeText(text);
|
saveDraftThemeText(text);
|
||||||
this.syncAnimationStylesheet();
|
this.syncAnimationStylesheet();
|
||||||
|
this.syncCssStylesheet();
|
||||||
this.setStatusMessage(successMessage);
|
this.setStatusMessage(successMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private composeDraftThemeWithCss(css: string): ThemeDocument | null {
|
||||||
|
if (!this.draftIsValidInternal()) {
|
||||||
|
this.setStatusMessage('Fix JSON errors before applying CSS over the theme draft.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
...structuredClone(this.draftThemeInternal()),
|
||||||
|
css
|
||||||
|
};
|
||||||
|
const result = validateThemeDocument(theme);
|
||||||
|
|
||||||
|
if (!result.valid || !result.value) {
|
||||||
|
this.setStatusMessage(result.errors[0] ?? 'The CSS-only theme could not be applied.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
private parseAndValidateTheme(text: string, label: string) {
|
private parseAndValidateTheme(text: string, label: string) {
|
||||||
try {
|
try {
|
||||||
return validateThemeDocument(JSON.parse(text) as unknown);
|
return validateThemeDocument(JSON.parse(text) as unknown);
|
||||||
@@ -465,9 +579,7 @@ export class ThemeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
|
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
|
||||||
const cssVariableName = tokenName === 'radius'
|
const cssVariableName = tokenName === 'radius' ? '--radius' : `--theme-radius-${toKebabCase(tokenName)}`;
|
||||||
? '--radius'
|
|
||||||
: `--theme-radius-${toKebabCase(tokenName)}`;
|
|
||||||
|
|
||||||
styles[cssVariableName] = tokenValue;
|
styles[cssVariableName] = tokenValue;
|
||||||
}
|
}
|
||||||
@@ -495,6 +607,18 @@ export class ThemeService {
|
|||||||
this.animationStyleElement.textContent = css;
|
this.animationStyleElement.textContent = css;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncCssStylesheet(): void {
|
||||||
|
const css = this.activeThemeInternal().css.trim();
|
||||||
|
|
||||||
|
if (!this.cssStyleElement) {
|
||||||
|
this.cssStyleElement = this.documentRef.createElement('style');
|
||||||
|
this.cssStyleElement.setAttribute('data-toju-theme-css', 'true');
|
||||||
|
this.documentRef.head.appendChild(this.cssStyleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cssStyleElement.textContent = css;
|
||||||
|
}
|
||||||
|
|
||||||
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
|
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
|
||||||
const animationClass = `.${className}`;
|
const animationClass = `.${className}`;
|
||||||
const declarationLines = [
|
const declarationLines = [
|
||||||
|
|||||||
@@ -7,15 +7,11 @@ import {
|
|||||||
} from '../logic/theme-schema.logic';
|
} from '../logic/theme-schema.logic';
|
||||||
|
|
||||||
function formatExample(example: string | number): string {
|
function formatExample(example: string | number): string {
|
||||||
return typeof example === 'number'
|
return typeof example === 'number' ? `${example}` : JSON.stringify(example);
|
||||||
? `${example}`
|
|
||||||
: JSON.stringify(example);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLayoutKeysForContainer(containerKey: string): string[] {
|
function getLayoutKeysForContainer(containerKey: string): string[] {
|
||||||
return THEME_REGISTRY
|
return THEME_REGISTRY.filter((entry) => entry.container === containerKey && entry.layoutEditable).map((entry) => entry.key);
|
||||||
.filter((entry) => entry.container === containerKey && entry.layoutEditable)
|
|
||||||
.map((entry) => entry.key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
||||||
@@ -26,9 +22,7 @@ function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
|||||||
entry.supportsIcon ? 'icon' : null
|
entry.supportsIcon ? 'icon' : null
|
||||||
].filter((value): value is string => value !== null);
|
].filter((value): value is string => value !== null);
|
||||||
|
|
||||||
return capabilities.length > 0
|
return capabilities.length > 0 ? capabilities.join(', ') : 'visual style overrides only';
|
||||||
? capabilities.join(', ')
|
|
||||||
: 'visual style overrides only';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeLayoutContainer(container: (typeof THEME_LAYOUT_CONTAINERS)[number]): string {
|
function describeLayoutContainer(container: (typeof THEME_LAYOUT_CONTAINERS)[number]): string {
|
||||||
@@ -56,15 +50,16 @@ function describeThemeEntry(entry: (typeof THEME_REGISTRY)[number]): string {
|
|||||||
const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors);
|
const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors);
|
||||||
const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii);
|
const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii);
|
||||||
const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects);
|
const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects);
|
||||||
const layoutEditableKeys = THEME_REGISTRY
|
const layoutEditableKeys = THEME_REGISTRY.filter((entry) => entry.layoutEditable).map((entry) => entry.key);
|
||||||
.filter((entry) => entry.layoutEditable)
|
const layoutContainerKeys = THEME_LAYOUT_CONTAINERS.map((container) => container.key);
|
||||||
.map((entry) => entry.key);
|
const layoutContainerUnion = layoutContainerKeys.map((key) => `"${key}"`).join(' | ');
|
||||||
const guideTemplateDocument = {
|
const guideTemplateDocument = {
|
||||||
meta: {
|
meta: {
|
||||||
name: 'Theme Name',
|
name: 'Theme Name',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Short mood and material direction.'
|
description: 'Short mood and material direction.'
|
||||||
},
|
},
|
||||||
|
css: '',
|
||||||
tokens: {
|
tokens: {
|
||||||
colors: {
|
colors: {
|
||||||
background: '224 28% 7%',
|
background: '224 28% 7%',
|
||||||
@@ -102,6 +97,9 @@ const guideTemplateDocument = {
|
|||||||
},
|
},
|
||||||
chatRoomMainPanel: {
|
chatRoomMainPanel: {
|
||||||
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||||
|
backgroundImage: '/assets/themes/paper-noise.png',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
borderRadius: 'var(--theme-radius-surface)',
|
borderRadius: 'var(--theme-radius-surface)',
|
||||||
boxShadow: 'var(--theme-effect-panel-shadow)'
|
boxShadow: 'var(--theme-effect-panel-shadow)'
|
||||||
}
|
}
|
||||||
@@ -119,12 +117,13 @@ export const THEME_LLM_GUIDE = [
|
|||||||
'- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.',
|
'- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.',
|
||||||
'',
|
'',
|
||||||
'Core rules',
|
'Core rules',
|
||||||
'- Keep the top-level keys exactly: meta, tokens, layout, elements, animations.',
|
'- Supported top-level keys are meta, css, tokens, layout, elements, and animations. Omit sections that do not need overrides.',
|
||||||
'- Use strict JSON with double-quoted keys, no comments, and no trailing commas.',
|
'- Use strict JSON with double-quoted keys, no comments, and no trailing commas.',
|
||||||
'- Omitted optional keys inherit from the built-in default theme, so leave out anything you are not intentionally changing.',
|
'- Omitted layout keys use the app default placement. Omitted element keys apply no Theme Studio visual override.',
|
||||||
'- Do not invent new top-level sections, layout containers, or element style properties.',
|
'- Do not invent new top-level sections, layout containers, or element style properties.',
|
||||||
'- links must be absolute http or https URLs.',
|
'- links must be absolute http or https URLs.',
|
||||||
'- animationClass must be a safe CSS class token and should match an entry in animations or an existing class already shipped by the app.',
|
'- animationClass must be a safe CSS class token and should match an entry in animations or an existing class already shipped by the app.',
|
||||||
|
'- css is optional raw CSS applied after the JSON theme is active. Keep selectors scoped to app/theme surfaces when possible.',
|
||||||
'- layout.grid values must be integers. x and y are zero-based. w and h must be greater than 0.',
|
'- layout.grid values must be integers. x and y are zero-based. w and h must be greater than 0.',
|
||||||
'- opacity must be a number between 0 and 1.',
|
'- opacity must be a number between 0 and 1.',
|
||||||
'',
|
'',
|
||||||
@@ -139,6 +138,8 @@ export const THEME_LLM_GUIDE = [
|
|||||||
'- tokens.colors entries become CSS variables like --background or --surface-highlight-alt.',
|
'- tokens.colors entries become CSS variables like --background or --surface-highlight-alt.',
|
||||||
'- Color token values should usually be raw HSL channels such as "224 28% 7%". The shell wraps many built-in color tokens with hsl(var(--token)).',
|
'- Color token values should usually be raw HSL channels such as "224 28% 7%". The shell wraps many built-in color tokens with hsl(var(--token)).',
|
||||||
'- You may add extra color tokens if you also reference them with CSS variables in element overrides.',
|
'- You may add extra color tokens if you also reference them with CSS variables in element overrides.',
|
||||||
|
'- elements.<key>.backgroundImage accepts a CSS background-image value, a local image path, or an http/https image URL.',
|
||||||
|
'- Use backgroundSize and backgroundPosition with backgroundImage when the image needs cover/center behavior.',
|
||||||
'- tokens.spacing entries become --theme-spacing-<kebab-case-key>.',
|
'- tokens.spacing entries become --theme-spacing-<kebab-case-key>.',
|
||||||
'- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.',
|
'- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.',
|
||||||
'- tokens.effects entries become --theme-effect-<kebab-case-key>.',
|
'- tokens.effects entries become --theme-effect-<kebab-case-key>.',
|
||||||
@@ -148,8 +149,9 @@ export const THEME_LLM_GUIDE = [
|
|||||||
'',
|
'',
|
||||||
'Top-level schema reference',
|
'Top-level schema reference',
|
||||||
'- meta: { name: string, version: string, description?: string }',
|
'- meta: { name: string, version: string, description?: string }',
|
||||||
|
'- css?: string',
|
||||||
'- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }',
|
'- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }',
|
||||||
'- layout: Record<string, { container: "appShell" | "roomLayout", grid: { x: number, y: number, w: number, h: number } }>',
|
`- layout: Record<string, { container: ${layoutContainerUnion}, grid: { x: number, y: number, w: number, h: number } }>`,
|
||||||
'- elements: Record<string, ThemeElementStyles>',
|
'- elements: Record<string, ThemeElementStyles>',
|
||||||
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
|
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -3,50 +3,41 @@ import {
|
|||||||
ThemeElementStyles,
|
ThemeElementStyles,
|
||||||
ThemeLayoutEntry
|
ThemeLayoutEntry
|
||||||
} from '../models/theme.model';
|
} from '../models/theme.model';
|
||||||
import {
|
import { THEME_LAYOUT_CONTAINERS, getLayoutEditableThemeKeys } from './theme-registry.logic';
|
||||||
THEME_LAYOUT_CONTAINERS,
|
|
||||||
THEME_REGISTRY,
|
|
||||||
getLayoutEditableThemeKeys
|
|
||||||
} from './theme-registry.logic';
|
|
||||||
|
|
||||||
const APP_ROOT_BASE_GRADIENT =
|
function createProvidedDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||||
'radial-gradient(circle at top, '
|
return {
|
||||||
+ 'hsl(var(--surface-highlight) / 0.18), transparent 34%)';
|
serversRail: {
|
||||||
const APP_ROOT_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(5, 8, 15, 0.98), rgba(9, 12, 20, 1))';
|
container: 'appShell',
|
||||||
const APP_ROOT_GRADIENT_LAYERS = [APP_ROOT_BASE_GRADIENT, APP_ROOT_OVERLAY_GRADIENT] as const;
|
grid: { x: 0, y: 0, w: 1, h: 1 }
|
||||||
const APP_WORKSPACE_BASE_GRADIENT =
|
},
|
||||||
'radial-gradient(circle at top right, '
|
appWorkspace: {
|
||||||
+ 'hsl(var(--surface-highlight-alt) / 0.14), transparent 30%)';
|
container: 'appShell',
|
||||||
const APP_WORKSPACE_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(9, 12, 21, 0.96), rgba(7, 10, 18, 1))';
|
grid: { x: 1, y: 0, w: 19, h: 1 }
|
||||||
const APP_WORKSPACE_GRADIENT_LAYERS = [APP_WORKSPACE_BASE_GRADIENT, APP_WORKSPACE_OVERLAY_GRADIENT] as const;
|
},
|
||||||
const CHAT_ROOM_MAIN_PANEL_BASE_GRADIENT =
|
chatRoomChannelsPanel: {
|
||||||
'radial-gradient(circle at top, '
|
container: 'roomLayout',
|
||||||
+ 'hsl(var(--surface-highlight) / 0.12), transparent 28%)';
|
grid: { x: 0, y: 0, w: 4, h: 12 }
|
||||||
const CHAT_ROOM_MAIN_PANEL_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(16, 20, 34, 0.82), rgba(8, 11, 19, 0.92))';
|
},
|
||||||
const CHAT_ROOM_MAIN_PANEL_GRADIENT_LAYERS = [CHAT_ROOM_MAIN_PANEL_BASE_GRADIENT, CHAT_ROOM_MAIN_PANEL_OVERLAY_GRADIENT] as const;
|
chatRoomMainPanel: {
|
||||||
const VOICE_WORKSPACE_BASE_GRADIENT =
|
container: 'roomLayout',
|
||||||
'radial-gradient(circle at top right, '
|
grid: { x: 4, y: 0, w: 12, h: 12 }
|
||||||
+ 'hsl(var(--surface-highlight) / 0.14), transparent 32%)';
|
},
|
||||||
const VOICE_WORKSPACE_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(18, 23, 37, 0.78), rgba(9, 12, 21, 0.88))';
|
chatRoomMembersPanel: {
|
||||||
const VOICE_WORKSPACE_GRADIENT_LAYERS = [VOICE_WORKSPACE_BASE_GRADIENT, VOICE_WORKSPACE_OVERLAY_GRADIENT] as const;
|
container: 'roomLayout',
|
||||||
|
grid: { x: 16, y: 0, w: 4, h: 12 }
|
||||||
function createDefaultElements(): Record<string, ThemeElementStyles> {
|
}
|
||||||
return Object.fromEntries(
|
};
|
||||||
THEME_REGISTRY.map((entry) => [entry.key, {}])
|
|
||||||
) as Record<string, ThemeElementStyles>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
export function createDefaultThemeLayout(): Record<string, ThemeLayoutEntry> {
|
||||||
const layoutEntries: Record<string, ThemeLayoutEntry> = {};
|
const layoutEntries: Record<string, ThemeLayoutEntry> = {};
|
||||||
|
|
||||||
for (const key of getLayoutEditableThemeKeys()) {
|
for (const key of getLayoutEditableThemeKeys()) {
|
||||||
if (key === 'serversRail') {
|
if (key === 'serversRail') {
|
||||||
layoutEntries[key] = {
|
layoutEntries[key] = {
|
||||||
container: 'appShell',
|
container: 'appShell',
|
||||||
grid: { x: 0,
|
grid: { x: 0, y: 0, w: 1, h: 1 }
|
||||||
y: 0,
|
|
||||||
w: 1,
|
|
||||||
h: 1 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -57,10 +48,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
|||||||
|
|
||||||
layoutEntries[key] = {
|
layoutEntries[key] = {
|
||||||
container: 'appShell',
|
container: 'appShell',
|
||||||
grid: { x: 1,
|
grid: { x: 1, y: 0, w: (appShell?.columns ?? 20) - 1, h: 1 }
|
||||||
y: 0,
|
|
||||||
w: (appShell?.columns ?? 20) - 1,
|
|
||||||
h: 1 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -69,10 +57,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
|||||||
if (key === 'chatRoomChannelsPanel') {
|
if (key === 'chatRoomChannelsPanel') {
|
||||||
layoutEntries[key] = {
|
layoutEntries[key] = {
|
||||||
container: 'roomLayout',
|
container: 'roomLayout',
|
||||||
grid: { x: 0,
|
grid: { x: 0, y: 0, w: 4, h: 12 }
|
||||||
y: 0,
|
|
||||||
w: 4,
|
|
||||||
h: 12 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -81,10 +66,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
|||||||
if (key === 'chatRoomMainPanel') {
|
if (key === 'chatRoomMainPanel') {
|
||||||
layoutEntries[key] = {
|
layoutEntries[key] = {
|
||||||
container: 'roomLayout',
|
container: 'roomLayout',
|
||||||
grid: { x: 4,
|
grid: { x: 4, y: 0, w: 12, h: 12 }
|
||||||
y: 0,
|
|
||||||
w: 12,
|
|
||||||
h: 12 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -93,10 +75,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
|||||||
if (key === 'chatRoomMembersPanel') {
|
if (key === 'chatRoomMembersPanel') {
|
||||||
layoutEntries[key] = {
|
layoutEntries[key] = {
|
||||||
container: 'roomLayout',
|
container: 'roomLayout',
|
||||||
grid: { x: 16,
|
grid: { x: 16, y: 0, w: 4, h: 12 }
|
||||||
y: 0,
|
|
||||||
w: 4,
|
|
||||||
h: 12 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -104,11 +83,8 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
|||||||
|
|
||||||
if (key === 'dmConversationsPanel') {
|
if (key === 'dmConversationsPanel') {
|
||||||
layoutEntries[key] = {
|
layoutEntries[key] = {
|
||||||
container: 'roomLayout',
|
container: 'dmLayout',
|
||||||
grid: { x: 0,
|
grid: { x: 0, y: 0, w: 4, h: 12 }
|
||||||
y: 0,
|
|
||||||
w: 4,
|
|
||||||
h: 12 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -116,11 +92,8 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
|||||||
|
|
||||||
if (key === 'dmChatPanel') {
|
if (key === 'dmChatPanel') {
|
||||||
layoutEntries[key] = {
|
layoutEntries[key] = {
|
||||||
container: 'roomLayout',
|
container: 'dmLayout',
|
||||||
grid: { x: 4,
|
grid: { x: 4, y: 0, w: 16, h: 12 }
|
||||||
y: 0,
|
|
||||||
w: 16,
|
|
||||||
h: 12 }
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,105 +101,6 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
|||||||
return layoutEntries;
|
return layoutEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
|
||||||
const elements = createDefaultElements();
|
|
||||||
|
|
||||||
elements['appRoot'] = {
|
|
||||||
backgroundColor: 'hsl(var(--background))',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
gradient: APP_ROOT_GRADIENT_LAYERS.join(', ')
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['serversRail'] = {
|
|
||||||
backgroundColor: 'hsl(var(--rail-background) / 0.96)',
|
|
||||||
gradient: 'linear-gradient(180deg, rgba(10, 14, 25, 0.92), rgba(6, 9, 16, 0.98))',
|
|
||||||
boxShadow: 'inset -1px 0 0 hsl(var(--border) / 0.82), 18px 0 38px rgba(0, 0, 0, 0.22)',
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['appWorkspace'] = {
|
|
||||||
backgroundColor: 'hsl(var(--workspace-background))',
|
|
||||||
gradient: APP_WORKSPACE_GRADIENT_LAYERS.join(', ')
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['titleBar'] = {
|
|
||||||
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
gradient: 'linear-gradient(180deg, rgba(20, 26, 41, 0.52), rgba(7, 10, 18, 0.18))',
|
|
||||||
boxShadow: ['inset 0 -1px 0 hsl(var(--border) / 0.78)', '0 12px 28px rgba(0, 0, 0, 0.18)'].join(', '),
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['chatRoomChannelsPanel'] = {
|
|
||||||
backgroundColor: 'hsl(var(--panel-background) / 0.9)',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
border: '1px solid hsl(var(--border) / 0.7)',
|
|
||||||
borderRadius: 'var(--theme-radius-surface)',
|
|
||||||
gradient: 'linear-gradient(180deg, rgba(18, 24, 38, 0.82), rgba(10, 13, 23, 0.88))',
|
|
||||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['chatRoomMainPanel'] = {
|
|
||||||
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
border: '1px solid hsl(var(--border) / 0.62)',
|
|
||||||
borderRadius: 'var(--theme-radius-surface)',
|
|
||||||
gradient: CHAT_ROOM_MAIN_PANEL_GRADIENT_LAYERS.join(', '),
|
|
||||||
boxShadow: 'var(--theme-effect-panel-shadow)',
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['chatRoomMembersPanel'] = {
|
|
||||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
border: '1px solid hsl(var(--border) / 0.7)',
|
|
||||||
borderRadius: 'var(--theme-radius-surface)',
|
|
||||||
gradient: 'linear-gradient(180deg, rgba(22, 27, 41, 0.82), rgba(11, 14, 24, 0.9))',
|
|
||||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['dmConversationsPanel'] = {
|
|
||||||
...elements['chatRoomChannelsPanel']
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['dmChatPanel'] = {
|
|
||||||
...elements['chatRoomMainPanel']
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['chatRoomEmptyState'] = {
|
|
||||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
|
|
||||||
color: 'hsl(var(--muted-foreground))',
|
|
||||||
border: '1px dashed hsl(var(--border) / 0.7)',
|
|
||||||
borderRadius: 'var(--theme-radius-surface)',
|
|
||||||
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.08), transparent 45%)',
|
|
||||||
boxShadow: 'var(--theme-effect-soft-shadow)'
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['voiceWorkspace'] = {
|
|
||||||
backgroundColor: 'hsl(var(--panel-background) / 0.74)',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
border: '1px solid hsl(var(--border) / 0.62)',
|
|
||||||
borderRadius: 'calc(var(--theme-radius-surface) + 0.2rem)',
|
|
||||||
gradient: VOICE_WORKSPACE_GRADIENT_LAYERS.join(', '),
|
|
||||||
boxShadow: 'var(--theme-effect-panel-shadow)',
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
|
||||||
};
|
|
||||||
|
|
||||||
elements['floatingVoiceControls'] = {
|
|
||||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
border: '1px solid hsl(var(--border) / 0.78)',
|
|
||||||
borderRadius: 'var(--theme-radius-pill)',
|
|
||||||
gradient: 'linear-gradient(180deg, rgba(24, 31, 47, 0.92), rgba(13, 17, 29, 0.96))',
|
|
||||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
|
||||||
};
|
|
||||||
|
|
||||||
return elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasOnlyLegacyRadius(radii: Record<string, string>): boolean {
|
function hasOnlyLegacyRadius(radii: Record<string, string>): boolean {
|
||||||
const keys = Object.keys(radii);
|
const keys = Object.keys(radii);
|
||||||
|
|
||||||
@@ -239,6 +113,7 @@ function allElementsEmpty(elements: Record<string, ThemeElementStyles>): boolean
|
|||||||
|
|
||||||
export function createDefaultThemeDocument(): ThemeDocument {
|
export function createDefaultThemeDocument(): ThemeDocument {
|
||||||
return {
|
return {
|
||||||
|
css: '',
|
||||||
meta: {
|
meta: {
|
||||||
name: 'Toju Default Dark',
|
name: 'Toju Default Dark',
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
@@ -285,23 +160,34 @@ export function createDefaultThemeDocument(): ThemeDocument {
|
|||||||
glassBlur: 'blur(18px) saturate(135%)'
|
glassBlur: 'blur(18px) saturate(135%)'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
layout: createDefaultLayout(),
|
layout: createProvidedDefaultLayout(),
|
||||||
elements: createDarkDefaultElements(),
|
elements: {},
|
||||||
animations: {}
|
animations: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
|
export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
|
||||||
return document.meta.name === 'Toju Default Theme'
|
return (
|
||||||
&& document.meta.version === '1.0.0'
|
document.meta.name === 'Toju Default Theme' &&
|
||||||
&& document.meta.description === 'Safe baseline theme that matches the built-in Toju shell layout.'
|
document.meta.version === '1.0.0' &&
|
||||||
&& Object.keys(document.tokens.colors).length === 0
|
document.meta.description === 'Safe baseline theme that matches the built-in Toju shell layout.' &&
|
||||||
&& Object.keys(document.tokens.spacing).length === 0
|
Object.keys(document.tokens.colors).length === 0 &&
|
||||||
&& hasOnlyLegacyRadius(document.tokens.radii)
|
Object.keys(document.tokens.spacing).length === 0 &&
|
||||||
&& Object.keys(document.tokens.effects).length === 0
|
hasOnlyLegacyRadius(document.tokens.radii) &&
|
||||||
&& allElementsEmpty(document.elements)
|
Object.keys(document.tokens.effects).length === 0 &&
|
||||||
&& Object.keys(document.animations).length === 0;
|
allElementsEmpty(document.elements) &&
|
||||||
|
Object.keys(document.animations).length === 0 &&
|
||||||
|
document.css.length === 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
||||||
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
|
export const DEFAULT_THEME_JSON = JSON.stringify(
|
||||||
|
{
|
||||||
|
meta: DEFAULT_THEME_DOCUMENT.meta,
|
||||||
|
tokens: DEFAULT_THEME_DOCUMENT.tokens,
|
||||||
|
layout: DEFAULT_THEME_DOCUMENT.layout
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[]
|
|||||||
columns: 20,
|
columns: 20,
|
||||||
rows: 12,
|
rows: 12,
|
||||||
templateColumns: 'repeat(4, 4.25rem) repeat(12, minmax(0, 1fr)) repeat(4, 4.25rem)'
|
templateColumns: 'repeat(4, 4.25rem) repeat(12, minmax(0, 1fr)) repeat(4, 4.25rem)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dmLayout',
|
||||||
|
label: 'DM Workspace',
|
||||||
|
description: 'Controls the conversations list and private chat panel inside direct messages.',
|
||||||
|
columns: 20,
|
||||||
|
rows: 12,
|
||||||
|
templateColumns: 'repeat(4, 4.25rem) repeat(16, minmax(0, 1fr))'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -66,6 +74,39 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
|||||||
supportsLink: true,
|
supportsLink: true,
|
||||||
supportsIcon: true
|
supportsIcon: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'serversRailCreateButton',
|
||||||
|
label: 'Create Server Button',
|
||||||
|
description: 'The primary action at the top of the server rail for creating or joining servers.',
|
||||||
|
category: 'shell',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'serversRailList',
|
||||||
|
label: 'Servers List',
|
||||||
|
description: 'The scrollable stack of saved server shortcuts in the left rail.',
|
||||||
|
category: 'shell',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'serversRailItem',
|
||||||
|
label: 'Server Shortcut',
|
||||||
|
description: 'An individual saved server icon button in the left rail.',
|
||||||
|
category: 'shell',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'chatRoomChannelsPanel',
|
key: 'chatRoomChannelsPanel',
|
||||||
label: 'Channels Panel',
|
label: 'Channels Panel',
|
||||||
@@ -78,6 +119,72 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
|||||||
supportsLink: false,
|
supportsLink: false,
|
||||||
supportsIcon: false
|
supportsIcon: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'roomPanelHeader',
|
||||||
|
label: 'Room Panel Header',
|
||||||
|
description: 'The header at the top of the room side panel with server or member context.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roomTextChannelsSection',
|
||||||
|
label: 'Text Channels Section',
|
||||||
|
description: 'The side-panel section that groups text channels.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roomTextChannelItem',
|
||||||
|
label: 'Text Channel Item',
|
||||||
|
description: 'An individual text channel row in the room side panel.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roomVoiceChannelsSection',
|
||||||
|
label: 'Voice Channels Section',
|
||||||
|
description: 'The side-panel section that groups voice channels.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roomVoiceChannelItem',
|
||||||
|
label: 'Voice Channel Item',
|
||||||
|
description: 'An individual voice channel row in the room side panel.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roomVoiceUserItem',
|
||||||
|
label: 'Voice User Item',
|
||||||
|
description: 'A user row nested under a voice channel.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'chatRoomMainPanel',
|
key: 'chatRoomMainPanel',
|
||||||
label: 'Chat Panel',
|
label: 'Chat Panel',
|
||||||
@@ -90,6 +197,182 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
|||||||
supportsLink: false,
|
supportsLink: false,
|
||||||
supportsIcon: false
|
supportsIcon: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'chatSurface',
|
||||||
|
label: 'Chat Surface',
|
||||||
|
description: 'The message area surface shared by room chat views.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatMessageList',
|
||||||
|
label: 'Message List',
|
||||||
|
description: 'The scrollable list that contains message bubbles, date separators, and load-more states.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatDateSeparator',
|
||||||
|
label: 'Date Separator',
|
||||||
|
description: 'The horizontal date divider inserted between groups of chat messages.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatNewMessagesBar',
|
||||||
|
label: 'New Messages Bar',
|
||||||
|
description: 'The sticky prompt for jumping to unread messages.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatMessageBubble',
|
||||||
|
label: 'Message Bubble',
|
||||||
|
description: 'The main row for an individual room or direct-message chat message.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatMessageAvatar',
|
||||||
|
label: 'Message Avatar',
|
||||||
|
description: 'The sender avatar hit target inside each message row.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatMessageContent',
|
||||||
|
label: 'Message Content',
|
||||||
|
description: 'The text and rich content column inside each chat message.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatAttachmentCard',
|
||||||
|
label: 'Attachment Card',
|
||||||
|
description: 'Cards that display attachment transfer, preview, request, and download states.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatReactionPill',
|
||||||
|
label: 'Reaction Pill',
|
||||||
|
description: 'A compact emoji reaction button under a message.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatMessageActions',
|
||||||
|
label: 'Message Actions',
|
||||||
|
description: 'The hover toolbar for reacting, replying, editing, and deleting a message.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatComposerBar',
|
||||||
|
label: 'Composer Bar',
|
||||||
|
description: 'The pinned area that holds reply state, typing indicator, markdown tools, and the message input.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatComposerReplyBar',
|
||||||
|
label: 'Composer Reply Bar',
|
||||||
|
description: 'The reply preview shown above the message composer.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatComposerToolbar',
|
||||||
|
label: 'Composer Toolbar',
|
||||||
|
description: 'The inline markdown formatting toolbar above the composer input.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatComposerInput',
|
||||||
|
label: 'Composer Input',
|
||||||
|
description: 'The textarea region where chat messages are written.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatComposerSendButton',
|
||||||
|
label: 'Send Button',
|
||||||
|
description: 'The send action button inside the message composer.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatGifPickerSurface',
|
||||||
|
label: 'GIF Picker Surface',
|
||||||
|
description: 'The floating GIF picker container opened from the chat composer.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'chatRoomMembersPanel',
|
key: 'chatRoomMembersPanel',
|
||||||
label: 'Members Panel',
|
label: 'Members Panel',
|
||||||
@@ -107,25 +390,102 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
|||||||
label: 'DM Conversations Panel',
|
label: 'DM Conversations Panel',
|
||||||
description: 'The direct-message sidebar showing private chat conversations.',
|
description: 'The direct-message sidebar showing private chat conversations.',
|
||||||
category: 'room',
|
category: 'room',
|
||||||
container: 'roomLayout',
|
container: 'dmLayout',
|
||||||
layoutEditable: true,
|
layoutEditable: true,
|
||||||
pickerVisible: true,
|
pickerVisible: true,
|
||||||
supportsTextOverride: false,
|
supportsTextOverride: false,
|
||||||
supportsLink: false,
|
supportsLink: false,
|
||||||
supportsIcon: false
|
supportsIcon: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'dmConversationsHeader',
|
||||||
|
label: 'DM Conversations Header',
|
||||||
|
description: 'The header above the direct-message conversations list.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dmConversationList',
|
||||||
|
label: 'DM Conversation List',
|
||||||
|
description: 'The scrollable list of direct-message conversations.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dmConversationItem',
|
||||||
|
label: 'DM Conversation Item',
|
||||||
|
description: 'An individual direct-message conversation row.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dmVoiceControlsArea',
|
||||||
|
label: 'DM Voice Controls Area',
|
||||||
|
description: 'The voice controls slot at the bottom of the direct-message sidebar.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'dmChatPanel',
|
key: 'dmChatPanel',
|
||||||
label: 'DM Chat Panel',
|
label: 'DM Chat Panel',
|
||||||
description: 'The main direct-message panel that hosts private chat messages.',
|
description: 'The main direct-message panel that hosts private chat messages.',
|
||||||
category: 'room',
|
category: 'room',
|
||||||
container: 'roomLayout',
|
container: 'dmLayout',
|
||||||
layoutEditable: true,
|
layoutEditable: true,
|
||||||
pickerVisible: true,
|
pickerVisible: true,
|
||||||
supportsTextOverride: false,
|
supportsTextOverride: false,
|
||||||
supportsLink: false,
|
supportsLink: false,
|
||||||
supportsIcon: false
|
supportsIcon: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'dmChatSurface',
|
||||||
|
label: 'DM Chat Surface',
|
||||||
|
description: 'The direct-message chat surface containing the DM header, messages, and composer.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dmChatHeader',
|
||||||
|
label: 'DM Chat Header',
|
||||||
|
description: 'The header above a direct-message conversation.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dmMessageRegion',
|
||||||
|
label: 'DM Message Region',
|
||||||
|
description: 'The absolute region that holds direct-message history and status markers.',
|
||||||
|
category: 'room',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'chatRoomEmptyState',
|
key: 'chatRoomEmptyState',
|
||||||
label: 'Room Empty State',
|
label: 'Room Empty State',
|
||||||
@@ -148,6 +508,39 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
|||||||
supportsLink: false,
|
supportsLink: false,
|
||||||
supportsIcon: false
|
supportsIcon: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'voiceControlsPanel',
|
||||||
|
label: 'Voice Controls Panel',
|
||||||
|
description: 'The voice connection card embedded in sidebars and direct-message views.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'voiceControlsUserRow',
|
||||||
|
label: 'Voice Controls User Row',
|
||||||
|
description: 'The current-user row inside the voice controls panel.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'voiceControlsButtons',
|
||||||
|
label: 'Voice Controls Buttons',
|
||||||
|
description: 'The mute, deafen, camera, screen-share, and disconnect button group.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'floatingVoiceControls',
|
key: 'floatingVoiceControls',
|
||||||
label: 'Floating Voice Controls',
|
label: 'Floating Voice Controls',
|
||||||
@@ -158,6 +551,127 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
|||||||
supportsTextOverride: false,
|
supportsTextOverride: false,
|
||||||
supportsLink: false,
|
supportsLink: false,
|
||||||
supportsIcon: false
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'settingsModalSurface',
|
||||||
|
label: 'Settings Modal Surface',
|
||||||
|
description: 'The main settings modal frame.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'settingsModalNav',
|
||||||
|
label: 'Settings Navigation',
|
||||||
|
description: 'The left navigation column inside Settings.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'settingsModalHeader',
|
||||||
|
label: 'Settings Header',
|
||||||
|
description: 'The title and close-button row at the top of Settings content.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'settingsModalContent',
|
||||||
|
label: 'Settings Content',
|
||||||
|
description: 'The scrollable settings page content area.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contextMenuSurface',
|
||||||
|
label: 'Context Menu Surface',
|
||||||
|
description: 'The floating context menu panel used throughout the app.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'confirmDialogSurface',
|
||||||
|
label: 'Confirm Dialog Surface',
|
||||||
|
description: 'The shared confirmation dialog frame.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profileCardSurface',
|
||||||
|
label: 'Profile Card Surface',
|
||||||
|
description: 'The user profile popover card.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profileCardBanner',
|
||||||
|
label: 'Profile Card Banner',
|
||||||
|
description: 'The decorative banner strip at the top of a profile card.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profileCardBody',
|
||||||
|
label: 'Profile Card Body',
|
||||||
|
description: 'The main text and status content inside a profile card.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'screenShareSourcePicker',
|
||||||
|
label: 'Screen Share Source Picker',
|
||||||
|
description: 'The dialog for choosing a screen or window to share.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'screenShareSourceCard',
|
||||||
|
label: 'Screen Share Source Card',
|
||||||
|
description: 'An individual screen or window option in the source picker.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -170,13 +684,9 @@ export function findThemeLayoutContainer(key: string): ThemeLayoutContainerDefin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLayoutEditableThemeKeys(): string[] {
|
export function getLayoutEditableThemeKeys(): string[] {
|
||||||
return THEME_REGISTRY
|
return THEME_REGISTRY.filter((entry) => entry.layoutEditable).map((entry) => entry.key);
|
||||||
.filter((entry) => entry.layoutEditable)
|
|
||||||
.map((entry) => entry.key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPickerVisibleThemeKeys(): string[] {
|
export function getPickerVisibleThemeKeys(): string[] {
|
||||||
return THEME_REGISTRY
|
return THEME_REGISTRY.filter((entry) => entry.pickerVisible).map((entry) => entry.key);
|
||||||
.filter((entry) => entry.pickerVisible)
|
|
||||||
.map((entry) => entry.key);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import {
|
|||||||
ThemeSchemaField
|
ThemeSchemaField
|
||||||
} from '../models/theme.model';
|
} from '../models/theme.model';
|
||||||
|
|
||||||
const RADIAL_GRADIENT_EXAMPLE =
|
const RADIAL_GRADIENT_EXAMPLE = 'radial-gradient(circle at top, rgba(255,255,255,0.12), ' + 'transparent 60%)';
|
||||||
'radial-gradient(circle at top, rgba(255,255,255,0.12), '
|
|
||||||
+ 'transparent 60%)';
|
|
||||||
|
|
||||||
export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
|
export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
|
||||||
|
{
|
||||||
|
key: 'css',
|
||||||
|
description: 'Optional raw CSS applied after the theme JSON is active.',
|
||||||
|
type: 'string',
|
||||||
|
example: '.theme-settings { outline: 1px solid hsl(var(--primary)); }',
|
||||||
|
examples: ['.theme-settings { outline: 1px solid hsl(var(--primary)); }']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'meta',
|
key: 'meta',
|
||||||
description: 'Theme metadata used for naming, versioning, and describing the preset.',
|
description: 'Theme metadata used for naming, versioning, and describing the preset.',
|
||||||
@@ -27,8 +32,8 @@ export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
|
|||||||
key: 'layout',
|
key: 'layout',
|
||||||
description: 'Grid layout entries for registered moveable surfaces.',
|
description: 'Grid layout entries for registered moveable surfaces.',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
example: '{ "chatRoomMainPanel": { "container": "roomLayout", "grid": { "x": 4, "y": 0, "w": 12, "h": 12 } } }',
|
example: '{ "dmChatPanel": { "container": "dmLayout", "grid": { "x": 4, "y": 0, "w": 16, "h": 12 } } }',
|
||||||
examples: ['{ "chatRoomMainPanel": { "container": "roomLayout", "grid": { "x": 4, "y": 0, "w": 12, "h": 12 } } }']
|
examples: ['{ "dmChatPanel": { "container": "dmLayout", "grid": { "x": 4, "y": 0, "w": 16, "h": 12 } } }']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'elements',
|
key: 'elements',
|
||||||
@@ -83,7 +88,11 @@ export const THEME_LAYOUT_FIELDS: readonly ThemeSchemaField[] = [
|
|||||||
description: 'The registered layout container that owns this grid item.',
|
description: 'The registered layout container that owns this grid item.',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
example: 'roomLayout',
|
example: 'roomLayout',
|
||||||
examples: ['appShell', 'roomLayout']
|
examples: [
|
||||||
|
'appShell',
|
||||||
|
'roomLayout',
|
||||||
|
'dmLayout'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'grid',
|
key: 'grid',
|
||||||
@@ -384,10 +393,14 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'backgroundImage',
|
key: 'backgroundImage',
|
||||||
description: 'CSS background image or image URL.',
|
description: 'CSS background-image value, local image path, or http/https image URL.',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
example: "url('/assets/themes/paper-noise.png')",
|
example: '/assets/themes/paper-noise.png',
|
||||||
examples: ["url('/assets/themes/paper-noise.png')", "url('https://example.com/bg.jpg')"]
|
examples: [
|
||||||
|
'/assets/themes/paper-noise.png',
|
||||||
|
'https://example.com/bg.jpg',
|
||||||
|
"url('/assets/themes/paper-noise.png')"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'backgroundSize',
|
key: 'backgroundSize',
|
||||||
@@ -497,10 +510,7 @@ export function getSuggestedValueOptions(
|
|||||||
return findThemeElementStyleField(field)?.examples ?? [];
|
return findThemeElementStyleField(field)?.examples ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSuggestedFieldDefault(
|
export function getSuggestedFieldDefault(field: ThemeElementStyleProperty, animationKeys: readonly string[] = []): string | number {
|
||||||
field: ThemeElementStyleProperty,
|
|
||||||
animationKeys: readonly string[] = []
|
|
||||||
): string | number {
|
|
||||||
return getSuggestedValueOptions(field, animationKeys)[0] ?? '';
|
return getSuggestedValueOptions(field, animationKeys)[0] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
ThemeAnimationDefinition,
|
ThemeAnimationDefinition,
|
||||||
|
ThemeContainerKey,
|
||||||
ThemeDocument,
|
ThemeDocument,
|
||||||
ThemeElementStyleProperty,
|
ThemeElementStyleProperty,
|
||||||
ThemeElementStyles,
|
ThemeElementStyles,
|
||||||
|
ThemeGridRect,
|
||||||
|
ThemeLayoutEntry,
|
||||||
ThemeValidationResult
|
ThemeValidationResult
|
||||||
} from '../models/theme.model';
|
} from '../models/theme.model';
|
||||||
import { createDefaultThemeDocument } from './theme-defaults.logic';
|
import { createDefaultThemeDocument, createDefaultThemeLayout } from './theme-defaults.logic';
|
||||||
import {
|
import {
|
||||||
THEME_LAYOUT_CONTAINERS,
|
THEME_LAYOUT_CONTAINERS,
|
||||||
THEME_REGISTRY,
|
THEME_REGISTRY,
|
||||||
@@ -13,6 +16,7 @@ import {
|
|||||||
} from './theme-registry.logic';
|
} from './theme-registry.logic';
|
||||||
|
|
||||||
const TOP_LEVEL_KEYS = [
|
const TOP_LEVEL_KEYS = [
|
||||||
|
'css',
|
||||||
'meta',
|
'meta',
|
||||||
'tokens',
|
'tokens',
|
||||||
'layout',
|
'layout',
|
||||||
@@ -103,12 +107,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateUnknownKeys(
|
function validateUnknownKeys(value: Record<string, unknown>, allowedKeys: readonly string[], path: string, errors: string[]): void {
|
||||||
value: Record<string, unknown>,
|
|
||||||
allowedKeys: readonly string[],
|
|
||||||
path: string,
|
|
||||||
errors: string[]
|
|
||||||
): void {
|
|
||||||
for (const key of Object.keys(value)) {
|
for (const key of Object.keys(value)) {
|
||||||
if (!allowedKeys.includes(key)) {
|
if (!allowedKeys.includes(key)) {
|
||||||
errors.push(`${path}.${key} is not part of the supported theme schema.`);
|
errors.push(`${path}.${key} is not part of the supported theme schema.`);
|
||||||
@@ -130,12 +129,7 @@ function validateString(value: unknown, path: string, errors: string[], allowEmp
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateEnum<T extends readonly string[]>(
|
function validateEnum<T extends readonly string[]>(value: unknown, allowedValues: T, path: string, errors: string[]): value is T[number] {
|
||||||
value: unknown,
|
|
||||||
allowedValues: T,
|
|
||||||
path: string,
|
|
||||||
errors: string[]
|
|
||||||
): value is T[number] {
|
|
||||||
if (typeof value !== 'string' || !allowedValues.includes(value)) {
|
if (typeof value !== 'string' || !allowedValues.includes(value)) {
|
||||||
errors.push(`${path} must be one of: ${allowedValues.join(', ')}.`);
|
errors.push(`${path} must be one of: ${allowedValues.join(', ')}.`);
|
||||||
return false;
|
return false;
|
||||||
@@ -144,13 +138,7 @@ function validateEnum<T extends readonly string[]>(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateInteger(
|
function validateInteger(value: unknown, path: string, errors: string[], minimum: number, allowZero: boolean): value is number {
|
||||||
value: unknown,
|
|
||||||
path: string,
|
|
||||||
errors: string[],
|
|
||||||
minimum: number,
|
|
||||||
allowZero: boolean
|
|
||||||
): value is number {
|
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||||
errors.push(`${path} must be an integer.`);
|
errors.push(`${path} must be an integer.`);
|
||||||
return false;
|
return false;
|
||||||
@@ -164,13 +152,7 @@ function validateInteger(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateNumberRange(
|
function validateNumberRange(value: unknown, path: string, errors: string[], minimum: number, maximum: number): value is number {
|
||||||
value: unknown,
|
|
||||||
path: string,
|
|
||||||
errors: string[],
|
|
||||||
minimum: number,
|
|
||||||
maximum: number
|
|
||||||
): value is number {
|
|
||||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||||
errors.push(`${path} must be a number.`);
|
errors.push(`${path} must be a number.`);
|
||||||
return false;
|
return false;
|
||||||
@@ -305,49 +287,56 @@ function validateAnimationDefinition(value: unknown, path: string, errors: strin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normaliseThemeDocument(input: Partial<ThemeDocument>): ThemeDocument {
|
function normaliseThemeDocument(input: Record<string, unknown>): ThemeDocument {
|
||||||
const document = createDefaultThemeDocument();
|
const defaults = createDefaultThemeDocument();
|
||||||
|
const meta = isPlainObject(input['meta']) ? input['meta'] : {};
|
||||||
|
const tokens = isPlainObject(input['tokens']) ? input['tokens'] : {};
|
||||||
|
const layout = isPlainObject(input['layout']) ? input['layout'] : {};
|
||||||
|
|
||||||
document.meta = {
|
return {
|
||||||
...document.meta,
|
css: typeof input['css'] === 'string' ? input['css'] : '',
|
||||||
...input.meta
|
meta: {
|
||||||
|
name: typeof meta['name'] === 'string' ? meta['name'] : defaults.meta.name,
|
||||||
|
version: typeof meta['version'] === 'string' ? meta['version'] : defaults.meta.version,
|
||||||
|
description: typeof meta['description'] === 'string' ? meta['description'] : defaults.meta.description
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
colors: isPlainObject(tokens['colors']) ? (tokens['colors'] as Record<string, string>) : {},
|
||||||
|
spacing: isPlainObject(tokens['spacing']) ? (tokens['spacing'] as Record<string, string>) : {},
|
||||||
|
radii: isPlainObject(tokens['radii']) ? (tokens['radii'] as Record<string, string>) : {},
|
||||||
|
effects: isPlainObject(tokens['effects']) ? (tokens['effects'] as Record<string, string>) : {}
|
||||||
|
},
|
||||||
|
layout: normaliseThemeLayout(layout, createDefaultThemeLayout()),
|
||||||
|
elements: isPlainObject(input['elements']) ? (input['elements'] as Record<string, ThemeElementStyles>) : {},
|
||||||
|
animations: isPlainObject(input['animations']) ? (input['animations'] as Record<string, ThemeAnimationDefinition>) : {}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
document.tokens = {
|
function normaliseThemeLayout(input: Record<string, unknown>, defaults: Record<string, ThemeLayoutEntry>): Record<string, ThemeLayoutEntry> {
|
||||||
colors: {
|
const layout: Record<string, ThemeLayoutEntry> = {};
|
||||||
...document.tokens.colors,
|
|
||||||
...(input.tokens?.colors ?? {})
|
for (const [key, value] of Object.entries(input)) {
|
||||||
},
|
if (!isPlainObject(value)) {
|
||||||
spacing: {
|
continue;
|
||||||
...document.tokens.spacing,
|
|
||||||
...(input.tokens?.spacing ?? {})
|
|
||||||
},
|
|
||||||
radii: {
|
|
||||||
...document.tokens.radii,
|
|
||||||
...(input.tokens?.radii ?? {})
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
...document.tokens.effects,
|
|
||||||
...(input.tokens?.effects ?? {})
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
document.layout = {
|
const defaultEntry = defaults[key];
|
||||||
...document.layout,
|
const grid = isPlainObject(value['grid']) ? (value['grid'] as Partial<ThemeGridRect>) : {};
|
||||||
...(input.layout ?? {})
|
|
||||||
};
|
|
||||||
|
|
||||||
document.elements = {
|
if (!defaultEntry) {
|
||||||
...document.elements,
|
continue;
|
||||||
...(input.elements ?? {})
|
}
|
||||||
};
|
|
||||||
|
|
||||||
document.animations = {
|
layout[key] = {
|
||||||
...document.animations,
|
container: typeof value['container'] === 'string' ? (value['container'] as ThemeContainerKey) : defaultEntry.container,
|
||||||
...(input.animations ?? {})
|
grid: {
|
||||||
};
|
...defaultEntry.grid,
|
||||||
|
...grid
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return document;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
||||||
@@ -363,14 +352,24 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
|||||||
|
|
||||||
validateUnknownKeys(input, TOP_LEVEL_KEYS, 'theme', errors);
|
validateUnknownKeys(input, TOP_LEVEL_KEYS, 'theme', errors);
|
||||||
|
|
||||||
|
if (input['css'] !== undefined) {
|
||||||
|
validateString(input['css'], 'theme.css', errors, true);
|
||||||
|
}
|
||||||
|
|
||||||
const meta = input['meta'];
|
const meta = input['meta'];
|
||||||
|
|
||||||
if (!isPlainObject(meta)) {
|
if (meta !== undefined && !isPlainObject(meta)) {
|
||||||
errors.push('theme.meta must be an object.');
|
errors.push('theme.meta must be an object.');
|
||||||
} else {
|
} else if (isPlainObject(meta)) {
|
||||||
validateUnknownKeys(meta, META_KEYS, 'theme.meta', errors);
|
validateUnknownKeys(meta, META_KEYS, 'theme.meta', errors);
|
||||||
validateString(meta['name'], 'theme.meta.name', errors);
|
|
||||||
validateString(meta['version'], 'theme.meta.version', errors);
|
if (meta['name'] !== undefined) {
|
||||||
|
validateString(meta['name'], 'theme.meta.name', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta['version'] !== undefined) {
|
||||||
|
validateString(meta['version'], 'theme.meta.version', errors);
|
||||||
|
}
|
||||||
|
|
||||||
if (meta['description'] !== undefined) {
|
if (meta['description'] !== undefined) {
|
||||||
validateString(meta['description'], 'theme.meta.description', errors, true);
|
validateString(meta['description'], 'theme.meta.description', errors, true);
|
||||||
@@ -492,6 +491,6 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
|||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
errors: [],
|
errors: [],
|
||||||
value: normaliseThemeDocument(input as Partial<ThemeDocument>)
|
value: normaliseThemeDocument(input)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ThemeContainerKey = 'appShell' | 'roomLayout';
|
export type ThemeContainerKey = 'appShell' | 'roomLayout' | 'dmLayout';
|
||||||
|
|
||||||
export interface ThemeMeta {
|
export interface ThemeMeta {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -70,6 +70,7 @@ export interface ThemeAnimationDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeDocument {
|
export interface ThemeDocument {
|
||||||
|
css: string;
|
||||||
meta: ThemeMeta;
|
meta: ThemeMeta;
|
||||||
tokens: ThemeTokenGroups;
|
tokens: ThemeTokenGroups;
|
||||||
layout: Record<string, ThemeLayoutEntry>;
|
layout: Record<string, ThemeLayoutEntry>;
|
||||||
|
|||||||
@@ -15,11 +15,18 @@
|
|||||||
class="theme-grid-editor__frame relative overflow-hidden rounded-lg border border-border/80"
|
class="theme-grid-editor__frame relative overflow-hidden rounded-lg border border-border/80"
|
||||||
[ngStyle]="frameStyle()"
|
[ngStyle]="frameStyle()"
|
||||||
>
|
>
|
||||||
<div class="theme-grid-editor__grid"></div>
|
<div
|
||||||
|
class="theme-grid-editor__grid"
|
||||||
|
[ngStyle]="frameStyle()"
|
||||||
|
>
|
||||||
|
@for (cell of gridCells(); track cell) {
|
||||||
|
<div class="theme-grid-editor__cell"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@for (item of items(); track item.key) {
|
@for (item of items(); track item.key) {
|
||||||
<div
|
<div
|
||||||
class="theme-grid-editor__item absolute"
|
class="theme-grid-editor__item"
|
||||||
[class.theme-grid-editor__item--selected]="selectedKey() === item.key"
|
[class.theme-grid-editor__item--selected]="selectedKey() === item.key"
|
||||||
[class.theme-grid-editor__item--disabled]="disabled()"
|
[class.theme-grid-editor__item--disabled]="disabled()"
|
||||||
[ngStyle]="itemStyle(item)"
|
[ngStyle]="itemStyle(item)"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-grid-editor__frame {
|
.theme-grid-editor__frame {
|
||||||
|
display: grid;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top, hsl(var(--primary) / 0.05), transparent 45%),
|
radial-gradient(circle at top, hsl(var(--primary) / 0.05), transparent 45%),
|
||||||
@@ -12,15 +13,18 @@
|
|||||||
.theme-grid-editor__grid {
|
.theme-grid-editor__grid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
display: grid;
|
||||||
linear-gradient(to right, hsl(var(--border) / 0.65) 1px, transparent 1px),
|
pointer-events: none;
|
||||||
linear-gradient(to bottom, hsl(var(--border) / 0.65) 1px, transparent 1px);
|
}
|
||||||
background-size:
|
|
||||||
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows)),
|
.theme-grid-editor__cell {
|
||||||
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows));
|
border-top: 1px solid hsl(var(--border) / 0.65);
|
||||||
|
border-left: 1px solid hsl(var(--border) / 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-grid-editor__item {
|
.theme-grid-editor__item {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
padding: 0.35rem;
|
padding: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,11 @@ export class ThemeGridEditorComponent {
|
|||||||
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
|
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
|
||||||
readonly frameStyle = computed(() => ({
|
readonly frameStyle = computed(() => ({
|
||||||
'--theme-grid-columns': `${this.container().columns}`,
|
'--theme-grid-columns': `${this.container().columns}`,
|
||||||
'--theme-grid-rows': `${this.container().rows}`
|
'--theme-grid-rows': `${this.container().rows}`,
|
||||||
|
gridTemplateColumns: this.container().templateColumns ?? `repeat(${this.container().columns}, minmax(0, 1fr))`,
|
||||||
|
gridTemplateRows: this.container().templateRows ?? `repeat(${this.container().rows}, minmax(0, 1fr))`
|
||||||
}));
|
}));
|
||||||
|
readonly gridCells = computed(() => Array.from({ length: this.container().columns * this.container().rows }, (_, index) => index));
|
||||||
|
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
private dragState: DragState | null = null;
|
private dragState: DragState | null = null;
|
||||||
@@ -57,11 +60,8 @@ export class ThemeGridEditorComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
|
const deltaColumns = this.gridDelta('columns', this.dragState.startClientX, event.clientX);
|
||||||
const columnWidth = canvasRect.width / this.container().columns;
|
const deltaRows = this.gridDelta('rows', this.dragState.startClientY, event.clientY);
|
||||||
const rowHeight = canvasRect.height / this.container().rows;
|
|
||||||
const deltaColumns = Math.round((event.clientX - this.dragState.startClientX) / columnWidth);
|
|
||||||
const deltaRows = Math.round((event.clientY - this.dragState.startClientY) / rowHeight);
|
|
||||||
const nextGrid = { ...this.dragState.startGrid };
|
const nextGrid = { ...this.dragState.startGrid };
|
||||||
|
|
||||||
if (this.dragState.mode === 'move') {
|
if (this.dragState.mode === 'move') {
|
||||||
@@ -90,13 +90,9 @@ export class ThemeGridEditorComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
itemStyle(item: ThemeGridEditorItem): Record<string, string> {
|
itemStyle(item: ThemeGridEditorItem): Record<string, string> {
|
||||||
const { columns, rows } = this.container();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: `${(item.grid.x / columns) * 100}%`,
|
gridColumn: `${item.grid.x + 1} / span ${item.grid.w}`,
|
||||||
top: `${(item.grid.y / rows) * 100}%`,
|
gridRow: `${item.grid.y + 1} / span ${item.grid.h}`
|
||||||
width: `${(item.grid.w / columns) * 100}%`,
|
|
||||||
height: `${(item.grid.h / rows) * 100}%`
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,4 +128,61 @@ export class ThemeGridEditorComponent {
|
|||||||
private clamp(value: number, minimum: number, maximum: number): number {
|
private clamp(value: number, minimum: number, maximum: number): number {
|
||||||
return Math.min(Math.max(value, minimum), maximum);
|
return Math.min(Math.max(value, minimum), maximum);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private gridDelta(axis: 'columns' | 'rows', startClientPosition: number, currentClientPosition: number): number {
|
||||||
|
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
|
||||||
|
const linePositions = this.measureGridLines(axis, canvasRect);
|
||||||
|
const startOffset = startClientPosition - (axis === 'columns' ? canvasRect.left : canvasRect.top);
|
||||||
|
const currentOffset = currentClientPosition - (axis === 'columns' ? canvasRect.left : canvasRect.top);
|
||||||
|
|
||||||
|
return this.nearestGridLineIndex(currentOffset, linePositions) - this.nearestGridLineIndex(startOffset, linePositions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private measureGridLines(axis: 'columns' | 'rows', canvasRect: DOMRect): number[] {
|
||||||
|
const canvas = this.canvasRef().nativeElement;
|
||||||
|
const computedStyles = getComputedStyle(canvas);
|
||||||
|
const expectedTracks = axis === 'columns' ? this.container().columns : this.container().rows;
|
||||||
|
const availableSize = axis === 'columns' ? canvasRect.width : canvasRect.height;
|
||||||
|
const template = axis === 'columns' ? computedStyles.gridTemplateColumns : computedStyles.gridTemplateRows;
|
||||||
|
const trackSizes = template
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((track) => Number.parseFloat(track))
|
||||||
|
.filter((size) => Number.isFinite(size) && size > 0);
|
||||||
|
|
||||||
|
if (trackSizes.length !== expectedTracks) {
|
||||||
|
return Array.from({ length: expectedTracks + 1 }, (_, index) => (availableSize / expectedTracks) * index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTrackSize = trackSizes.reduce((sum, size) => sum + size, 0);
|
||||||
|
const scale = totalTrackSize > 0 ? availableSize / totalTrackSize : 1;
|
||||||
|
const linePositions = [0];
|
||||||
|
|
||||||
|
for (const trackSize of trackSizes) {
|
||||||
|
const previousLinePosition = linePositions[linePositions.length - 1] ?? 0;
|
||||||
|
|
||||||
|
linePositions.push(previousLinePosition + trackSize * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
linePositions[linePositions.length - 1] = availableSize;
|
||||||
|
|
||||||
|
return linePositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nearestGridLineIndex(offset: number, linePositions: number[]): number {
|
||||||
|
const clampedOffset = this.clamp(offset, linePositions[0] ?? 0, linePositions.at(-1) ?? 0);
|
||||||
|
|
||||||
|
let nearestIndex = 0;
|
||||||
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let index = 0; index < linePositions.length; index++) {
|
||||||
|
const distance = Math.abs(linePositions[index] - clampedOffset);
|
||||||
|
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestDistance = distance;
|
||||||
|
nearestIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearestIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,75 +11,149 @@ import {
|
|||||||
viewChild
|
viewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { indentWithTab } from '@codemirror/commands';
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { css } from '@codemirror/lang-css';
|
||||||
import { indentUnit } from '@codemirror/language';
|
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
import { indentUnit, syntaxTree } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
Diagnostic,
|
||||||
|
lintGutter,
|
||||||
|
linter
|
||||||
|
} from '@codemirror/lint';
|
||||||
|
import {
|
||||||
|
EditorSelection,
|
||||||
|
EditorState,
|
||||||
|
Extension
|
||||||
|
} from '@codemirror/state';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
import { basicSetup } from 'codemirror';
|
import { basicSetup } from 'codemirror';
|
||||||
|
|
||||||
const THEME_JSON_EDITOR_THEME = EditorView.theme({
|
import { formatPastedJsonText } from './theme-json-format.logic';
|
||||||
'&': {
|
|
||||||
height: '100%',
|
type ThemeCodeEditorLanguage = 'json' | 'css';
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: '#e7eef9'
|
const ERROR_SQUIGGLE_BACKGROUND =
|
||||||
|
'linear-gradient(45deg, transparent 65%, #fb7185 65%, #fb7185 80%, transparent 80%), '
|
||||||
|
+ 'linear-gradient(135deg, transparent 65%, #fb7185 65%, #fb7185 80%, transparent 80%)';
|
||||||
|
const THEME_JSON_EDITOR_THEME = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#e7eef9'
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: 'none'
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
fontFamily: "'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
lineHeight: '1.55'
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: '1rem 0',
|
||||||
|
caretColor: '#f8fafc'
|
||||||
|
},
|
||||||
|
'.cm-line': {
|
||||||
|
padding: '0 1rem 0 0.5rem'
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
|
borderLeftColor: '#f8fafc'
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
minHeight: '100%',
|
||||||
|
borderRight: '1px solid #2f405c',
|
||||||
|
backgroundColor: '#172033',
|
||||||
|
color: '#7b8aa5'
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: 'rgb(148 163 184 / 0.08)'
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter': {
|
||||||
|
backgroundColor: 'rgb(148 163 184 / 0.12)',
|
||||||
|
color: '#d7e2f2'
|
||||||
|
},
|
||||||
|
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: 'rgb(96 165 250 / 0.22)'
|
||||||
|
},
|
||||||
|
'.cm-panels': {
|
||||||
|
backgroundColor: '#111827',
|
||||||
|
color: '#e5eefc',
|
||||||
|
borderBottom: '1px solid #2f405c'
|
||||||
|
},
|
||||||
|
'.cm-searchMatch': {
|
||||||
|
backgroundColor: 'rgb(250 204 21 / 0.18)',
|
||||||
|
outline: '1px solid rgb(250 204 21 / 0.32)'
|
||||||
|
},
|
||||||
|
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||||
|
backgroundColor: 'rgb(250 204 21 / 0.28)'
|
||||||
|
},
|
||||||
|
'.cm-tooltip': {
|
||||||
|
border: '1px solid #314158',
|
||||||
|
backgroundColor: '#111827'
|
||||||
|
},
|
||||||
|
'.cm-tooltip-autocomplete ul li[aria-selected]': {
|
||||||
|
backgroundColor: 'rgb(96 165 250 / 0.18)',
|
||||||
|
color: '#f8fafc'
|
||||||
|
},
|
||||||
|
'.cm-diagnosticRange-error': {
|
||||||
|
backgroundImage: ERROR_SQUIGGLE_BACKGROUND,
|
||||||
|
backgroundPosition: '0 100%',
|
||||||
|
backgroundRepeat: 'repeat-x',
|
||||||
|
backgroundSize: '8px 3px',
|
||||||
|
paddingBottom: '2px'
|
||||||
|
},
|
||||||
|
'.cm-lintRange-error': {
|
||||||
|
backgroundImage: ERROR_SQUIGGLE_BACKGROUND,
|
||||||
|
backgroundPosition: '0 100%',
|
||||||
|
backgroundRepeat: 'repeat-x',
|
||||||
|
backgroundSize: '8px 3px',
|
||||||
|
paddingBottom: '2px'
|
||||||
|
},
|
||||||
|
'.cm-lintPoint-error:after': {
|
||||||
|
borderBottomColor: '#fb7185'
|
||||||
|
},
|
||||||
|
'.cm-diagnostic': {
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif'
|
||||||
|
},
|
||||||
|
'.cm-diagnostic-error': {
|
||||||
|
borderLeftColor: '#fb7185'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'&.cm-focused': {
|
{ dark: true }
|
||||||
outline: 'none'
|
);
|
||||||
},
|
|
||||||
'.cm-scroller': {
|
function getLanguageExtensions(language: ThemeCodeEditorLanguage): Extension[] {
|
||||||
overflow: 'auto',
|
if (language === 'css') {
|
||||||
fontFamily: "'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace",
|
return [css(), linter(cssSyntaxLinter, { delay: 250 })];
|
||||||
lineHeight: '1.55'
|
|
||||||
},
|
|
||||||
'.cm-content': {
|
|
||||||
minHeight: '100%',
|
|
||||||
padding: '1rem 0',
|
|
||||||
caretColor: '#f8fafc'
|
|
||||||
},
|
|
||||||
'.cm-line': {
|
|
||||||
padding: '0 1rem 0 0.5rem'
|
|
||||||
},
|
|
||||||
'.cm-cursor, .cm-dropCursor': {
|
|
||||||
borderLeftColor: '#f8fafc'
|
|
||||||
},
|
|
||||||
'.cm-gutters': {
|
|
||||||
minHeight: '100%',
|
|
||||||
borderRight: '1px solid #2f405c',
|
|
||||||
backgroundColor: '#172033',
|
|
||||||
color: '#7b8aa5'
|
|
||||||
},
|
|
||||||
'.cm-activeLine': {
|
|
||||||
backgroundColor: 'rgb(148 163 184 / 0.08)'
|
|
||||||
},
|
|
||||||
'.cm-activeLineGutter': {
|
|
||||||
backgroundColor: 'rgb(148 163 184 / 0.12)',
|
|
||||||
color: '#d7e2f2'
|
|
||||||
},
|
|
||||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
|
|
||||||
backgroundColor: 'rgb(96 165 250 / 0.22)'
|
|
||||||
},
|
|
||||||
'.cm-panels': {
|
|
||||||
backgroundColor: '#111827',
|
|
||||||
color: '#e5eefc',
|
|
||||||
borderBottom: '1px solid #2f405c'
|
|
||||||
},
|
|
||||||
'.cm-searchMatch': {
|
|
||||||
backgroundColor: 'rgb(250 204 21 / 0.18)',
|
|
||||||
outline: '1px solid rgb(250 204 21 / 0.32)'
|
|
||||||
},
|
|
||||||
'.cm-searchMatch.cm-searchMatch-selected': {
|
|
||||||
backgroundColor: 'rgb(250 204 21 / 0.28)'
|
|
||||||
},
|
|
||||||
'.cm-tooltip': {
|
|
||||||
border: '1px solid #314158',
|
|
||||||
backgroundColor: '#111827'
|
|
||||||
},
|
|
||||||
'.cm-tooltip-autocomplete ul li[aria-selected]': {
|
|
||||||
backgroundColor: 'rgb(96 165 250 / 0.18)',
|
|
||||||
color: '#f8fafc'
|
|
||||||
}
|
}
|
||||||
}, { dark: true });
|
|
||||||
|
return [json(), linter(jsonParseLinter(), { delay: 250 })];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssSyntaxLinter(view: EditorView): Diagnostic[] {
|
||||||
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
const documentLength = view.state.doc.length;
|
||||||
|
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
enter: (node) => {
|
||||||
|
if (!node.type.isError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics.push({
|
||||||
|
from: node.from,
|
||||||
|
to: Math.max(node.to, Math.min(node.from + 1, documentLength)),
|
||||||
|
severity: 'error',
|
||||||
|
source: 'CSS',
|
||||||
|
message: 'CSS syntax error.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-theme-json-code-editor',
|
selector: 'app-theme-json-code-editor',
|
||||||
@@ -91,9 +165,10 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
|||||||
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
|
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
|
||||||
readonly value = input.required<string>();
|
readonly value = input.required<string>();
|
||||||
readonly fullscreen = input(false);
|
readonly fullscreen = input(false);
|
||||||
|
readonly language = input<ThemeCodeEditorLanguage>('json');
|
||||||
readonly valueChange = output<string>();
|
readonly valueChange = output<string>();
|
||||||
|
|
||||||
readonly editorMinHeight = computed(() => this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem');
|
readonly editorMinHeight = computed(() => (this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem'));
|
||||||
|
|
||||||
private readonly zone = inject(NgZone);
|
private readonly zone = inject(NgZone);
|
||||||
|
|
||||||
@@ -173,6 +248,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
|||||||
|
|
||||||
private createEditor(host: HTMLDivElement): void {
|
private createEditor(host: HTMLDivElement): void {
|
||||||
this.zone.runOutsideAngular(() => {
|
this.zone.runOutsideAngular(() => {
|
||||||
|
const language = this.language();
|
||||||
|
|
||||||
this.editorView = new EditorView({
|
this.editorView = new EditorView({
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
doc: this.value(),
|
doc: this.value(),
|
||||||
@@ -180,7 +257,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
|||||||
basicSetup,
|
basicSetup,
|
||||||
keymap.of([indentWithTab]),
|
keymap.of([indentWithTab]),
|
||||||
indentUnit.of(' '),
|
indentUnit.of(' '),
|
||||||
json(),
|
...getLanguageExtensions(language),
|
||||||
|
lintGutter(),
|
||||||
oneDark,
|
oneDark,
|
||||||
THEME_JSON_EDITOR_THEME,
|
THEME_JSON_EDITOR_THEME,
|
||||||
EditorState.tabSize.of(2),
|
EditorState.tabSize.of(2),
|
||||||
@@ -188,7 +266,25 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
|||||||
spellcheck: 'false',
|
spellcheck: 'false',
|
||||||
autocapitalize: 'off',
|
autocapitalize: 'off',
|
||||||
autocorrect: 'off',
|
autocorrect: 'off',
|
||||||
'aria-label': 'Theme JSON editor'
|
'aria-label': language === 'css' ? 'Theme CSS editor' : 'Theme JSON editor'
|
||||||
|
}),
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
paste: (event, view) => {
|
||||||
|
if (language !== 'json') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pastedText = event.clipboardData?.getData('application/json') || event.clipboardData?.getData('text/plain') || '';
|
||||||
|
const formattedText = formatPastedJsonText(pastedText);
|
||||||
|
|
||||||
|
if (!formattedText) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
view.dispatch(view.state.replaceSelection(formattedText));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (!update.docChanged || this.isApplyingExternalValue) {
|
if (!update.docChanged || this.isApplyingExternalValue) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { formatPastedJsonText } from './theme-json-format.logic';
|
||||||
|
|
||||||
|
describe('theme JSON paste formatting', () => {
|
||||||
|
it('formats pasted JSON with two-space indentation', () => {
|
||||||
|
expect(formatPastedJsonText('{"meta":{"name":"Paste","version":"1"},"tokens":{"colors":{"background":"1 2% 3%"}}}')).toBe([
|
||||||
|
'{',
|
||||||
|
' "meta": {',
|
||||||
|
' "name": "Paste",',
|
||||||
|
' "version": "1"',
|
||||||
|
' },',
|
||||||
|
' "tokens": {',
|
||||||
|
' "colors": {',
|
||||||
|
' "background": "1 2% 3%"',
|
||||||
|
' }',
|
||||||
|
' }',
|
||||||
|
'}'
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves non-JSON paste content to the editor default', () => {
|
||||||
|
expect(formatPastedJsonText('backgroundColor: red')).toBeNull();
|
||||||
|
expect(formatPastedJsonText('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function formatPastedJsonText(text: string): string | null {
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
|
||||||
|
if (!trimmedText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(trimmedText) as unknown, null, 2);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@
|
|||||||
>
|
>
|
||||||
Format JSON
|
Format JSON
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="jumpToCss()"
|
||||||
|
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
Open CSS
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="copyLlmThemeGuide()"
|
(click)="copyLlmThemeGuide()"
|
||||||
@@ -33,13 +40,35 @@
|
|||||||
>
|
>
|
||||||
Copy LLM Guide
|
Copy LLM Guide
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="themeFileInput.click()"
|
||||||
|
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
Import File
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="exportThemeFile()"
|
||||||
|
[disabled]="!draftIsValid()"
|
||||||
|
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Export File
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
#themeFileInput
|
||||||
|
type="file"
|
||||||
|
accept=".json,.css,application/json,text/css"
|
||||||
|
class="hidden"
|
||||||
|
(change)="importThemeFile($event)"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="applyDraft()"
|
(click)="applyDraft()"
|
||||||
[disabled]="!draftIsValid()"
|
[disabled]="!draftIsValid()"
|
||||||
class="inline-flex items-center rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
class="inline-flex items-center rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
Apply Draft
|
{{ activeEditorTab() === 'cssOnly' ? 'Apply CSS Theme' : 'Apply Draft' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -59,24 +88,8 @@
|
|||||||
|
|
||||||
<div class="theme-settings__hero-grid mt-4">
|
<div class="theme-settings__hero-grid mt-4">
|
||||||
<div class="theme-settings__hero-stat">
|
<div class="theme-settings__hero-stat">
|
||||||
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--compact">
|
<span class="theme-settings__hero-label">Workspace</span>
|
||||||
<label
|
<strong class="theme-settings__hero-value">{{ activeWorkspaceInfo().label }}</strong>
|
||||||
for="theme-studio-workspace-select"
|
|
||||||
class="theme-settings__workspace-selector-label"
|
|
||||||
>
|
|
||||||
Workspace
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="theme-studio-workspace-select"
|
|
||||||
class="theme-settings__workspace-select"
|
|
||||||
[value]="activeWorkspace()"
|
|
||||||
(change)="onWorkspaceSelect($event)"
|
|
||||||
>
|
|
||||||
@for (workspace of workspaceTabs; track workspace.key) {
|
|
||||||
<option [value]="workspace.key">{{ workspace.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-settings__hero-stat">
|
<div class="theme-settings__hero-stat">
|
||||||
<span class="theme-settings__hero-label">Regions</span>
|
<span class="theme-settings__hero-label">Regions</span>
|
||||||
@@ -110,6 +123,43 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<nav
|
||||||
|
class="theme-settings__workspace-tabs mt-4"
|
||||||
|
aria-label="Theme Studio workspace"
|
||||||
|
>
|
||||||
|
@for (workspace of workspaceTabs; track workspace.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setWorkspace(workspace.key)"
|
||||||
|
class="theme-settings__workspace-tab"
|
||||||
|
[class.theme-settings__workspace-tab--active]="activeWorkspace() === workspace.key"
|
||||||
|
[attr.aria-current]="activeWorkspace() === workspace.key ? 'page' : null"
|
||||||
|
>
|
||||||
|
<span class="theme-settings__workspace-tab-label">{{ workspace.label }}</span>
|
||||||
|
<span class="theme-settings__workspace-tab-description">{{ workspace.description }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--mobile mt-4">
|
||||||
|
<label
|
||||||
|
for="theme-studio-workspace-select"
|
||||||
|
class="theme-settings__workspace-selector-label"
|
||||||
|
>
|
||||||
|
Workspace
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="theme-studio-workspace-select"
|
||||||
|
class="theme-settings__workspace-select"
|
||||||
|
[value]="activeWorkspace()"
|
||||||
|
(change)="onWorkspaceSelect($event)"
|
||||||
|
>
|
||||||
|
@for (workspace of workspaceTabs; track workspace.key) {
|
||||||
|
<option [value]="workspace.key">{{ workspace.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="theme-settings__workspace min-h-0 flex-1">
|
<div class="theme-settings__workspace min-h-0 flex-1">
|
||||||
@@ -222,7 +272,7 @@
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="theme-studio-card p-3.5">
|
<section class="theme-studio-card theme-settings__explorer-card p-3.5">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<p class="text-sm font-semibold text-foreground">Explorer</p>
|
<p class="text-sm font-semibold text-foreground">Explorer</p>
|
||||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||||
@@ -240,7 +290,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="theme-settings__entry-list mt-4">
|
<div class="theme-settings__entry-list theme-settings__explorer-list mt-4">
|
||||||
@for (entry of filteredEntries(); track entry.key) {
|
@for (entry of filteredEntries(); track entry.key) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -318,23 +368,70 @@
|
|||||||
@if (activeWorkspace() === 'editor') {
|
@if (activeWorkspace() === 'editor') {
|
||||||
<section class="theme-studio-card theme-settings__editor-card p-4">
|
<section class="theme-studio-card theme-settings__editor-card p-4">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
|
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
|
||||||
<p class="text-sm font-semibold text-foreground">Theme JSON</p>
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-foreground">
|
||||||
|
{{ activeEditorTab() === 'cssOnly' ? 'CSS-Only Theme' : 'Theme JSON' }}
|
||||||
|
</p>
|
||||||
|
@if (activeEditorTab() === 'cssOnly') {
|
||||||
|
<p class="mt-1 text-xs leading-5 text-muted-foreground">CSS here is applied over the built-in default JSON theme.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
||||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftLineCount() }} lines</span>
|
<span class="rounded-md bg-secondary px-2.5 py-1"
|
||||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftCharacterCount() }} chars</span>
|
>{{ activeEditorTab() === 'cssOnly' ? cssOnlyText().split('\n').length : draftLineCount() }} lines</span
|
||||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
>
|
||||||
|
<span class="rounded-md bg-secondary px-2.5 py-1"
|
||||||
|
>{{ activeEditorTab() === 'cssOnly' ? cssOnlyText().length : draftCharacterCount() }} chars</span
|
||||||
|
>
|
||||||
|
@if (activeEditorTab() === 'json') {
|
||||||
|
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
||||||
|
}
|
||||||
<span class="rounded-md bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
|
<span class="rounded-md bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setEditorTab('json')"
|
||||||
|
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
[class.border-primary/40]="activeEditorTab() === 'json'"
|
||||||
|
[class.bg-primary/10]="activeEditorTab() === 'json'"
|
||||||
|
[attr.aria-current]="activeEditorTab() === 'json' ? 'page' : null"
|
||||||
|
>
|
||||||
|
JSON Theme
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setEditorTab('cssOnly')"
|
||||||
|
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
[class.border-primary/40]="activeEditorTab() === 'cssOnly'"
|
||||||
|
[class.bg-primary/10]="activeEditorTab() === 'cssOnly'"
|
||||||
|
[attr.aria-current]="activeEditorTab() === 'cssOnly' ? 'page' : null"
|
||||||
|
>
|
||||||
|
CSS Only
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="theme-settings__editor-panel pt-3">
|
<div class="theme-settings__editor-panel pt-3">
|
||||||
<app-theme-json-code-editor
|
@if (activeEditorTab() === 'json') {
|
||||||
#jsonEditorRef
|
<app-theme-json-code-editor
|
||||||
[value]="draftText()"
|
#jsonEditorRef
|
||||||
[fullscreen]="isFullscreen()"
|
[value]="draftText()"
|
||||||
(valueChange)="onDraftEditorValueChange($event)"
|
[fullscreen]="isFullscreen()"
|
||||||
/>
|
language="json"
|
||||||
|
(valueChange)="onDraftEditorValueChange($event)"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<app-theme-json-code-editor
|
||||||
|
#jsonEditorRef
|
||||||
|
[value]="cssOnlyText()"
|
||||||
|
[fullscreen]="isFullscreen()"
|
||||||
|
language="css"
|
||||||
|
(valueChange)="onCssOnlyEditorValueChange($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@@ -370,7 +467,7 @@
|
|||||||
|
|
||||||
<section class="theme-studio-card p-4">
|
<section class="theme-studio-card p-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<p class="text-sm font-semibold text-foreground">Schema Hints</p>
|
<p class="text-sm font-semibold text-foreground">Editable Attributes</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -468,7 +565,7 @@
|
|||||||
|
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<app-theme-grid-editor
|
<app-theme-grid-editor
|
||||||
[container]="selectedContainer() === 'appShell' ? layoutContainers[0] : layoutContainers[1]"
|
[container]="selectedLayoutContainer()"
|
||||||
[items]="selectedContainerItems()"
|
[items]="selectedContainerItems()"
|
||||||
[selectedKey]="selectedElementKey()"
|
[selectedKey]="selectedElementKey()"
|
||||||
[disabled]="!draftIsValid()"
|
[disabled]="!draftIsValid()"
|
||||||
|
|||||||
@@ -18,6 +18,54 @@
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-selector--mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab {
|
||||||
|
min-height: 5rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: hsl(var(--secondary) / 0.22);
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background-color 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab:hover {
|
||||||
|
background: hsl(var(--secondary) / 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab--active {
|
||||||
|
border-color: hsl(var(--primary) / 0.45);
|
||||||
|
background: hsl(var(--primary) / 0.1);
|
||||||
|
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab-description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.theme-settings__workspace-selector-label {
|
.theme-settings__workspace-selector-label {
|
||||||
font-size: 0.69rem;
|
font-size: 0.69rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -52,6 +100,29 @@
|
|||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace {
|
||||||
|
display: grid;
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: minmax(17rem, 22rem) minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__sidebar {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-settings__editor-card {
|
.theme-settings__editor-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -92,6 +163,22 @@
|
|||||||
background: hsl(var(--primary) / 0.08);
|
background: hsl(var(--primary) / 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-settings__explorer-card {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__explorer-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-json-editor-panel__header {
|
.theme-json-editor-panel__header {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -114,3 +201,22 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.theme-settings__workspace-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-selector--mobile {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__explorer-card {
|
||||||
|
max-height: min(28rem, calc(100vh - 10rem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||||
|
import { getElectronApi } from '../../../../../core/platform/electron/get-electron-api';
|
||||||
import {
|
import {
|
||||||
ThemeContainerKey,
|
ThemeContainerKey,
|
||||||
ThemeElementStyleProperty,
|
ThemeElementStyleProperty,
|
||||||
@@ -29,7 +30,8 @@ import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.const
|
|||||||
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
|
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
|
||||||
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
|
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
|
||||||
|
|
||||||
type JumpSection = 'elements' | 'layout' | 'animations';
|
type JumpSection = 'elements' | 'layout' | 'animations' | 'css';
|
||||||
|
type ThemeEditorTab = 'json' | 'cssOnly';
|
||||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -59,6 +61,7 @@ export class ThemeSettingsComponent {
|
|||||||
readonly statusMessage = this.theme.statusMessage;
|
readonly statusMessage = this.theme.statusMessage;
|
||||||
readonly isDraftDirty = this.theme.isDraftDirty;
|
readonly isDraftDirty = this.theme.isDraftDirty;
|
||||||
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
||||||
|
readonly activeTheme = this.theme.activeTheme;
|
||||||
readonly draftTheme = this.theme.draftTheme;
|
readonly draftTheme = this.theme.draftTheme;
|
||||||
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
||||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||||
@@ -83,6 +86,8 @@ export class ThemeSettingsComponent {
|
|||||||
];
|
];
|
||||||
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
||||||
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
||||||
|
readonly activeEditorTab = signal<ThemeEditorTab>('json');
|
||||||
|
readonly cssOnlyText = signal('');
|
||||||
readonly explorerQuery = signal('');
|
readonly explorerQuery = signal('');
|
||||||
|
|
||||||
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||||
@@ -103,9 +108,15 @@ export class ThemeSettingsComponent {
|
|||||||
].filter((value): value is string => value !== null);
|
].filter((value): value is string => value !== null);
|
||||||
});
|
});
|
||||||
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
||||||
|
readonly selectedLayoutContainer = computed(() => {
|
||||||
|
return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0];
|
||||||
|
});
|
||||||
readonly selectedElementGrid = computed(() => {
|
readonly selectedElementGrid = computed(() => {
|
||||||
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
||||||
});
|
});
|
||||||
|
readonly activeWorkspaceInfo = computed(() => {
|
||||||
|
return this.workspaceTabs.find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs[0];
|
||||||
|
});
|
||||||
readonly visiblePropertyHints = computed(() => {
|
readonly visiblePropertyHints = computed(() => {
|
||||||
const selected = this.selectedElement();
|
const selected = this.selectedElement();
|
||||||
|
|
||||||
@@ -156,6 +167,8 @@ export class ThemeSettingsComponent {
|
|||||||
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.syncCssOnlyTextFromTheme();
|
||||||
|
|
||||||
if (this.savedThemesAvailable()) {
|
if (this.savedThemesAvailable()) {
|
||||||
void this.themeLibrary.refresh();
|
void this.themeLibrary.refresh();
|
||||||
}
|
}
|
||||||
@@ -167,8 +180,16 @@ export class ThemeSettingsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeWorkspace.set('inspector');
|
this.openPickedElementInJson(pickedKey);
|
||||||
this.selectThemeEntry(pickedKey, 'elements');
|
this.picker.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
if (this.activeEditorTab() !== 'json') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cssOnlyText.set(this.draftTheme().css);
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -186,10 +207,35 @@ export class ThemeSettingsComponent {
|
|||||||
this.theme.updateDraftText(value);
|
this.theme.updateDraftText(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCssOnlyEditorValueChange(value: string): void {
|
||||||
|
this.cssOnlyText.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
applyDraft(): void {
|
applyDraft(): void {
|
||||||
|
if (this.activeEditorTab() === 'cssOnly') {
|
||||||
|
this.applyCssOnlyTheme();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.theme.applyDraft();
|
this.theme.applyDraft();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyCssOnlyTheme(): void {
|
||||||
|
this.theme.applyCssOnlyTheme(this.cssOnlyText());
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorTab(tab: ThemeEditorTab): void {
|
||||||
|
this.activeEditorTab.set(tab);
|
||||||
|
|
||||||
|
if (tab === 'json') {
|
||||||
|
this.focusEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncCssOnlyTextFromTheme();
|
||||||
|
this.focusEditor();
|
||||||
|
}
|
||||||
|
|
||||||
setWorkspace(workspace: ThemeStudioWorkspace): void {
|
setWorkspace(workspace: ThemeStudioWorkspace): void {
|
||||||
this.activeWorkspace.set(workspace);
|
this.activeWorkspace.set(workspace);
|
||||||
|
|
||||||
@@ -202,6 +248,7 @@ export class ThemeSettingsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (workspace === 'editor') {
|
if (workspace === 'editor') {
|
||||||
|
this.activeEditorTab.set('json');
|
||||||
this.focusEditor();
|
this.focusEditor();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,12 +270,36 @@ export class ThemeSettingsComponent {
|
|||||||
this.focusEditor();
|
this.focusEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportThemeFile(): Promise<void> {
|
||||||
|
const exportText = this.getExportThemeText();
|
||||||
|
|
||||||
|
if (!exportText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${this.sanitizeThemeFileName(this.draftTheme().meta.name)}.json`;
|
||||||
|
const saved = await this.saveTextAsFile(fileName, exportText);
|
||||||
|
|
||||||
|
this.theme.announceStatus(saved ? `${fileName} exported.` : 'Theme export cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
importThemeFile(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0] ?? null;
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.loadThemeFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
async copyLlmThemeGuide(): Promise<void> {
|
async copyLlmThemeGuide(): Promise<void> {
|
||||||
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
|
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
|
||||||
|
|
||||||
this.setLlmGuideCopyMessage(copied
|
this.setLlmGuideCopyMessage(copied ? 'LLM guide copied.' : 'Manual copy opened.');
|
||||||
? 'LLM guide copied.'
|
|
||||||
: 'Manual copy opened.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startPicker(): void {
|
startPicker(): void {
|
||||||
@@ -285,9 +356,10 @@ export class ThemeSettingsComponent {
|
|||||||
restoreDefaultTheme(): void {
|
restoreDefaultTheme(): void {
|
||||||
this.theme.resetToDefault('button');
|
this.theme.resetToDefault('button');
|
||||||
this.activeWorkspace.set('editor');
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('json');
|
||||||
this.selectedContainer.set('roomLayout');
|
this.selectedContainer.set('roomLayout');
|
||||||
this.selectedElementKey.set('chatRoomMainPanel');
|
this.selectedElementKey.set('chatRoomMainPanel');
|
||||||
this.focusJsonAnchor('elements', 'chatRoomMainPanel');
|
this.focusEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
|
selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
|
||||||
@@ -299,7 +371,7 @@ export class ThemeSettingsComponent {
|
|||||||
|
|
||||||
if (section === 'layout') {
|
if (section === 'layout') {
|
||||||
this.activeWorkspace.set('layout');
|
this.activeWorkspace.set('layout');
|
||||||
} else if (section === 'animations') {
|
} else if (section === 'animations' || section === 'css') {
|
||||||
this.activeWorkspace.set('editor');
|
this.activeWorkspace.set('editor');
|
||||||
} else {
|
} else {
|
||||||
this.activeWorkspace.set('inspector');
|
this.activeWorkspace.set('inspector');
|
||||||
@@ -331,6 +403,9 @@ export class ThemeSettingsComponent {
|
|||||||
const value = getSuggestedFieldDefault(property, this.animationKeys());
|
const value = getSuggestedFieldDefault(property, this.animationKeys());
|
||||||
|
|
||||||
this.theme.setElementStyle(this.selectedElementKey(), property, value);
|
this.theme.setElementStyle(this.selectedElementKey(), property, value);
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('json');
|
||||||
|
this.focusJsonProperty('elements', this.selectedElementKey(), property);
|
||||||
}
|
}
|
||||||
|
|
||||||
addStarterAnimation(): void {
|
addStarterAnimation(): void {
|
||||||
@@ -355,37 +430,200 @@ export class ThemeSettingsComponent {
|
|||||||
|
|
||||||
jumpToLayout(): void {
|
jumpToLayout(): void {
|
||||||
this.activeWorkspace.set('editor');
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('json');
|
||||||
this.focusJsonAnchor('layout', this.selectedElementKey());
|
this.focusJsonAnchor('layout', this.selectedElementKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
jumpToStyles(): void {
|
jumpToStyles(): void {
|
||||||
this.activeWorkspace.set('editor');
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('json');
|
||||||
this.focusJsonAnchor('elements', this.selectedElementKey());
|
this.focusJsonAnchor('elements', this.selectedElementKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
jumpToAnimation(animationKey: string): void {
|
jumpToAnimation(animationKey: string): void {
|
||||||
this.activeWorkspace.set('editor');
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('json');
|
||||||
this.focusJsonAnchor('animations', animationKey);
|
this.focusJsonAnchor('animations', animationKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jumpToCss(): void {
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('cssOnly');
|
||||||
|
this.syncCssOnlyTextFromTheme();
|
||||||
|
this.focusEditor();
|
||||||
|
}
|
||||||
|
|
||||||
isMounted(entry: ThemeRegistryEntry): boolean {
|
isMounted(entry: ThemeRegistryEntry): boolean {
|
||||||
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private focusEditor(): void {
|
private focusEditor(): void {
|
||||||
|
this.withEditorReady((editor) => {
|
||||||
|
editor.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private withEditorReady(action: (editor: ThemeJsonCodeEditorComponent) => void, attempts = 8): void {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
this.editorRef()?.focus();
|
const editor = this.editorRef();
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
action(editor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.withEditorReady(action, attempts - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncCssOnlyTextFromTheme(): void {
|
||||||
|
this.cssOnlyText.set(this.activeTheme().css || this.draftTheme().css);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExportThemeText(): string | null {
|
||||||
|
if (this.activeEditorTab() === 'cssOnly') {
|
||||||
|
return this.theme.buildDraftTextWithCss(this.cssOnlyText());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.draftIsValid()) {
|
||||||
|
this.theme.announceStatus('Fix JSON errors before exporting the theme.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.draftText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadThemeFile(file: File): Promise<void> {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
|
||||||
|
if (file.name.toLowerCase().endsWith('.css')) {
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('cssOnly');
|
||||||
|
this.cssOnlyText.set(text);
|
||||||
|
this.theme.announceStatus(`${file.name} loaded into the CSS editor.`);
|
||||||
|
this.focusEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = this.theme.loadThemeText(text, 'draft', `${file.name} imported into the draft editor.`, 'imported theme file');
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('json');
|
||||||
|
this.syncCssOnlyTextFromTheme();
|
||||||
|
this.focusEditor();
|
||||||
|
} catch {
|
||||||
|
this.theme.announceStatus(`Unable to import ${file.name}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveTextAsFile(fileName: string, text: string): Promise<boolean> {
|
||||||
|
const electronApi = getElectronApi();
|
||||||
|
|
||||||
|
if (electronApi) {
|
||||||
|
const result = await electronApi.saveFileAs(fileName, this.encodeBase64(text));
|
||||||
|
|
||||||
|
return result.saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
link.style.display = 'none';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeBase64(value: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
|
||||||
|
let binary = '';
|
||||||
|
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeThemeFileName(value: string): string {
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : 'theme';
|
||||||
|
}
|
||||||
|
|
||||||
|
private openPickedElementInJson(key: string): void {
|
||||||
|
const definition = this.registry.getDefinition(key);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedElementKey.set(key);
|
||||||
|
|
||||||
|
if (definition.container) {
|
||||||
|
this.selectedContainer.set(definition.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.draftIsValid()) {
|
||||||
|
this.theme.ensureElementEntry(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.activeEditorTab.set('json');
|
||||||
|
this.focusJsonElementEntry(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusJsonElementEntry(key: string): void {
|
||||||
|
this.withEditorReady((editor) => {
|
||||||
|
let text = this.draftText();
|
||||||
|
let anchorIndex = this.findAnchorIndex(text, 'elements', key);
|
||||||
|
|
||||||
|
if (anchorIndex === -1 && this.draftIsValid()) {
|
||||||
|
this.theme.ensureElementEntry(key);
|
||||||
|
text = this.draftText();
|
||||||
|
anchorIndex = this.findAnchorIndex(text, 'elements', key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorIndex === -1) {
|
||||||
|
editor.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonIndex = text.indexOf(':', anchorIndex);
|
||||||
|
const objectStart = colonIndex === -1 ? -1 : text.indexOf('{', colonIndex);
|
||||||
|
|
||||||
|
if (objectStart === -1) {
|
||||||
|
editor.focusRange(anchorIndex, Math.min(anchorIndex + key.length + 2, text.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.focusRange(objectStart + 1, objectStart + 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private focusJsonAnchor(section: JumpSection, key: string): void {
|
private focusJsonAnchor(section: JumpSection, key: string): void {
|
||||||
queueMicrotask(() => {
|
this.withEditorReady((editor) => {
|
||||||
const editor = this.editorRef();
|
|
||||||
|
|
||||||
if (!editor) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = this.draftText();
|
let text = this.draftText();
|
||||||
let anchorIndex = this.findAnchorIndex(text, section, key);
|
let anchorIndex = this.findAnchorIndex(text, section, key);
|
||||||
|
|
||||||
@@ -413,6 +651,94 @@ export class ThemeSettingsComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private focusJsonProperty(section: JumpSection, key: string, property: string): void {
|
||||||
|
this.withEditorReady((editor) => {
|
||||||
|
const text = this.draftText();
|
||||||
|
const keyIndex = this.findAnchorIndex(text, section, key);
|
||||||
|
|
||||||
|
if (keyIndex === -1) {
|
||||||
|
editor.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyIndex = text.indexOf(`"${property}"`, keyIndex);
|
||||||
|
|
||||||
|
if (propertyIndex === -1) {
|
||||||
|
editor.focusRange(keyIndex, Math.min(keyIndex + key.length + 2, text.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueRange = this.findJsonPropertyValueRange(text, propertyIndex);
|
||||||
|
|
||||||
|
editor.focusRange(valueRange.start, valueRange.end);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private findJsonPropertyValueRange(text: string, propertyIndex: number): { start: number; end: number } {
|
||||||
|
const colonIndex = text.indexOf(':', propertyIndex);
|
||||||
|
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
return {
|
||||||
|
start: propertyIndex,
|
||||||
|
end: propertyIndex
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let valueStart = colonIndex + 1;
|
||||||
|
|
||||||
|
while (valueStart < text.length && /\s/.test(text[valueStart])) {
|
||||||
|
valueStart += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text[valueStart] === '"') {
|
||||||
|
const stringContentStart = valueStart + 1;
|
||||||
|
|
||||||
|
let stringContentEnd = stringContentStart;
|
||||||
|
|
||||||
|
while (stringContentEnd < text.length) {
|
||||||
|
if (text[stringContentEnd] === '"' && text[stringContentEnd - 1] !== '\\') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
stringContentEnd += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: stringContentStart,
|
||||||
|
end: stringContentEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let valueEnd = valueStart;
|
||||||
|
|
||||||
|
while (valueEnd < text.length && ![
|
||||||
|
',',
|
||||||
|
'\n',
|
||||||
|
'}'
|
||||||
|
].includes(text[valueEnd])) {
|
||||||
|
valueEnd += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: valueStart,
|
||||||
|
end: Math.max(valueStart, valueEnd)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusJsonSection(section: JumpSection): void {
|
||||||
|
this.withEditorReady((editor) => {
|
||||||
|
const text = this.draftText();
|
||||||
|
const anchorIndex = text.indexOf(`"${section}"`);
|
||||||
|
|
||||||
|
if (anchorIndex === -1) {
|
||||||
|
editor.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.focusRange(anchorIndex, Math.min(anchorIndex + section.length + 2, text.length));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async copyTextToClipboard(value: string): Promise<boolean> {
|
private async copyTextToClipboard(value: string): Promise<boolean> {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ExternalLinkService } from '../../../core/platform';
|
|||||||
import { ElementPickerService } from '../application/services/element-picker.service';
|
import { ElementPickerService } from '../application/services/element-picker.service';
|
||||||
import { ThemeRegistryService } from '../application/services/theme-registry.service';
|
import { ThemeRegistryService } from '../application/services/theme-registry.service';
|
||||||
import { ThemeService } from '../application/services/theme.service';
|
import { ThemeService } from '../application/services/theme.service';
|
||||||
|
import { applyThemeStyleDeclaration } from './theme-style-application.logic';
|
||||||
|
|
||||||
function looksLikeImageReference(value: string): boolean {
|
function looksLikeImageReference(value: string): boolean {
|
||||||
return value.startsWith('url(')
|
return value.startsWith('url(')
|
||||||
@@ -96,8 +97,7 @@ export class ThemeNodeDirective implements OnDestroy {
|
|||||||
this.clearAppliedStyles();
|
this.clearAppliedStyles();
|
||||||
|
|
||||||
for (const [styleKey, styleValue] of Object.entries(styles)) {
|
for (const [styleKey, styleValue] of Object.entries(styles)) {
|
||||||
this.host.nativeElement.style.setProperty(styleKey, styleValue);
|
this.appliedStyleKeys.add(applyThemeStyleDeclaration(this.host.nativeElement, styleKey, styleValue));
|
||||||
this.appliedStyleKeys.add(styleKey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
applyThemeStyleDeclaration,
|
||||||
|
toCssStylePropertyName
|
||||||
|
} from './theme-style-application.logic';
|
||||||
|
|
||||||
|
describe('theme style application', () => {
|
||||||
|
it('applies camelCase theme properties as real CSS declarations', () => {
|
||||||
|
const host = createHost();
|
||||||
|
|
||||||
|
expect(applyThemeStyleDeclaration(host, 'backgroundImage', 'url("/theme.png")')).toBe('background-image');
|
||||||
|
expect(applyThemeStyleDeclaration(host, 'borderRadius', '12px')).toBe('border-radius');
|
||||||
|
expect(applyThemeStyleDeclaration(host, 'boxShadow', '0 4px 20px rgba(0, 0, 0, 0.25)')).toBe('box-shadow');
|
||||||
|
|
||||||
|
expect(host.style.backgroundImage).toContain('/theme.png');
|
||||||
|
expect(host.style.borderRadius).toBe('12px');
|
||||||
|
expect(host.style.boxShadow).toBe('0 4px 20px rgba(0, 0, 0, 0.25)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps CSS custom properties intact', () => {
|
||||||
|
const host = createHost();
|
||||||
|
|
||||||
|
expect(toCssStylePropertyName('--theme-effect-glass-blur')).toBe('--theme-effect-glass-blur');
|
||||||
|
applyThemeStyleDeclaration(host, '--theme-effect-glass-blur', 'blur(18px)');
|
||||||
|
|
||||||
|
expect(host.style.getPropertyValue('--theme-effect-glass-blur')).toBe('blur(18px)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createHost(): HTMLElement {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const style = {
|
||||||
|
backgroundImage: '',
|
||||||
|
borderRadius: '',
|
||||||
|
boxShadow: '',
|
||||||
|
setProperty(propertyName: string, value: string) {
|
||||||
|
values.set(propertyName, value);
|
||||||
|
|
||||||
|
if (propertyName === 'background-image') {
|
||||||
|
this.backgroundImage = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propertyName === 'border-radius') {
|
||||||
|
this.borderRadius = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propertyName === 'box-shadow') {
|
||||||
|
this.boxShadow = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getPropertyValue(propertyName: string) {
|
||||||
|
return values.get(propertyName) ?? '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { style } as unknown as HTMLElement;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function toCssStylePropertyName(propertyName: string): string {
|
||||||
|
if (propertyName.startsWith('--')) {
|
||||||
|
return propertyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return propertyName.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeStyleDeclaration(host: HTMLElement, propertyName: string, value: string): string {
|
||||||
|
const cssPropertyName = toCssStylePropertyName(propertyName);
|
||||||
|
|
||||||
|
host.style.setProperty(cssPropertyName, value);
|
||||||
|
return cssPropertyName;
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<div class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5">
|
<div
|
||||||
|
appThemeNode="voiceControlsPanel"
|
||||||
|
class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5"
|
||||||
|
>
|
||||||
<!-- Connection Error Banner -->
|
<!-- Connection Error Banner -->
|
||||||
@if (showConnectionError()) {
|
@if (showConnectionError()) {
|
||||||
<div class="mb-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2">
|
<div class="mb-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2">
|
||||||
@@ -15,7 +18,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
<div class="relative flex items-center gap-3">
|
<div
|
||||||
|
appThemeNode="voiceControlsUserRow"
|
||||||
|
class="relative flex items-center gap-3"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-3 flex-1 min-w-0 rounded-md px-1 py-0.5 hover:bg-secondary/60 transition-colors cursor-pointer"
|
class="flex items-center gap-3 flex-1 min-w-0 rounded-md px-1 py-0.5 hover:bg-secondary/60 transition-colors cursor-pointer"
|
||||||
@@ -73,7 +79,10 @@
|
|||||||
[attr.aria-hidden]="isConnected() ? null : 'true'"
|
[attr.aria-hidden]="isConnected() ? null : 'true'"
|
||||||
>
|
>
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div
|
||||||
|
appThemeNode="voiceControlsButtons"
|
||||||
|
class="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
<!-- Mute Toggle -->
|
<!-- Mute Toggle -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../
|
|||||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||||
import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection';
|
import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection';
|
||||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||||
|
import { ThemeNodeDirective } from '../../../theme';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||||
@@ -52,7 +53,8 @@ interface AudioDevice {
|
|||||||
NgIcon,
|
NgIcon,
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -145,17 +147,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
|
||||||
this.inputDevices.set(
|
this.inputDevices.set(
|
||||||
devices
|
devices.filter((device) => device.kind === 'audioinput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||||
.filter((device) => device.kind === 'audioinput')
|
|
||||||
.map((device) => ({ deviceId: device.deviceId,
|
|
||||||
label: device.label }))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.outputDevices.set(
|
this.outputDevices.set(
|
||||||
devices
|
devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||||
.filter((device) => device.kind === 'audiooutput')
|
|
||||||
.map((device) => ({ deviceId: device.deviceId,
|
|
||||||
label: device.label }))
|
|
||||||
);
|
);
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
}
|
}
|
||||||
@@ -238,9 +234,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.webrtcService.clearConnectionError();
|
this.webrtcService.clearConnectionError();
|
||||||
this.saveSettings();
|
this.saveSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error
|
const message = error instanceof Error ? error.message : 'Failed to connect voice session.';
|
||||||
? error.message
|
|
||||||
: 'Failed to connect voice session.';
|
|
||||||
|
|
||||||
this.webrtcService.reportConnectionError(message);
|
this.webrtcService.reportConnectionError(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
|
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
|
||||||
<aside class="flex h-full min-h-0 flex-col bg-card">
|
<aside class="flex h-full min-h-0 flex-col bg-card">
|
||||||
<div class="border-b border-border px-3 py-3">
|
<div
|
||||||
|
appThemeNode="roomPanelHeader"
|
||||||
|
class="border-b border-border px-3 py-3"
|
||||||
|
>
|
||||||
@if (panelMode() === 'channels') {
|
@if (panelMode() === 'channels') {
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground">
|
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground">
|
||||||
@@ -33,7 +36,10 @@
|
|||||||
@if (panelMode() === 'channels') {
|
@if (panelMode() === 'channels') {
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto">
|
||||||
<!-- Text Channels -->
|
<!-- Text Channels -->
|
||||||
<section class="px-2 py-3">
|
<section
|
||||||
|
appThemeNode="roomTextChannelsSection"
|
||||||
|
class="px-2 py-3"
|
||||||
|
>
|
||||||
<div class="mb-2 flex items-center justify-between px-1">
|
<div class="mb-2 flex items-center justify-between px-1">
|
||||||
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Text Channels</h4>
|
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Text Channels</h4>
|
||||||
@if (canManageChannels()) {
|
@if (canManageChannels()) {
|
||||||
@@ -52,6 +58,7 @@
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (ch of textChannels(); track ch.id) {
|
@for (ch of textChannels(); track ch.id) {
|
||||||
<button
|
<button
|
||||||
|
appThemeNode="roomTextChannelItem"
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors"
|
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors"
|
||||||
[class.bg-secondary]="activeChannelId() === ch.id"
|
[class.bg-secondary]="activeChannelId() === ch.id"
|
||||||
[class.text-foreground]="activeChannelId() === ch.id"
|
[class.text-foreground]="activeChannelId() === ch.id"
|
||||||
@@ -94,7 +101,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Voice Channels -->
|
<!-- Voice Channels -->
|
||||||
<section class="border-t border-border px-2 py-3">
|
<section
|
||||||
|
appThemeNode="roomVoiceChannelsSection"
|
||||||
|
class="border-t border-border px-2 py-3"
|
||||||
|
>
|
||||||
<div class="mb-2 flex items-center justify-between px-1">
|
<div class="mb-2 flex items-center justify-between px-1">
|
||||||
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Voice Channels</h4>
|
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Voice Channels</h4>
|
||||||
@if (canManageChannels()) {
|
@if (canManageChannels()) {
|
||||||
@@ -125,6 +135,7 @@
|
|||||||
(drop)="onVoiceChannelDrop($event, ch.id)"
|
(drop)="onVoiceChannelDrop($event, ch.id)"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
appThemeNode="roomVoiceChannelItem"
|
||||||
class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-secondary/60"
|
class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-secondary/60"
|
||||||
(click)="joinVoice(ch.id)"
|
(click)="joinVoice(ch.id)"
|
||||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||||
@@ -174,6 +185,7 @@
|
|||||||
>
|
>
|
||||||
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="roomVoiceUserItem"
|
||||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
|
||||||
[class.cursor-pointer]="canDragVoiceUser(u)"
|
[class.cursor-pointer]="canDragVoiceUser(u)"
|
||||||
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"
|
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { MessagesActions } from '../../../store/messages/messages.actions';
|
|||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
import { ScreenShareFacade } from '../../../domains/screen-share';
|
import { ScreenShareFacade } from '../../../domains/screen-share';
|
||||||
import { NotificationsFacade } from '../../../domains/notifications';
|
import { NotificationsFacade } from '../../../domains/notifications';
|
||||||
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
import {
|
import {
|
||||||
VoiceActivityService,
|
VoiceActivityService,
|
||||||
VoiceConnectionFacade,
|
VoiceConnectionFacade,
|
||||||
@@ -82,7 +83,8 @@ type PanelMode = 'channels' | 'users';
|
|||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
UserVolumeMenuComponent,
|
UserVolumeMenuComponent,
|
||||||
UserAvatarComponent,
|
UserAvatarComponent,
|
||||||
ConfirmDialogComponent
|
ConfirmDialogComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -142,10 +144,8 @@ export class RoomsSidePanelComponent {
|
|||||||
const memberIdentifiers = this.roomMemberIdentifiers();
|
const memberIdentifiers = this.roomMemberIdentifiers();
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
|
|
||||||
return this.onlineUsers().filter((user) =>
|
return this.onlineUsers().filter(
|
||||||
!this.isCurrentUserIdentity(user)
|
(user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId)
|
||||||
&& this.matchesIdentifiers(memberIdentifiers, user)
|
|
||||||
&& this.isUserPresentInRoom(user, roomId)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
offlineRoomMembers = computed(() => {
|
offlineRoomMembers = computed(() => {
|
||||||
@@ -661,9 +661,7 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleVoiceJoinFailure(error: unknown): void {
|
private handleVoiceJoinFailure(error: unknown): void {
|
||||||
const message = error instanceof Error
|
const message = error instanceof Error ? error.message : 'Failed to join voice channel.';
|
||||||
? error.message
|
|
||||||
: 'Failed to join voice channel.';
|
|
||||||
|
|
||||||
this.voiceConnection.reportConnectionError(message);
|
this.voiceConnection.reportConnectionError(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<nav class="relative flex h-full w-full flex-col items-center gap-2 border-r border-border bg-secondary/35 px-2 py-3">
|
<nav class="relative flex h-full w-full flex-col items-center gap-2 border-r border-border bg-secondary/35 px-2 py-3">
|
||||||
<!-- Create button -->
|
<!-- Create button -->
|
||||||
<button
|
<button
|
||||||
|
appThemeNode="serversRailCreateButton"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
class="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
title="Create Server"
|
title="Create Server"
|
||||||
@@ -17,7 +18,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Saved servers icons -->
|
<!-- Saved servers icons -->
|
||||||
<div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto pt-0.5">
|
<div
|
||||||
|
appThemeNode="serversRailList"
|
||||||
|
class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto pt-0.5"
|
||||||
|
>
|
||||||
@for (room of visibleSavedRooms(); track room.id) {
|
@for (room of visibleSavedRooms(); track room.id) {
|
||||||
<div class="group/server relative flex w-full justify-center">
|
<div class="group/server relative flex w-full justify-center">
|
||||||
<span
|
<span
|
||||||
@@ -27,6 +31,7 @@
|
|||||||
></span>
|
></span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
appThemeNode="serversRailItem"
|
||||||
type="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-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"
|
||||||
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
|
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
|||||||
import { DatabaseService } from '../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||||
import { NotificationsFacade } from '../../../domains/notifications';
|
import { NotificationsFacade } from '../../../domains/notifications';
|
||||||
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
import { hasRoomBanForUser } from '../../../domains/access-control';
|
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||||
import {
|
import {
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
LeaveServerDialogComponent,
|
LeaveServerDialogComponent,
|
||||||
NgOptimizedImage,
|
NgOptimizedImage,
|
||||||
|
ThemeNodeDirective,
|
||||||
UserBarComponent
|
UserBarComponent
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucidePlus })],
|
viewProviders: [provideIcons({ lucidePlus })],
|
||||||
@@ -221,8 +223,7 @@ export class ServersRailComponent {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
this.joinPasswordError.set(null);
|
this.joinPasswordError.set(null);
|
||||||
this.savedRoomJoinRequests.next({ room,
|
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
|
||||||
password: this.joinPassword() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isRoomMarkedBanned(room: Room): boolean {
|
isRoomMarkedBanned(room: Room): boolean {
|
||||||
@@ -264,10 +265,12 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.forgetRoom({
|
this.store.dispatch(
|
||||||
roomId: ctx.id,
|
RoomsActions.forgetRoom({
|
||||||
nextOwnerKey: result.nextOwnerKey
|
roomId: ctx.id,
|
||||||
}));
|
nextOwnerKey: result.nextOwnerKey
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (isCurrentRoom) {
|
if (isCurrentRoom) {
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
@@ -354,8 +357,7 @@ export class ServersRailComponent {
|
|||||||
this.prepareVoiceContext(room);
|
this.prepareVoiceContext(room);
|
||||||
this.closePasswordDialog();
|
this.closePasswordDialog();
|
||||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
this.store.dispatch(RoomsActions.viewServer({ room, skipBanCheck: true }));
|
||||||
skipBanCheck: true }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private requestJoinInBackground(room: Room, password?: string) {
|
private requestJoinInBackground(room: Room, password?: string) {
|
||||||
@@ -377,13 +379,17 @@ export class ServersRailComponent {
|
|||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.serverDirectory.requestJoin({
|
return this.serverDirectory
|
||||||
roomId: room.id,
|
.requestJoin(
|
||||||
userId: currentUserId,
|
{
|
||||||
userPublicKey: currentUser?.oderId || currentUserId,
|
roomId: room.id,
|
||||||
displayName: currentUser?.displayName || 'Anonymous',
|
userId: currentUserId,
|
||||||
password: password?.trim() || undefined
|
userPublicKey: currentUser?.oderId || currentUserId,
|
||||||
}, joinTarget.selector)
|
displayName: currentUser?.displayName || 'Anonymous',
|
||||||
|
password: password?.trim() || undefined
|
||||||
|
},
|
||||||
|
joinTarget.selector
|
||||||
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
tap((response) => {
|
tap((response) => {
|
||||||
this.closePasswordDialog();
|
this.closePasswordDialog();
|
||||||
@@ -447,22 +453,22 @@ export class ServersRailComponent {
|
|||||||
const errorCode = serverError?.error?.errorCode;
|
const errorCode = serverError?.error?.errorCode;
|
||||||
const status = serverError?.status;
|
const status = serverError?.status;
|
||||||
|
|
||||||
return errorCode === 'SERVER_NOT_FOUND'
|
return errorCode === 'SERVER_NOT_FOUND' || status === 0 || status === 404 || (typeof status === 'number' && status >= 500);
|
||||||
|| status === 0
|
|
||||||
|| status === 404
|
|
||||||
|| (typeof status === 'number' && status >= 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toRoomRefreshChanges(room: Room, server: ServerInfo, signalingUrl?: string): Partial<Room> {
|
private toRoomRefreshChanges(room: Room, server: ServerInfo, signalingUrl?: string): Partial<Room> {
|
||||||
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource(
|
||||||
sourceId: server.sourceId ?? room.sourceId,
|
{
|
||||||
sourceName: server.sourceName ?? room.sourceName,
|
sourceId: server.sourceId ?? room.sourceId,
|
||||||
sourceUrl: server.sourceUrl ?? room.sourceUrl,
|
sourceName: server.sourceName ?? room.sourceName,
|
||||||
signalingUrl,
|
sourceUrl: server.sourceUrl ?? room.sourceUrl,
|
||||||
fallbackName: server.sourceName ?? room.sourceName ?? room.name
|
signalingUrl,
|
||||||
}, {
|
fallbackName: server.sourceName ?? room.sourceName ?? room.name
|
||||||
ensureEndpoint: true
|
},
|
||||||
});
|
{
|
||||||
|
ensureEndpoint: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: server.name,
|
name: server.name,
|
||||||
@@ -472,15 +478,10 @@ export class ServersRailComponent {
|
|||||||
userCount: server.userCount,
|
userCount: server.userCount,
|
||||||
maxUsers: server.maxUsers,
|
maxUsers: server.maxUsers,
|
||||||
hasPassword:
|
hasPassword:
|
||||||
typeof server.hasPassword === 'boolean'
|
typeof server.hasPassword === 'boolean' ? server.hasPassword : typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||||
? server.hasPassword
|
|
||||||
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
|
|
||||||
isPrivate: server.isPrivate,
|
isPrivate: server.isPrivate,
|
||||||
createdAt: server.createdAt,
|
createdAt: server.createdAt,
|
||||||
channels:
|
channels: Array.isArray(server.channels) && server.channels.length > 0 ? server.channels : room.channels,
|
||||||
Array.isArray(server.channels) && server.channels.length > 0
|
|
||||||
? server.channels
|
|
||||||
: room.channels,
|
|
||||||
...resolvedSource
|
...resolvedSource
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -489,26 +490,33 @@ export class ServersRailComponent {
|
|||||||
room: Room;
|
room: Room;
|
||||||
selector: ReturnType<ServerDirectoryFacade['buildRoomSignalSelector']>;
|
selector: ReturnType<ServerDirectoryFacade['buildRoomSignalSelector']>;
|
||||||
}> {
|
}> {
|
||||||
let resolvedRoom = this.applyResolvedRoomSource(room, this.serverDirectory.normaliseRoomSignalSource({
|
let resolvedRoom = this.applyResolvedRoomSource(
|
||||||
sourceId: room.sourceId,
|
room,
|
||||||
sourceName: room.sourceName,
|
this.serverDirectory.normaliseRoomSignalSource(
|
||||||
sourceUrl: room.sourceUrl,
|
{
|
||||||
fallbackName: room.sourceName ?? room.name
|
sourceId: room.sourceId,
|
||||||
}, {
|
sourceName: room.sourceName,
|
||||||
ensureEndpoint: !!room.sourceUrl
|
sourceUrl: room.sourceUrl,
|
||||||
}));
|
fallbackName: room.sourceName ?? room.name
|
||||||
let selector = this.serverDirectory.buildRoomSignalSelector({
|
},
|
||||||
sourceId: resolvedRoom.sourceId,
|
{
|
||||||
sourceName: resolvedRoom.sourceName,
|
ensureEndpoint: !!room.sourceUrl
|
||||||
sourceUrl: resolvedRoom.sourceUrl,
|
}
|
||||||
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
|
)
|
||||||
}, {
|
);
|
||||||
ensureEndpoint: !!resolvedRoom.sourceUrl
|
let selector = this.serverDirectory.buildRoomSignalSelector(
|
||||||
});
|
{
|
||||||
|
sourceId: resolvedRoom.sourceId,
|
||||||
|
sourceName: resolvedRoom.sourceName,
|
||||||
|
sourceUrl: resolvedRoom.sourceUrl,
|
||||||
|
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ensureEndpoint: !!resolvedRoom.sourceUrl
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const authoritativeServer = selector
|
const authoritativeServer = selector ? await firstValueFrom(this.serverDirectory.getServer(room.id, selector)) : null;
|
||||||
? await firstValueFrom(this.serverDirectory.getServer(room.id, selector))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!authoritativeServer) {
|
if (!authoritativeServer) {
|
||||||
return {
|
return {
|
||||||
@@ -517,24 +525,30 @@ export class ServersRailComponent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource({
|
const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource(
|
||||||
sourceId: authoritativeServer.sourceId ?? resolvedRoom.sourceId,
|
{
|
||||||
sourceName: authoritativeServer.sourceName ?? resolvedRoom.sourceName,
|
sourceId: authoritativeServer.sourceId ?? resolvedRoom.sourceId,
|
||||||
sourceUrl: authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl,
|
sourceName: authoritativeServer.sourceName ?? resolvedRoom.sourceName,
|
||||||
fallbackName: authoritativeServer.sourceName ?? resolvedRoom.sourceName ?? resolvedRoom.name
|
sourceUrl: authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl,
|
||||||
}, {
|
fallbackName: authoritativeServer.sourceName ?? resolvedRoom.sourceName ?? resolvedRoom.name
|
||||||
ensureEndpoint: !!(authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl)
|
},
|
||||||
});
|
{
|
||||||
|
ensureEndpoint: !!(authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
resolvedRoom = this.applyResolvedRoomSource(resolvedRoom, authoritativeSource);
|
resolvedRoom = this.applyResolvedRoomSource(resolvedRoom, authoritativeSource);
|
||||||
selector = this.serverDirectory.buildRoomSignalSelector({
|
selector = this.serverDirectory.buildRoomSignalSelector(
|
||||||
sourceId: resolvedRoom.sourceId,
|
{
|
||||||
sourceName: resolvedRoom.sourceName,
|
sourceId: resolvedRoom.sourceId,
|
||||||
sourceUrl: resolvedRoom.sourceUrl,
|
sourceName: resolvedRoom.sourceName,
|
||||||
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
|
sourceUrl: resolvedRoom.sourceUrl,
|
||||||
}, {
|
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
|
||||||
ensureEndpoint: !!resolvedRoom.sourceUrl
|
},
|
||||||
});
|
{
|
||||||
|
ensureEndpoint: !!resolvedRoom.sourceUrl
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
room: resolvedRoom,
|
room: resolvedRoom,
|
||||||
@@ -550,22 +564,20 @@ export class ServersRailComponent {
|
|||||||
sourceUrl: source.sourceUrl
|
sourceUrl: source.sourceUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (room.sourceId === nextRoom.sourceId && room.sourceName === nextRoom.sourceName && room.sourceUrl === nextRoom.sourceUrl) {
|
||||||
room.sourceId === nextRoom.sourceId
|
|
||||||
&& room.sourceName === nextRoom.sourceName
|
|
||||||
&& room.sourceUrl === nextRoom.sourceUrl
|
|
||||||
) {
|
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.updateRoom({
|
this.store.dispatch(
|
||||||
roomId: room.id,
|
RoomsActions.updateRoom({
|
||||||
changes: {
|
roomId: room.id,
|
||||||
sourceId: nextRoom.sourceId,
|
changes: {
|
||||||
sourceName: nextRoom.sourceName,
|
sourceId: nextRoom.sourceId,
|
||||||
sourceUrl: nextRoom.sourceUrl
|
sourceName: nextRoom.sourceName,
|
||||||
}
|
sourceUrl: nextRoom.sourceUrl
|
||||||
}));
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return nextRoom;
|
return nextRoom;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<!-- Modal -->
|
<!-- Modal -->
|
||||||
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="settingsModalSurface"
|
||||||
class="pointer-events-auto relative flex w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-lg transition-all duration-200"
|
class="pointer-events-auto relative flex w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-lg transition-all duration-200"
|
||||||
style="height: min(720px, 88vh)"
|
style="height: min(720px, 88vh)"
|
||||||
[class.scale-100]="animating()"
|
[class.scale-100]="animating()"
|
||||||
@@ -30,7 +31,10 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Side Navigation -->
|
<!-- Side Navigation -->
|
||||||
<nav class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card">
|
<nav
|
||||||
|
appThemeNode="settingsModalNav"
|
||||||
|
class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card"
|
||||||
|
>
|
||||||
<div class="border-b border-border px-3 py-3">
|
<div class="border-b border-border px-3 py-3">
|
||||||
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
|
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +120,10 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 flex flex-col min-w-0">
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between border-b border-border px-5 py-3 flex-shrink-0">
|
<div
|
||||||
|
appThemeNode="settingsModalHeader"
|
||||||
|
class="flex items-center justify-between border-b border-border px-5 py-3 flex-shrink-0"
|
||||||
|
>
|
||||||
<h3 class="text-lg font-semibold text-foreground">
|
<h3 class="text-lg font-semibold text-foreground">
|
||||||
@switch (activePage()) {
|
@switch (activePage()) {
|
||||||
@case ('general') {
|
@case ('general') {
|
||||||
@@ -169,7 +176,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable Content Area -->
|
<!-- Scrollable Content Area -->
|
||||||
<div class="flex-1 overflow-y-auto bg-background px-4 py-4 sm:px-5 sm:py-4">
|
<div
|
||||||
|
appThemeNode="settingsModalContent"
|
||||||
|
class="flex-1 overflow-y-auto bg-background px-4 py-4 sm:px-5 sm:py-4"
|
||||||
|
>
|
||||||
@switch (activePage()) {
|
@switch (activePage()) {
|
||||||
@case ('general') {
|
@case ('general') {
|
||||||
<app-general-settings />
|
<app-general-settings />
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
|
|||||||
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||||
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
||||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||||
import { ThemeLibraryService, ThemeService } from '../../../domains/theme';
|
import {
|
||||||
|
ThemeLibraryService,
|
||||||
|
ThemeNodeDirective,
|
||||||
|
ThemeService
|
||||||
|
} from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings-modal',
|
selector: 'app-settings-modal',
|
||||||
@@ -63,7 +67,8 @@ import { ThemeLibraryService, ThemeService } from '../../../domains/theme';
|
|||||||
ServerSettingsComponent,
|
ServerSettingsComponent,
|
||||||
MembersSettingsComponent,
|
MembersSettingsComponent,
|
||||||
BansSettingsComponent,
|
BansSettingsComponent,
|
||||||
PermissionsSettingsComponent
|
PermissionsSettingsComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -109,41 +114,19 @@ export class SettingsModalComponent {
|
|||||||
selectedSavedTheme = this.themeLibrary.selectedEntry;
|
selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||||
|
|
||||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'general',
|
{ id: 'general', label: 'General', icon: 'lucideSettings' },
|
||||||
label: 'General',
|
{ id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' },
|
||||||
icon: 'lucideSettings' },
|
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
||||||
{ id: 'theme',
|
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
||||||
label: 'Theme Studio',
|
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
||||||
icon: 'lucidePalette' },
|
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
|
||||||
{ id: 'network',
|
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
|
||||||
label: 'Network',
|
|
||||||
icon: 'lucideGlobe' },
|
|
||||||
{ id: 'notifications',
|
|
||||||
label: 'Notifications',
|
|
||||||
icon: 'lucideBell' },
|
|
||||||
{ id: 'voice',
|
|
||||||
label: 'Voice & Audio',
|
|
||||||
icon: 'lucideAudioLines' },
|
|
||||||
{ id: 'updates',
|
|
||||||
label: 'Updates',
|
|
||||||
icon: 'lucideDownload' },
|
|
||||||
{ id: 'debugging',
|
|
||||||
label: 'Debugging',
|
|
||||||
icon: 'lucideBug' }
|
|
||||||
];
|
];
|
||||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'server',
|
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
|
||||||
label: 'Server',
|
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
|
||||||
icon: 'lucideSettings' },
|
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
|
||||||
{ id: 'members',
|
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
|
||||||
label: 'Members',
|
|
||||||
icon: 'lucideUsers' },
|
|
||||||
{ id: 'bans',
|
|
||||||
label: 'Bans',
|
|
||||||
icon: 'lucideBan' },
|
|
||||||
{ id: 'permissions',
|
|
||||||
label: 'Permissions',
|
|
||||||
icon: 'lucideShield' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
manageableRooms = computed<Room[]>(() => {
|
manageableRooms = computed<Room[]>(() => {
|
||||||
@@ -153,16 +136,18 @@ export class SettingsModalComponent {
|
|||||||
return [];
|
return [];
|
||||||
|
|
||||||
return this.savedRooms().filter((room) => {
|
return this.savedRooms().filter((room) => {
|
||||||
const viewedRoom = this.currentRoom()?.id === room.id ? this.currentRoom() ?? room : room;
|
const viewedRoom = this.currentRoom()?.id === room.id ? (this.currentRoom() ?? room) : room;
|
||||||
const role = resolveLegacyRole(viewedRoom, user);
|
const role = resolveLegacyRole(viewedRoom, user);
|
||||||
|
|
||||||
return role === 'host'
|
return (
|
||||||
|| resolveRoomPermission(viewedRoom, user, 'manageServer')
|
role === 'host' ||
|
||||||
|| resolveRoomPermission(viewedRoom, user, 'manageRoles')
|
resolveRoomPermission(viewedRoom, user, 'manageServer') ||
|
||||||
|| resolveRoomPermission(viewedRoom, user, 'manageChannels')
|
resolveRoomPermission(viewedRoom, user, 'manageRoles') ||
|
||||||
|| resolveRoomPermission(viewedRoom, user, 'manageBans')
|
resolveRoomPermission(viewedRoom, user, 'manageChannels') ||
|
||||||
|| resolveRoomPermission(viewedRoom, user, 'kickMembers')
|
resolveRoomPermission(viewedRoom, user, 'manageBans') ||
|
||||||
|| resolveRoomPermission(viewedRoom, user, 'banMembers');
|
resolveRoomPermission(viewedRoom, user, 'kickMembers') ||
|
||||||
|
resolveRoomPermission(viewedRoom, user, 'banMembers')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,21 +172,23 @@ export class SettingsModalComponent {
|
|||||||
if (!server || !user)
|
if (!server || !user)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return resolveLegacyRole(this.currentRoom()?.id === server.id ? this.currentRoom() ?? server : server, user);
|
return resolveLegacyRole(this.currentRoom()?.id === server.id ? (this.currentRoom() ?? server) : server, user);
|
||||||
});
|
});
|
||||||
|
|
||||||
canAccessSelectedServer = computed(() => {
|
canAccessSelectedServer = computed(() => {
|
||||||
const server = this.selectedServer();
|
const server = this.selectedServer();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
return !!server && !!user && (
|
return (
|
||||||
resolveLegacyRole(server, user) === 'host'
|
!!server &&
|
||||||
|| resolveRoomPermission(server, user, 'manageServer')
|
!!user &&
|
||||||
|| resolveRoomPermission(server, user, 'manageRoles')
|
(resolveLegacyRole(server, user) === 'host' ||
|
||||||
|| resolveRoomPermission(server, user, 'manageChannels')
|
resolveRoomPermission(server, user, 'manageServer') ||
|
||||||
|| resolveRoomPermission(server, user, 'manageBans')
|
resolveRoomPermission(server, user, 'manageRoles') ||
|
||||||
|| resolveRoomPermission(server, user, 'kickMembers')
|
resolveRoomPermission(server, user, 'manageChannels') ||
|
||||||
|| resolveRoomPermission(server, user, 'banMembers')
|
resolveRoomPermission(server, user, 'manageBans') ||
|
||||||
|
resolveRoomPermission(server, user, 'kickMembers') ||
|
||||||
|
resolveRoomPermission(server, user, 'banMembers'))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,11 +196,13 @@ export class SettingsModalComponent {
|
|||||||
const server = this.selectedServer();
|
const server = this.selectedServer();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
return !!server && !!user && (
|
return (
|
||||||
resolveLegacyRole(server, user) === 'host'
|
!!server &&
|
||||||
|| resolveRoomPermission(server, user, 'manageRoles')
|
!!user &&
|
||||||
|| resolveRoomPermission(server, user, 'kickMembers')
|
(resolveLegacyRole(server, user) === 'host' ||
|
||||||
|| resolveRoomPermission(server, user, 'banMembers')
|
resolveRoomPermission(server, user, 'manageRoles') ||
|
||||||
|
resolveRoomPermission(server, user, 'kickMembers') ||
|
||||||
|
resolveRoomPermission(server, user, 'banMembers'))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,20 +210,19 @@ export class SettingsModalComponent {
|
|||||||
const server = this.selectedServer();
|
const server = this.selectedServer();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
return !!server && !!user && (
|
return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageBans'));
|
||||||
resolveLegacyRole(server, user) === 'host'
|
|
||||||
|| resolveRoomPermission(server, user, 'manageBans')
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
canManageSelectedPermissions = computed(() => {
|
canManageSelectedPermissions = computed(() => {
|
||||||
const server = this.selectedServer();
|
const server = this.selectedServer();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
return !!server && !!user && (
|
return (
|
||||||
resolveLegacyRole(server, user) === 'host'
|
!!server &&
|
||||||
|| resolveRoomPermission(server, user, 'manageRoles')
|
!!user &&
|
||||||
|| resolveRoomPermission(server, user, 'manageServer')
|
(resolveLegacyRole(server, user) === 'host' ||
|
||||||
|
resolveRoomPermission(server, user, 'manageRoles') ||
|
||||||
|
resolveRoomPermission(server, user, 'manageServer'))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,9 +247,8 @@ export class SettingsModalComponent {
|
|||||||
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
|
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
|
||||||
|
|
||||||
if (!hasSelected) {
|
if (!hasSelected) {
|
||||||
const fallbackId = [targetId, currentRoomId].find((candidateId) =>
|
const fallbackId =
|
||||||
!!candidateId && rooms.some((room) => room.id === candidateId)
|
[targetId, currentRoomId].find((candidateId) => !!candidateId && rooms.some((room) => room.id === candidateId)) ?? rooms[0]?.id ?? null;
|
||||||
) ?? rooms[0]?.id ?? null;
|
|
||||||
|
|
||||||
this.selectedServerId.set(fallbackId);
|
this.selectedServerId.set(fallbackId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="confirmDialogSurface"
|
||||||
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
||||||
[class]="widthClass()"
|
[class]="widthClass()"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
output,
|
output,
|
||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-confirm-dialog',
|
selector: 'app-confirm-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
imports: [ThemeNodeDirective],
|
||||||
templateUrl: './confirm-dialog.component.html',
|
templateUrl: './confirm-dialog.component.html',
|
||||||
host: {
|
host: {
|
||||||
style: 'display: contents;'
|
style: 'display: contents;'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<!-- Positioned menu panel -->
|
<!-- Positioned menu panel -->
|
||||||
<div
|
<div
|
||||||
#panel
|
#panel
|
||||||
|
appThemeNode="contextMenuSurface"
|
||||||
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
|
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||||
[class]="widthPx() ? '' : width()"
|
[class]="widthPx() ? '' : width()"
|
||||||
[style.left.px]="clampedX()"
|
[style.left.px]="clampedX()"
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-context-menu',
|
selector: 'app-context-menu',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
imports: [ThemeNodeDirective],
|
||||||
templateUrl: './context-menu.component.html',
|
templateUrl: './context-menu.component.html',
|
||||||
styleUrl: './context-menu.component.scss'
|
styleUrl: './context-menu.component.scss'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<div
|
<div
|
||||||
|
appThemeNode="profileCardSurface"
|
||||||
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
||||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||||
>
|
>
|
||||||
@@ -8,7 +9,10 @@
|
|||||||
@let statusColor = currentStatusColor();
|
@let statusColor = currentStatusColor();
|
||||||
@let statusLabel = currentStatusLabel();
|
@let statusLabel = currentStatusLabel();
|
||||||
|
|
||||||
<div class="h-20 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
<div
|
||||||
|
appThemeNode="profileCardBanner"
|
||||||
|
class="h-20 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"
|
||||||
|
></div>
|
||||||
|
|
||||||
<div class="relative px-4">
|
<div class="relative px-4">
|
||||||
<div class="-mt-8">
|
<div class="-mt-8">
|
||||||
@@ -36,7 +40,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4 pb-3 pt-2.5">
|
<div
|
||||||
|
appThemeNode="profileCardBody"
|
||||||
|
class="px-4 pb-3 pt-2.5"
|
||||||
|
>
|
||||||
@if (isEditable) {
|
@if (isEditable) {
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '../../../domains/profile-avatar';
|
} from '../../../domains/profile-avatar';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectUsersEntities } from '../../../store/users/users.selectors';
|
import { selectUsersEntities } from '../../../store/users/users.selectors';
|
||||||
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-profile-card',
|
selector: 'app-profile-card',
|
||||||
@@ -28,15 +29,14 @@ import { selectUsersEntities } from '../../../store/users/users.selectors';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
UserAvatarComponent
|
UserAvatarComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown })],
|
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown })],
|
||||||
templateUrl: './profile-card.component.html'
|
templateUrl: './profile-card.component.html'
|
||||||
})
|
})
|
||||||
export class ProfileCardComponent {
|
export class ProfileCardComponent {
|
||||||
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
|
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly users = this.store.selectSignal(selectUsersEntities);
|
|
||||||
readonly displayedUser = computed(() => {
|
readonly displayedUser = computed(() => {
|
||||||
const snapshot = this.user();
|
const snapshot = this.user();
|
||||||
const entities = this.users();
|
const entities = this.users();
|
||||||
@@ -60,42 +60,58 @@ export class ProfileCardComponent {
|
|||||||
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
|
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly users = this.store.selectSignal(selectUsersEntities);
|
||||||
private readonly userStatus = inject(UserStatusService);
|
private readonly userStatus = inject(UserStatusService);
|
||||||
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
||||||
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
||||||
private readonly syncProfileDrafts = effect(() => {
|
private readonly syncProfileDrafts = effect(
|
||||||
const user = this.displayedUser();
|
() => {
|
||||||
const editingField = this.editingField();
|
const user = this.displayedUser();
|
||||||
|
const editingField = this.editingField();
|
||||||
|
|
||||||
if (editingField !== 'displayName') {
|
if (editingField !== 'displayName') {
|
||||||
this.displayNameDraft.set(user.displayName || '');
|
this.displayNameDraft.set(user.displayName || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingField !== 'description') {
|
if (editingField !== 'description') {
|
||||||
this.descriptionDraft.set(user.description || '');
|
this.descriptionDraft.set(user.description || '');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}, { allowSignalWrites: true });
|
{ allowSignalWrites: true }
|
||||||
|
);
|
||||||
|
|
||||||
currentStatusColor(): string {
|
currentStatusColor(): string {
|
||||||
switch (this.displayedUser().status) {
|
switch (this.displayedUser().status) {
|
||||||
case 'online': return 'bg-green-500';
|
case 'online':
|
||||||
case 'away': return 'bg-yellow-500';
|
return 'bg-green-500';
|
||||||
case 'busy': return 'bg-red-500';
|
case 'away':
|
||||||
case 'offline': return 'bg-gray-500';
|
return 'bg-yellow-500';
|
||||||
case 'disconnected': return 'bg-gray-500';
|
case 'busy':
|
||||||
default: return 'bg-green-500';
|
return 'bg-red-500';
|
||||||
|
case 'offline':
|
||||||
|
return 'bg-gray-500';
|
||||||
|
case 'disconnected':
|
||||||
|
return 'bg-gray-500';
|
||||||
|
default:
|
||||||
|
return 'bg-green-500';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentStatusLabel(): string {
|
currentStatusLabel(): string {
|
||||||
switch (this.displayedUser().status) {
|
switch (this.displayedUser().status) {
|
||||||
case 'online': return 'Online';
|
case 'online':
|
||||||
case 'away': return 'Away';
|
return 'Online';
|
||||||
case 'busy': return 'Do Not Disturb';
|
case 'away':
|
||||||
case 'offline': return 'Invisible';
|
return 'Away';
|
||||||
case 'disconnected': return 'Offline';
|
case 'busy':
|
||||||
default: return 'Online';
|
return 'Do Not Disturb';
|
||||||
|
case 'offline':
|
||||||
|
return 'Invisible';
|
||||||
|
case 'disconnected':
|
||||||
|
return 'Offline';
|
||||||
|
default:
|
||||||
|
return 'Online';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +127,7 @@ export class ProfileCardComponent {
|
|||||||
isStatusOptionSelected(status: UserStatus | null): boolean {
|
isStatusOptionSelected(status: UserStatus | null): boolean {
|
||||||
const currentStatus = this.displayedUser().status;
|
const currentStatus = this.displayedUser().status;
|
||||||
|
|
||||||
return status === null
|
return status === null ? currentStatus === 'online' : currentStatus === status;
|
||||||
? currentStatus === 'online'
|
|
||||||
: currentStatus === status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDisplayNameInput(event: Event): void {
|
onDisplayNameInput(event: Event): void {
|
||||||
@@ -223,10 +237,7 @@ export class ProfileCardComponent {
|
|||||||
const user = this.displayedUser();
|
const user = this.displayedUser();
|
||||||
const description = this.normalizeDescription(this.descriptionDraft());
|
const description = this.normalizeDescription(this.descriptionDraft());
|
||||||
|
|
||||||
if (
|
if (displayName === this.normalizeDisplayName(user.displayName) && description === this.normalizeDescription(user.description)) {
|
||||||
displayName === this.normalizeDisplayName(user.displayName)
|
|
||||||
&& description === this.normalizeDescription(user.description)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<section
|
<section
|
||||||
|
appThemeNode="screenShareSourcePicker"
|
||||||
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
|
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(keydown.enter)="$event.stopPropagation()"
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
@@ -124,6 +125,7 @@
|
|||||||
@for (source of filteredSources(); track trackSource($index, source)) {
|
@for (source of filteredSources(); track trackSource($index, source)) {
|
||||||
<button
|
<button
|
||||||
#sourceButton
|
#sourceButton
|
||||||
|
appThemeNode="screenShareSourceCard"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-xl border px-4 py-4 text-left transition-colors screen-share-source-picker__source"
|
class="rounded-xl border px-4 py-4 text-left transition-colors screen-share-source-picker__source"
|
||||||
[attr.aria-pressed]="selectedSourceId() === source.id"
|
[attr.aria-pressed]="selectedSourceId() === source.id"
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ import {
|
|||||||
ScreenShareSourceOption,
|
ScreenShareSourceOption,
|
||||||
ScreenShareSourcePickerService
|
ScreenShareSourcePickerService
|
||||||
} from '../../../domains/screen-share';
|
} from '../../../domains/screen-share';
|
||||||
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-screen-share-source-picker',
|
selector: 'app-screen-share-source-picker',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgOptimizedImage],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NgOptimizedImage,
|
||||||
|
ThemeNodeDirective
|
||||||
|
],
|
||||||
templateUrl: './screen-share-source-picker.component.html',
|
templateUrl: './screen-share-source-picker.component.html',
|
||||||
styleUrl: './screen-share-source-picker.component.scss',
|
styleUrl: './screen-share-source-picker.component.scss',
|
||||||
host: {
|
host: {
|
||||||
@@ -32,9 +37,7 @@ export class ScreenShareSourcePickerComponent {
|
|||||||
readonly screenSources = computed(() => this.sources().filter((source) => source.kind === 'screen'));
|
readonly screenSources = computed(() => this.sources().filter((source) => source.kind === 'screen'));
|
||||||
readonly windowSources = computed(() => this.sources().filter((source) => source.kind === 'window'));
|
readonly windowSources = computed(() => this.sources().filter((source) => source.kind === 'window'));
|
||||||
readonly filteredSources = computed(() => {
|
readonly filteredSources = computed(() => {
|
||||||
return this.activeTab() === 'screen'
|
return this.activeTab() === 'screen' ? this.screenSources() : this.windowSources();
|
||||||
? this.screenSources()
|
|
||||||
: this.windowSources();
|
|
||||||
});
|
});
|
||||||
readonly hasOpenRequest = computed(() => !!this.request());
|
readonly hasOpenRequest = computed(() => !!this.request());
|
||||||
readonly activeTab = signal<ScreenShareSourceKind>('screen');
|
readonly activeTab = signal<ScreenShareSourceKind>('screen');
|
||||||
@@ -46,9 +49,7 @@ export class ScreenShareSourcePickerComponent {
|
|||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const request = this.request();
|
const request = this.request();
|
||||||
const defaultTab: ScreenShareSourceKind = request?.sources.some((source) => source.kind === 'screen')
|
const defaultTab: ScreenShareSourceKind = request?.sources.some((source) => source.kind === 'screen') ? 'screen' : 'window';
|
||||||
? 'screen'
|
|
||||||
: 'window';
|
|
||||||
|
|
||||||
this.activeTab.set(defaultTab);
|
this.activeTab.set(defaultTab);
|
||||||
this.includeSystemAudio.set(request?.includeSystemAudio ?? false);
|
this.includeSystemAudio.set(request?.includeSystemAudio ?? false);
|
||||||
@@ -68,9 +69,8 @@ export class ScreenShareSourcePickerComponent {
|
|||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
const activeSourceId = this.selectedSourceId();
|
const activeSourceId = this.selectedSourceId();
|
||||||
const targetButton = this.sourceButtons().find(
|
const targetButton =
|
||||||
(button) => button.nativeElement.dataset['sourceId'] === activeSourceId
|
this.sourceButtons().find((button) => button.nativeElement.dataset['sourceId'] === activeSourceId) ?? this.sourceButtons()[0];
|
||||||
) ?? this.sourceButtons()[0];
|
|
||||||
|
|
||||||
targetButton?.nativeElement.focus();
|
targetButton?.nativeElement.focus();
|
||||||
});
|
});
|
||||||
@@ -125,8 +125,6 @@ export class ScreenShareSourcePickerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getTabSources(tab: ScreenShareSourceKind): ScreenShareSourceOption[] {
|
private getTabSources(tab: ScreenShareSourceKind): ScreenShareSourceOption[] {
|
||||||
return tab === 'screen'
|
return tab === 'screen' ? this.screenSources() : this.windowSources();
|
||||||
? this.screenSources()
|
|
||||||
: this.windowSources();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user