json viewer
This commit is contained in:
@@ -53,6 +53,9 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": [
|
||||||
|
"src/jsonview.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const routes: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
component: HomePageComponent,
|
component: HomePageComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'chat',
|
||||||
|
component: ChatPageComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'chat/:peerId',
|
path: 'chat/:peerId',
|
||||||
component: ChatPageComponent,
|
component: ChatPageComponent,
|
||||||
|
|||||||
@@ -42,10 +42,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
||||||
|
<article class="peer-tile" [class.peer-tile-active]="connectedPeer.id === peerId()">
|
||||||
<button
|
<button
|
||||||
class="peer-tile text-start"
|
class="peer-tile-main text-start"
|
||||||
type="button"
|
type="button"
|
||||||
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
|
||||||
(click)="switchPeer(connectedPeer.id)"
|
(click)="switchPeer(connectedPeer.id)"
|
||||||
>
|
>
|
||||||
<div class="peer-tile-row">
|
<div class="peer-tile-row">
|
||||||
@@ -71,6 +71,16 @@
|
|||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="peer-tile-delete"
|
||||||
|
type="button"
|
||||||
|
title="Delete conversation"
|
||||||
|
aria-label="Delete conversation"
|
||||||
|
(click)="deleteConversation(connectedPeer.id, $event)"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -121,6 +131,10 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (isIncomingJsonFileEntry(entry)) {
|
||||||
|
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
||||||
|
}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold">{{ entry.fileName }}</div>
|
<div class="fw-semibold">{{ entry.fileName }}</div>
|
||||||
@if (entry.fileSize) {
|
@if (entry.fileSize) {
|
||||||
|
|||||||
@@ -106,8 +106,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.peer-tile {
|
.peer-tile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.95rem 1rem;
|
padding: 0.8rem 0.85rem 0.8rem 1rem;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -115,6 +119,30 @@
|
|||||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.peer-tile-main {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-delete {
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-delete:hover,
|
||||||
|
.peer-tile-delete:focus-visible {
|
||||||
|
background: var(--badge-background);
|
||||||
|
}
|
||||||
|
|
||||||
.peer-tile:hover,
|
.peer-tile:hover,
|
||||||
.peer-tile:focus-visible,
|
.peer-tile:focus-visible,
|
||||||
.peer-tile-active {
|
.peer-tile-active {
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { ChatSessionService } from './chat-session.service';
|
import { ChatSessionService } from './chat-session.service';
|
||||||
|
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
||||||
import type { ChatEntry, ConnectionState } from './models';
|
import type { ChatEntry, ConnectionState } from './models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-page',
|
selector: 'app-chat-page',
|
||||||
imports: [CommonModule, FormsModule, RouterLink],
|
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent],
|
||||||
templateUrl: './chat-page.component.html',
|
templateUrl: './chat-page.component.html',
|
||||||
styleUrl: './chat-page.component.scss',
|
styleUrl: './chat-page.component.scss',
|
||||||
})
|
})
|
||||||
@@ -120,10 +121,25 @@ export class ChatPageComponent {
|
|||||||
await this.session.deleteMessage(entry);
|
await this.session.deleteMessage(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteConversation(peerId: string, event?: Event): Promise<void> {
|
||||||
|
event?.stopPropagation();
|
||||||
|
await this.session.deleteConversation(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
isImageEntry(entry: ChatEntry): boolean {
|
isImageEntry(entry: ChatEntry): boolean {
|
||||||
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isIncomingJsonFileEntry(entry: ChatEntry): boolean {
|
||||||
|
return (
|
||||||
|
entry.kind === 'file' &&
|
||||||
|
entry.direction === 'incoming' &&
|
||||||
|
!!entry.downloadUrl &&
|
||||||
|
!!entry.fileName &&
|
||||||
|
entry.fileName.toLowerCase().endsWith('.json')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
isPeerTyping(peerId: string): boolean {
|
isPeerTyping(peerId: string): boolean {
|
||||||
return this.session.typingPeerIds().includes(peerId);
|
return this.session.typingPeerIds().includes(peerId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1060,6 +1060,51 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteConversation(peerId: string): Promise<void> {
|
||||||
|
const entries = this.messages().filter((entry) => entry.peerId === peerId);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
this.removeMessageById(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearUnreadPeer(peerId);
|
||||||
|
this.clearPeerTyping(peerId);
|
||||||
|
|
||||||
|
const currentUserId = this.currentUser()?.id;
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conversationKey = this.conversationStorageKey(currentUserId, peerId);
|
||||||
|
await this.queueMessageStoreOperation(conversationKey, async () => {
|
||||||
|
const database = await this.openMessageDatabase();
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = database.transaction(ChatSessionService.messageStoreName, 'readwrite');
|
||||||
|
const store = transaction.objectStore(ChatSessionService.messageStoreName);
|
||||||
|
const conversationIndex = store.index('conversationKeyCreatedAt');
|
||||||
|
const rows = await this.waitForRequest(
|
||||||
|
conversationIndex.getAll(
|
||||||
|
IDBKeyRange.bound([conversationKey, 0], [conversationKey, Number.MAX_SAFE_INTEGER]),
|
||||||
|
),
|
||||||
|
) as PersistedChatEntry[];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
store.delete(row.storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForTransaction(transaction);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not delete chat conversation.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private addSystemMessage(peerId: string, text: string): void {
|
private addSystemMessage(peerId: string, text: string): void {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="h4 mb-1">{{ user.displayName }}</div>
|
<div class="h4 mb-1">{{ user.displayName }}</div>
|
||||||
<div class="text-secondary mb-3">{{ user.username }}</div>
|
<div class="text-secondary mb-3">{{ user.username }}</div>
|
||||||
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
||||||
<button class="btn btn-accent w-100 mb-2" type="button" [disabled]="!canOpenChatUi()" (click)="openChatUi()">
|
<button class="btn btn-accent w-100 mb-2" type="button" (click)="openChatUi()">
|
||||||
Open chat UI
|
Open chat UI
|
||||||
</button>
|
</button>
|
||||||
@if (session.isApprovalAdmin()) {
|
@if (session.isApprovalAdmin()) {
|
||||||
|
|||||||
@@ -80,20 +80,17 @@ export class HomePageComponent {
|
|||||||
this.accessKeyLabel = '';
|
this.accessKeyLabel = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
canOpenChatUi(): boolean {
|
|
||||||
return this.session.peers().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async openChatUi(): Promise<void> {
|
async openChatUi(): Promise<void> {
|
||||||
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
||||||
|
|
||||||
if (!peerId) {
|
if (peerId) {
|
||||||
this.session.error.set('No connected peers are available yet.');
|
this.session.selectPeer(peerId);
|
||||||
|
await this.router.navigate(['/chat', peerId]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.session.selectPeer(peerId);
|
this.session.error.set(null);
|
||||||
await this.router.navigate(['/chat', peerId]);
|
await this.router.navigate(['/chat']);
|
||||||
}
|
}
|
||||||
|
|
||||||
cycleTheme(): void {
|
cycleTheme(): void {
|
||||||
|
|||||||
31
client/src/app/json-file-viewer.component.scss
Normal file
31
client/src/app/json-file-viewer.component.scss
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-viewer-shell {
|
||||||
|
width: 95%;
|
||||||
|
max-width: 95%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-viewer-host {
|
||||||
|
max-height: 18rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-viewer-error {
|
||||||
|
padding: 0 0.75rem 0.75rem;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .json-viewer-host .json-container {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
101
client/src/app/json-file-viewer.component.ts
Normal file
101
client/src/app/json-file-viewer.component.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core';
|
||||||
|
|
||||||
|
import type { ChatEntry } from './models';
|
||||||
|
|
||||||
|
type JsonViewTree = object;
|
||||||
|
|
||||||
|
type JsonViewApi = {
|
||||||
|
renderJSON(value: unknown, container: HTMLElement): JsonViewTree;
|
||||||
|
expand?(tree: JsonViewTree): void;
|
||||||
|
destroy?(tree: JsonViewTree): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
jsonview?: JsonViewApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-json-file-viewer',
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="json-viewer-shell">
|
||||||
|
<div #host class="json-viewer-host"></div>
|
||||||
|
@if (errorMessage) {
|
||||||
|
<p class="json-viewer-error mb-0">{{ errorMessage }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrl: './json-file-viewer.component.scss',
|
||||||
|
})
|
||||||
|
export class JsonFileViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||||
|
@Input({ required: true }) entry!: ChatEntry;
|
||||||
|
@ViewChild('host') private readonly host?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
errorMessage: string | null = null;
|
||||||
|
private renderedTree: JsonViewTree | null = null;
|
||||||
|
private renderVersion = 0;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
void this.renderJsonTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
void this.renderJsonTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroyRenderedTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderJsonTree(): Promise<void> {
|
||||||
|
const host = this.host?.nativeElement;
|
||||||
|
|
||||||
|
if (!host || !this.entry.downloadUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderVersion = ++this.renderVersion;
|
||||||
|
const jsonview = window.jsonview;
|
||||||
|
|
||||||
|
this.destroyRenderedTree();
|
||||||
|
host.replaceChildren();
|
||||||
|
this.errorMessage = null;
|
||||||
|
|
||||||
|
if (!jsonview) {
|
||||||
|
this.errorMessage = 'JSON viewer unavailable.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.entry.downloadUrl);
|
||||||
|
const content = await response.text();
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
|
||||||
|
if (renderVersion !== this.renderVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = jsonview.renderJSON(parsed, host);
|
||||||
|
jsonview.expand?.(tree);
|
||||||
|
this.renderedTree = tree;
|
||||||
|
} catch {
|
||||||
|
if (renderVersion !== this.renderVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.errorMessage = 'Could not render JSON preview.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyRenderedTree(): void {
|
||||||
|
if (!this.renderedTree) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.jsonview?.destroy?.(this.renderedTree);
|
||||||
|
this.renderedTree = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/src/jsonview.js
Normal file
1
client/src/jsonview.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user