Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f064c7ea8 | |||
| 76b5cb5142 | |||
| 790b468188 | |||
| 24d6d6d2e7 | |||
| a86fd6c1f3 | |||
| d04179ccbe | |||
| d6eacf5fcc | |||
| 0f974701d4 |
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-05 - 7.8.18 - fix(readme)
|
||||
Update README to reflect new features and updated examples (SPA/PWA/Edge/ServiceWorker) and clarify API usage
|
||||
|
||||
- Rewrite project introduction and features list to highlight Service Worker, Edge Workers, SPA support, and PWA readiness
|
||||
- Replace and expand example sections: Basic Server, Full Configuration, TypedRequest handlers, WebSocket usage, Edge Worker entrypoint, and Service Worker client usage
|
||||
- Update configuration reference: remove legacy compression flags, add spaFallback, defaultAnswer, feedMetadata, and blockWaybackMachine options
|
||||
- Document package exports and add examples for utility servers (WebsiteServer, ServiceServer)
|
||||
- Clarify TypedRequest/TypedSocket usage by showing server.typedrouter and service worker client initializer (getServiceworkerClient)
|
||||
|
||||
## 2025-12-04 - 7.8.11 - fix(web_inject)
|
||||
Improve logging in web injection (TypedRequest) and update dees-comms dependency
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "7.8.14",
|
||||
"version": "7.8.18",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -62,6 +62,7 @@
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@cloudflare/workers-types": "^4.20251202.0",
|
||||
"@design.estate/dees-catalog": "^2.0.3",
|
||||
"@design.estate/dees-comms": "^1.0.30",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
|
||||
848
pnpm-lock.yaml
generated
848
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
195
readme.md
195
readme.md
@@ -1,6 +1,6 @@
|
||||
# @api.global/typedserver
|
||||
|
||||
A powerful TypeScript-first web server framework featuring static file serving, live reload, compression, and seamless type-safe API integration. Part of the `@api.global` ecosystem, it provides a modern foundation for building full-stack TypeScript applications with first-class support for typed HTTP requests and WebSocket communication.
|
||||
A TypeScript-first web server framework for building modern full-stack applications. Features static file serving, live reload, type-safe API integration, service worker support, and edge computing capabilities. Part of the `@api.global` ecosystem.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -8,15 +8,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🔒 **Type-Safe API Ecosystem** - Full TypeScript support with `@api.global/typedrequest` and `@api.global/typedsocket`
|
||||
- 🔒 **Type-Safe API** - Full TypeScript support with `@api.global/typedrequest` and `@api.global/typedsocket`
|
||||
- ⚡ **Live Reload** - Automatic browser refresh on file changes during development
|
||||
- 🗜️ **Compression** - Built-in support for gzip, deflate, and brotli compression
|
||||
- 🌐 **CORS Management** - Flexible cross-origin resource sharing configuration
|
||||
- 🔧 **Service Worker Integration** - Advanced caching and offline capabilities
|
||||
- ☁️ **Edge Worker Support** - Cloudflare Workers compatible edge computing
|
||||
- 📡 **WebSocket Support** - Real-time bidirectional communication via TypedSocket
|
||||
- 🗺️ **Sitemap & Feeds** - Automatic sitemap and RSS feed generation
|
||||
- 🤖 **Robots.txt** - Built-in robots.txt handling
|
||||
- 🛠️ **Service Worker** - Advanced caching, offline support, and background sync
|
||||
- ☁️ **Edge Workers** - Cloudflare Workers compatible edge computing with domain routing
|
||||
- 📡 **WebSocket** - Real-time bidirectional communication via TypedSocket
|
||||
- 🗺️ **SEO Tools** - Built-in sitemap, RSS feed, and robots.txt generation
|
||||
- 🎯 **SPA Support** - Single-page application fallback routing
|
||||
- 📱 **PWA Ready** - Web App Manifest generation for progressive web apps
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
@@ -30,7 +29,7 @@ npm install @api.global/typedserver
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Basic Static File Server
|
||||
### Basic Server
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
@@ -43,10 +42,10 @@ const server = new TypedServer({
|
||||
});
|
||||
|
||||
await server.start();
|
||||
console.log('Server running on port 3000');
|
||||
console.log('Server running!');
|
||||
```
|
||||
|
||||
### Server with All Options
|
||||
### Full Configuration
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
@@ -55,15 +54,23 @@ const server = new TypedServer({
|
||||
port: 8080,
|
||||
serveDir: './dist',
|
||||
cors: true,
|
||||
|
||||
// Development
|
||||
watch: true,
|
||||
injectReload: true,
|
||||
enableCompression: true,
|
||||
preferredCompressionMethod: 'brotli',
|
||||
forceSsl: false,
|
||||
|
||||
// Production
|
||||
forceSsl: true,
|
||||
spaFallback: true, // Serve index.html for client-side routes
|
||||
|
||||
// SEO
|
||||
sitemap: true,
|
||||
feed: true,
|
||||
robots: true,
|
||||
domain: 'example.com',
|
||||
blockWaybackMachine: false,
|
||||
|
||||
// PWA
|
||||
appVersion: 'v1.0.0',
|
||||
manifest: {
|
||||
name: 'My App',
|
||||
@@ -83,11 +90,11 @@ await server.start();
|
||||
### Adding TypedRequest Handlers
|
||||
|
||||
```typescript
|
||||
import { TypedServer, servertools } from '@api.global/typedserver';
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
// Define your typed request interface
|
||||
interface IGetUser {
|
||||
interface IGetUser extends typedrequest.implementsTR<IGetUser> {
|
||||
method: 'getUser';
|
||||
request: { userId: string };
|
||||
response: { name: string; email: string };
|
||||
@@ -95,94 +102,46 @@ interface IGetUser {
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Create a typed router
|
||||
const typedRouter = new typedrequest.TypedRouter();
|
||||
|
||||
// Add a typed handler
|
||||
typedRouter.addTypedHandler<IGetUser>(
|
||||
// Add a typed handler directly to the server's router
|
||||
server.typedrouter.addTypedHandler<IGetUser>(
|
||||
new typedrequest.TypedHandler('getUser', async (data) => {
|
||||
// Your logic here
|
||||
return { name: 'John Doe', email: 'john@example.com' };
|
||||
})
|
||||
);
|
||||
|
||||
// Attach the router to the server
|
||||
server.server.addRoute('/api', new servertools.HandlerTypedRouter(typedRouter));
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### WebSocket Communication with TypedSocket
|
||||
### Real-Time WebSocket Communication
|
||||
|
||||
TypedServer automatically sets up TypedSocket for real-time communication:
|
||||
|
||||
```typescript
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
const typedRouter = new typedrequest.TypedRouter();
|
||||
|
||||
await server.start();
|
||||
|
||||
// Create WebSocket server attached to the HTTP server
|
||||
const socketServer = await typedsocket.TypedSocket.createServer(
|
||||
typedRouter,
|
||||
server.server
|
||||
);
|
||||
|
||||
// Handle real-time events
|
||||
interface IChatMessage {
|
||||
interface IChatMessage extends typedrequest.implementsTR<IChatMessage> {
|
||||
method: 'sendMessage';
|
||||
request: { text: string; room: string };
|
||||
response: { messageId: string; timestamp: number };
|
||||
}
|
||||
|
||||
typedRouter.addTypedHandler<IChatMessage>(
|
||||
const server = new TypedServer({ serveDir: './public', cors: true });
|
||||
|
||||
// Handle real-time messages
|
||||
server.typedrouter.addTypedHandler<IChatMessage>(
|
||||
new typedrequest.TypedHandler('sendMessage', async (data) => {
|
||||
return { messageId: crypto.randomUUID(), timestamp: Date.now() };
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## 🛠️ Server Tools
|
||||
await server.start();
|
||||
|
||||
### Custom Route Handlers
|
||||
|
||||
```typescript
|
||||
import { servertools } from '@api.global/typedserver';
|
||||
|
||||
const server = new servertools.Server({
|
||||
cors: true,
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
// Add a custom route with handler
|
||||
server.addRoute('/api/hello', new servertools.Handler('GET', async (req, res) => {
|
||||
res.json({ message: 'Hello, World!' });
|
||||
}));
|
||||
|
||||
// Serve static files from a directory
|
||||
server.addRoute('/{*splat}', new servertools.HandlerStatic('./public', {
|
||||
serveIndexHtmlDefault: true,
|
||||
enableCompression: true,
|
||||
}));
|
||||
|
||||
await server.start(3000);
|
||||
```
|
||||
|
||||
### Proxy Handler
|
||||
|
||||
```typescript
|
||||
import { servertools } from '@api.global/typedserver';
|
||||
|
||||
const server = new servertools.Server({ cors: true });
|
||||
|
||||
// Proxy requests to another server
|
||||
server.addRoute('/proxy/{*splat}', new servertools.HandlerProxy({
|
||||
target: 'https://api.example.com',
|
||||
}));
|
||||
|
||||
await server.start(3000);
|
||||
// Push messages to connected clients
|
||||
const connections = await server.typedsocket.findAllTargetConnectionsByTag('typedserver_frontend');
|
||||
for (const conn of connections) {
|
||||
// Push to specific clients
|
||||
}
|
||||
```
|
||||
|
||||
## ☁️ Edge Worker (Cloudflare Workers)
|
||||
@@ -190,36 +149,44 @@ await server.start(3000);
|
||||
```typescript
|
||||
import { EdgeWorker, DomainRouter } from '@api.global/typedserver/edgeworker';
|
||||
|
||||
const router = new DomainRouter();
|
||||
const worker = new EdgeWorker();
|
||||
|
||||
router.addDomainInstruction({
|
||||
// Configure domain routing
|
||||
worker.domainRouter.addDomainInstruction({
|
||||
domainPattern: '*.example.com',
|
||||
originUrl: 'https://origin.example.com',
|
||||
type: 'cache',
|
||||
cacheConfig: { maxAge: 3600 },
|
||||
});
|
||||
|
||||
const worker = new EdgeWorker(router);
|
||||
worker.domainRouter.addDomainInstruction({
|
||||
domainPattern: 'api.example.com',
|
||||
originUrl: 'https://api-origin.example.com',
|
||||
type: 'origin', // Pass through to origin
|
||||
});
|
||||
|
||||
// In your Cloudflare Worker entry point
|
||||
// Cloudflare Worker entry point
|
||||
export default {
|
||||
fetch: (request: Request, env: any, ctx: any) => worker.handleRequest(request, env, ctx),
|
||||
fetch: worker.fetchFunction.bind(worker),
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Service Worker Client
|
||||
|
||||
Manage service workers in your frontend application:
|
||||
|
||||
```typescript
|
||||
import { ServiceWorkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
import { getServiceworkerClient } from '@api.global/typedserver/web_serviceworker_client';
|
||||
|
||||
const swClient = new ServiceWorkerClient();
|
||||
|
||||
// Register and manage service worker
|
||||
await swClient.register('/serviceworker.bundle.js');
|
||||
|
||||
// Listen for updates
|
||||
swClient.onUpdate(() => {
|
||||
console.log('New version available!');
|
||||
// Initialize and register service worker
|
||||
const swClient = await getServiceworkerClient({
|
||||
pollInterval: 30000, // Poll for updates every 30s
|
||||
});
|
||||
|
||||
// The service worker handles:
|
||||
// - Cache invalidation from server
|
||||
// - Offline support
|
||||
// - Background sync
|
||||
```
|
||||
|
||||
## 📋 Configuration Reference
|
||||
@@ -233,9 +200,8 @@ swClient.onUpdate(() => {
|
||||
| `cors` | `boolean` | `true` | Enable CORS headers |
|
||||
| `watch` | `boolean` | `false` | Watch files for changes |
|
||||
| `injectReload` | `boolean` | `false` | Inject live reload script into HTML |
|
||||
| `enableCompression` | `boolean` | `false` | Enable response compression |
|
||||
| `preferredCompressionMethod` | `'gzip' \| 'deflate' \| 'brotli'` | - | Preferred compression algorithm |
|
||||
| `forceSsl` | `boolean` | `false` | Redirect HTTP to HTTPS |
|
||||
| `spaFallback` | `boolean` | `false` | Serve index.html for non-file routes |
|
||||
| `sitemap` | `boolean` | `false` | Generate sitemap at `/sitemap` |
|
||||
| `feed` | `boolean` | `false` | Generate RSS feed at `/feed` |
|
||||
| `robots` | `boolean` | `false` | Serve robots.txt |
|
||||
@@ -244,16 +210,39 @@ swClient.onUpdate(() => {
|
||||
| `manifest` | `object` | - | Web App Manifest configuration |
|
||||
| `publicKey` | `string` | - | SSL certificate |
|
||||
| `privateKey` | `string` | - | SSL private key |
|
||||
| `defaultAnswer` | `function` | - | Custom default response handler |
|
||||
| `feedMetadata` | `object` | - | RSS feed metadata options |
|
||||
| `blockWaybackMachine` | `boolean` | `false` | Block Wayback Machine archiving |
|
||||
|
||||
## 🏗️ Architecture
|
||||
## 🏗️ Package Exports
|
||||
|
||||
```
|
||||
@api.global/typedserver
|
||||
├── /backend - Main server exports (TypedServer, servertools)
|
||||
├── /edgeworker - Cloudflare Workers edge computing
|
||||
├── /web_inject - Live reload script injection
|
||||
├── /web_serviceworker - Service Worker implementation
|
||||
└── /web_serviceworker_client - Service Worker client utilities
|
||||
├── . - Main server (TypedServer)
|
||||
├── /backend - Alias for main server
|
||||
├── /edgeworker - Cloudflare Workers edge computing
|
||||
├── /web_inject - Live reload script injection
|
||||
├── /web_serviceworker - Service Worker implementation
|
||||
└── /web_serviceworker_client - Service Worker client utilities
|
||||
```
|
||||
|
||||
## 🔄 Utility Servers
|
||||
|
||||
Pre-configured server templates for common use cases:
|
||||
|
||||
```typescript
|
||||
import { utilityservers } from '@api.global/typedserver';
|
||||
|
||||
// WebsiteServer - optimized for static websites
|
||||
const websiteServer = new utilityservers.WebsiteServer({
|
||||
serveDir: './dist',
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
// ServiceServer - optimized for API services
|
||||
const serviceServer = new utilityservers.ServiceServer({
|
||||
cors: true,
|
||||
});
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '7.8.11',
|
||||
version: '7.8.18',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ export class TypedServer {
|
||||
websocket: {
|
||||
typedRouter: this.typedrouter,
|
||||
onConnectionOpen: (peer) => {
|
||||
peer.tags.add('typedserver_frontend');
|
||||
peer.tags.add('allClients');
|
||||
console.log(`WebSocket connected: ${peer.id}`);
|
||||
},
|
||||
onConnectionClose: (peer) => {
|
||||
@@ -644,6 +644,8 @@ export class TypedServer {
|
||||
);
|
||||
pushTime.fire({
|
||||
time: this.lastReload,
|
||||
}).catch(err => {
|
||||
console.warn('Failed to push latest server change time to client:', err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,9 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
// DeesComms for push communication
|
||||
import * as deesComms from '@design.estate/dees-comms';
|
||||
|
||||
// Dees-catalog for UI components
|
||||
import { DeesContextmenu } from '@design.estate/dees-catalog';
|
||||
|
||||
export {
|
||||
LitElement,
|
||||
html,
|
||||
@@ -14,6 +17,7 @@ export {
|
||||
property,
|
||||
state,
|
||||
deesComms,
|
||||
DeesContextmenu,
|
||||
};
|
||||
|
||||
export type { CSSResult, TemplateResult };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, html, css, property, state, customElement } from './plugins.js';
|
||||
import { LitElement, html, css, property, state, customElement, DeesContextmenu } from './plugins.js';
|
||||
import type { CSSResult, TemplateResult } from './plugins.js';
|
||||
import { sharedStyles, panelStyles, tableStyles, buttonStyles } from './sw-dash-styles.js';
|
||||
|
||||
@@ -237,6 +237,17 @@ export class SwDashRequests extends LitElement {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.method-stat-card:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.method-stat-card.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border: 1px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.method-stat-name {
|
||||
@@ -504,6 +515,11 @@ export class SwDashRequests extends LitElement {
|
||||
// Local filtering - no HTTP request
|
||||
}
|
||||
|
||||
private setMethodFilter(method: string): void {
|
||||
// Toggle: clicking the same method clears the filter
|
||||
this.methodFilter = this.methodFilter === method ? '' : method;
|
||||
}
|
||||
|
||||
private handleSearch(e: Event): void {
|
||||
this.searchText = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
}
|
||||
@@ -546,6 +562,90 @@ export class SwDashRequests extends LitElement {
|
||||
this.modalOpen = true;
|
||||
}
|
||||
|
||||
private handleContextMenu(event: MouseEvent, group: IGroupedRequest): void {
|
||||
// Build full message object for copying
|
||||
const fullMessage = {
|
||||
correlationId: group.correlationId,
|
||||
method: group.method,
|
||||
timestamp: group.timestamp,
|
||||
durationMs: group.durationMs,
|
||||
request: group.request ? {
|
||||
direction: group.request.direction,
|
||||
phase: group.request.phase,
|
||||
timestamp: group.request.timestamp,
|
||||
payload: group.request.payload,
|
||||
} : null,
|
||||
response: group.response ? {
|
||||
direction: group.response.direction,
|
||||
phase: group.response.phase,
|
||||
timestamp: group.response.timestamp,
|
||||
durationMs: group.response.durationMs,
|
||||
payload: group.response.payload,
|
||||
error: group.response.error,
|
||||
} : null,
|
||||
};
|
||||
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'Copy Full Message',
|
||||
iconName: 'copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(fullMessage, null, 2));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Request Payload',
|
||||
iconName: 'upload',
|
||||
disabled: !group.request,
|
||||
action: async () => {
|
||||
if (group.request) {
|
||||
await navigator.clipboard.writeText(JSON.stringify(group.request.payload, null, 2));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Response Payload',
|
||||
iconName: 'download',
|
||||
disabled: !group.response,
|
||||
action: async () => {
|
||||
if (group.response) {
|
||||
await navigator.clipboard.writeText(JSON.stringify(group.response.payload, null, 2));
|
||||
}
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Copy Correlation ID',
|
||||
iconName: 'hash',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(group.correlationId);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Method Name',
|
||||
iconName: 'tag',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(group.method);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Filter by Method',
|
||||
iconName: 'filter',
|
||||
action: async () => {
|
||||
this.setMethodFilter(group.method);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Show Payload',
|
||||
iconName: 'eye',
|
||||
action: async () => {
|
||||
this.openPayloadModal(group);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private closeModal(): void {
|
||||
this.modalOpen = false;
|
||||
this.selectedGroup = null;
|
||||
@@ -781,7 +881,10 @@ export class SwDashRequests extends LitElement {
|
||||
<div class="method-stats-title">Methods</div>
|
||||
<div class="method-stats-grid">
|
||||
${Object.entries(this.stats.methodCounts).slice(0, 8).map(([method, data]) => html`
|
||||
<div class="method-stat-card">
|
||||
<div
|
||||
class="method-stat-card ${this.methodFilter === method ? 'active' : ''}"
|
||||
@click="${() => this.setMethodFilter(method)}"
|
||||
>
|
||||
<div class="method-stat-name" title="${method}">${method}</div>
|
||||
<div class="method-stat-details">
|
||||
<span>${data.requests} req</span>
|
||||
@@ -813,9 +916,9 @@ export class SwDashRequests extends LitElement {
|
||||
</select>
|
||||
|
||||
<span class="filter-label">Method:</span>
|
||||
<select class="filter-select" @change="${this.handleMethodFilterChange}">
|
||||
<select class="filter-select" .value="${this.methodFilter}" @change="${this.handleMethodFilterChange}">
|
||||
<option value="">All Methods</option>
|
||||
${this.methods.map(m => html`<option value="${m}">${m}</option>`)}
|
||||
${this.methods.map(m => html`<option value="${m}" ?selected="${this.methodFilter === m}">${m}</option>`)}
|
||||
</select>
|
||||
|
||||
<input
|
||||
@@ -838,7 +941,10 @@ export class SwDashRequests extends LitElement {
|
||||
` : html`
|
||||
<div class="requests-list">
|
||||
${groupedLogs.map(group => html`
|
||||
<div class="request-card ${group.hasError ? 'has-error' : ''}">
|
||||
<div
|
||||
class="request-card ${group.hasError ? 'has-error' : ''}"
|
||||
@contextmenu="${(e: MouseEvent) => this.handleContextMenu(e, group)}"
|
||||
>
|
||||
<div class="request-header">
|
||||
<div>
|
||||
<div class="request-badges">
|
||||
|
||||
Reference in New Issue
Block a user