json viewer
This commit is contained in:
@@ -53,6 +53,9 @@
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [
|
||||
"src/jsonview.js"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
|
||||
@@ -9,6 +9,10 @@ export const routes: Routes = [
|
||||
path: '',
|
||||
component: HomePageComponent,
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
component: ChatPageComponent,
|
||||
},
|
||||
{
|
||||
path: 'chat/:peerId',
|
||||
component: ChatPageComponent,
|
||||
|
||||
@@ -42,10 +42,10 @@
|
||||
}
|
||||
|
||||
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
||||
<article class="peer-tile" [class.peer-tile-active]="connectedPeer.id === peerId()">
|
||||
<button
|
||||
class="peer-tile text-start"
|
||||
class="peer-tile-main text-start"
|
||||
type="button"
|
||||
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
||||
(click)="switchPeer(connectedPeer.id)"
|
||||
>
|
||||
<div class="peer-tile-row">
|
||||
@@ -71,6 +71,16 @@
|
||||
></span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="peer-tile-delete"
|
||||
type="button"
|
||||
title="Delete conversation"
|
||||
aria-label="Delete conversation"
|
||||
(click)="deleteConversation(connectedPeer.id, $event)"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -121,6 +131,10 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (isIncomingJsonFileEntry(entry)) {
|
||||
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
||||
}
|
||||
|
||||
<div>
|
||||
<div class="fw-semibold">{{ entry.fileName }}</div>
|
||||
@if (entry.fileSize) {
|
||||
|
||||
@@ -106,8 +106,12 @@
|
||||
}
|
||||
|
||||
.peer-tile {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.95rem 1rem;
|
||||
padding: 0.8rem 0.85rem 0.8rem 1rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
color: inherit;
|
||||
@@ -115,6 +119,30 @@
|
||||
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:focus-visible,
|
||||
.peer-tile-active {
|
||||
|
||||
@@ -5,11 +5,12 @@ import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { ChatSessionService } from './chat-session.service';
|
||||
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
||||
import type { ChatEntry, ConnectionState } from './models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-page',
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent],
|
||||
templateUrl: './chat-page.component.html',
|
||||
styleUrl: './chat-page.component.scss',
|
||||
})
|
||||
@@ -120,10 +121,25 @@ export class ChatPageComponent {
|
||||
await this.session.deleteMessage(entry);
|
||||
}
|
||||
|
||||
async deleteConversation(peerId: string, event?: Event): Promise<void> {
|
||||
event?.stopPropagation();
|
||||
await this.session.deleteConversation(peerId);
|
||||
}
|
||||
|
||||
isImageEntry(entry: ChatEntry): boolean {
|
||||
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 {
|
||||
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 {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="h4 mb-1">{{ user.displayName }}</div>
|
||||
<div class="text-secondary mb-3">{{ user.username }}</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
|
||||
</button>
|
||||
@if (session.isApprovalAdmin()) {
|
||||
|
||||
@@ -80,20 +80,17 @@ export class HomePageComponent {
|
||||
this.accessKeyLabel = '';
|
||||
}
|
||||
|
||||
canOpenChatUi(): boolean {
|
||||
return this.session.peers().length > 0;
|
||||
}
|
||||
|
||||
async openChatUi(): Promise<void> {
|
||||
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
||||
|
||||
if (!peerId) {
|
||||
this.session.error.set('No connected peers are available yet.');
|
||||
if (peerId) {
|
||||
this.session.selectPeer(peerId);
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.selectPeer(peerId);
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
this.session.error.set(null);
|
||||
await this.router.navigate(['/chat']);
|
||||
}
|
||||
|
||||
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