parent
78a0a8bd18
commit
fe8b589787
@ -26,27 +26,64 @@
|
||||
<!-- Main Content -->
|
||||
<div class="content">
|
||||
<div class="info-box">
|
||||
<p>This tool allows you to receive and view HTTP events (kind 21120) from Nostr relays. Connect to a relay, subscribe to events, and decrypt messages.</p>
|
||||
<p>This tool allows you to receive and view HTTP events (kind 21120) from Nostr relays, QR codes, or raw text input. Choose your preferred method using the tabs below.</p>
|
||||
</div>
|
||||
|
||||
<h2>Relay Connection</h2>
|
||||
<div class="relay-connection">
|
||||
<div class="relay-input-container">
|
||||
<label for="relayUrl">Relay URL:</label>
|
||||
<input type="text" id="relayUrl" value="wss://relay.degmods.com" placeholder="wss://relay.example.com">
|
||||
<button id="connectRelayBtn" class="relay-connect-button">Connect</button>
|
||||
<!-- Tab Navigation - Updated data-tab attributes to match section IDs -->
|
||||
<div class="tabs">
|
||||
<button class="tab-button active" data-tab="relay-connection-section">From Relay</button>
|
||||
<button class="tab-button" data-tab="qr-code-scanner-section">From QR Scanner</button>
|
||||
<button class="tab-button" data-tab="raw-event-input-section">From Raw Text</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content - Each section gets a distinct ID -->
|
||||
<!-- Relay Tab Content -->
|
||||
<div id="relay-connection-section" class="active">
|
||||
<h2>Relay Connection</h2>
|
||||
<div class="relay-connection">
|
||||
<div class="relay-input-container">
|
||||
<label for="relayUrl">Relay URL:</label>
|
||||
<input type="text" id="relayUrl" value="wss://relay.degmods.com" placeholder="wss://relay.example.com">
|
||||
<button id="connectRelayBtn" class="relay-connect-button">Connect</button>
|
||||
</div>
|
||||
<div id="relayStatus" class="relay-status">Not connected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Scanner Tab Content -->
|
||||
<div id="qr-code-scanner-section">
|
||||
<h2>QR Code Scanner</h2>
|
||||
<div class="qr-scanner-container">
|
||||
<div class="qr-viewport">
|
||||
<video id="qrVideo"></video>
|
||||
<canvas id="qrCanvas" style="display: none;"></canvas>
|
||||
</div>
|
||||
<div class="qr-controls">
|
||||
<button id="startScanBtn" class="action-button">Start Camera</button>
|
||||
<button id="stopScanBtn" class="action-button" disabled>Stop Camera</button>
|
||||
</div>
|
||||
<div id="qrStatus" class="status-message">Camera inactive. Click Start Camera to begin scanning.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Text Tab Content -->
|
||||
<div id="raw-event-input-section">
|
||||
<h2>Raw Event Input</h2>
|
||||
<div class="raw-input-container">
|
||||
<p>Paste a raw Nostr event JSON below:</p>
|
||||
<textarea id="rawEventInput" placeholder='{"id": "...", "pubkey": "...", "created_at": 1234567890, "kind": 21120, "tags": [], "content": "..."}' rows="10"></textarea>
|
||||
<button id="parseRawEventBtn" class="action-button">Parse Event</button>
|
||||
<div id="rawInputStatus" class="status-message"></div>
|
||||
</div>
|
||||
<div id="relayStatus" class="relay-status">Not connected</div>
|
||||
</div>
|
||||
|
||||
<h2>Received Events</h2>
|
||||
<div class="received-events">
|
||||
|
||||
<div class="events-container">
|
||||
<div class="events-sidebar">
|
||||
<div id="eventsList" class="events-list">
|
||||
<div class="empty-state">
|
||||
No events received yet. Connect to a relay to start receiving events.
|
||||
No events received yet. Use one of the methods above to receive events.
|
||||
</div>
|
||||
<!-- Events will be displayed here -->
|
||||
</div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
// External dependencies
|
||||
import jsQR from 'jsqr';
|
||||
import * as nostrTools from 'nostr-tools';
|
||||
|
||||
// Internal imports
|
||||
@ -706,6 +707,271 @@ Decrypted key: ${decryptedKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Setup tab navigation
|
||||
function setupTabNavigation(): void {
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
const contentSections = document.querySelectorAll('#relay-connection-section, #qr-code-scanner-section, #raw-event-input-section');
|
||||
|
||||
// Add click event listeners to tab buttons
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
// Get the target tab
|
||||
const targetTabId = (button as HTMLElement).dataset.tab;
|
||||
if (!targetTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove active class from all buttons and content sections
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
contentSections.forEach(section => section.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and corresponding content section
|
||||
button.classList.add('active');
|
||||
const targetSection = document.getElementById(targetTabId);
|
||||
if (targetSection) {
|
||||
targetSection.classList.add('active');
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
console.log(`Tab switched to: ${targetTabId}, active sections: ${document.querySelectorAll('.active').length}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Variables for QR scanner
|
||||
let qrScanning = false;
|
||||
let videoStream: MediaStream | null = null;
|
||||
|
||||
// Setup QR code scanner
|
||||
function setupQRScanner(): void {
|
||||
const startScanBtn = document.getElementById('startScanBtn');
|
||||
const stopScanBtn = document.getElementById('stopScanBtn');
|
||||
const qrVideo = document.getElementById('qrVideo') as HTMLVideoElement;
|
||||
const qrCanvas = document.getElementById('qrCanvas') as HTMLCanvasElement;
|
||||
const qrStatus = document.getElementById('qrStatus');
|
||||
|
||||
// Function to start the QR scanner
|
||||
async function startQRScanner() {
|
||||
// Already scanning
|
||||
if (qrScanning) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get access to the camera
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' }
|
||||
});
|
||||
|
||||
// Update status
|
||||
if (qrStatus) {
|
||||
qrStatus.textContent = 'Camera active. Point at a QR code.';
|
||||
qrStatus.className = 'status-message';
|
||||
}
|
||||
|
||||
// Set video source to camera stream
|
||||
if (qrVideo) {
|
||||
qrVideo.srcObject = stream;
|
||||
qrVideo.play();
|
||||
videoStream = stream;
|
||||
}
|
||||
|
||||
// Enable/disable buttons
|
||||
if (startScanBtn) {
|
||||
startScanBtn.setAttribute('disabled', 'true');
|
||||
}
|
||||
if (stopScanBtn) {
|
||||
stopScanBtn.removeAttribute('disabled');
|
||||
}
|
||||
|
||||
// Start scanning
|
||||
qrScanning = true;
|
||||
scanQRCode();
|
||||
} catch (error) {
|
||||
// Handle errors
|
||||
console.error('Error accessing camera:', error);
|
||||
if (qrStatus) {
|
||||
qrStatus.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
qrStatus.className = 'status-message error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to stop the QR scanner
|
||||
function stopQRScanner() {
|
||||
if (!qrScanning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop all tracks
|
||||
if (videoStream) {
|
||||
videoStream.getTracks().forEach(track => track.stop());
|
||||
videoStream = null;
|
||||
}
|
||||
|
||||
// Update status
|
||||
if (qrStatus) {
|
||||
qrStatus.textContent = 'Camera inactive. Click Start Camera to begin scanning.';
|
||||
qrStatus.className = 'status-message';
|
||||
}
|
||||
|
||||
// Enable/disable buttons
|
||||
if (startScanBtn) {
|
||||
startScanBtn.removeAttribute('disabled');
|
||||
}
|
||||
if (stopScanBtn) {
|
||||
stopScanBtn.setAttribute('disabled', 'true');
|
||||
}
|
||||
|
||||
qrScanning = false;
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
if (startScanBtn) {
|
||||
startScanBtn.addEventListener('click', startQRScanner);
|
||||
}
|
||||
|
||||
if (stopScanBtn) {
|
||||
stopScanBtn.addEventListener('click', stopQRScanner);
|
||||
}
|
||||
|
||||
// Function to scan for QR codes
|
||||
function scanQRCode() {
|
||||
if (!qrScanning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Request animation frame for next scan
|
||||
window.requestAnimationFrame(scanQRCode);
|
||||
|
||||
if (!qrVideo || !qrCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if video isn't playing
|
||||
if (qrVideo.readyState !== qrVideo.HAVE_ENOUGH_DATA) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get canvas context
|
||||
const context = qrCanvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas dimensions to match video
|
||||
qrCanvas.width = qrVideo.videoWidth;
|
||||
qrCanvas.height = qrVideo.videoHeight;
|
||||
|
||||
// Draw current video frame to canvas
|
||||
context.drawImage(qrVideo, 0, 0, qrCanvas.width, qrCanvas.height);
|
||||
|
||||
// Get image data for QR detection
|
||||
const imageData = context.getImageData(0, 0, qrCanvas.width, qrCanvas.height);
|
||||
|
||||
// Process with jsQR
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: 'dontInvert'
|
||||
});
|
||||
|
||||
// Process QR code if found
|
||||
if (code) {
|
||||
console.log('QR code detected:', code.data);
|
||||
if (qrStatus) {
|
||||
qrStatus.textContent = 'QR code found! Processing...';
|
||||
}
|
||||
|
||||
// Try to parse as a Nostr event
|
||||
try {
|
||||
const eventData = JSON.parse(code.data);
|
||||
|
||||
// Check if this is a valid Nostr event
|
||||
if (eventData && eventData.id && eventData.pubkey &&
|
||||
eventData.created_at && eventData.kind === 21120) {
|
||||
|
||||
// Stop scanning after successful detection
|
||||
stopQRScanner();
|
||||
|
||||
if (qrStatus) {
|
||||
qrStatus.textContent = 'Valid Nostr event found! Event processed.';
|
||||
}
|
||||
|
||||
// Process the event
|
||||
processEvent(eventData);
|
||||
} else {
|
||||
if (qrStatus) {
|
||||
qrStatus.textContent = 'QR code found, but not a valid kind 21120 Nostr event.';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing QR code data:', error);
|
||||
if (qrStatus) {
|
||||
qrStatus.textContent = `Error parsing QR data: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
if (startScanBtn) {
|
||||
startScanBtn.addEventListener('click', startQRScanner);
|
||||
}
|
||||
|
||||
if (stopScanBtn) {
|
||||
stopScanBtn.addEventListener('click', stopQRScanner);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup raw text input
|
||||
function setupRawTextInput(): void {
|
||||
const parseRawEventBtn = document.getElementById('parseRawEventBtn');
|
||||
const rawEventInput = document.getElementById('rawEventInput') as HTMLTextAreaElement;
|
||||
const rawInputStatus = document.getElementById('rawInputStatus');
|
||||
|
||||
if (parseRawEventBtn && rawEventInput) {
|
||||
parseRawEventBtn.addEventListener('click', () => {
|
||||
const rawText = rawEventInput.value.trim();
|
||||
|
||||
if (!rawText) {
|
||||
if (rawInputStatus) {
|
||||
rawInputStatus.textContent = 'Please enter a raw Nostr event.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the JSON data
|
||||
const eventData = JSON.parse(rawText);
|
||||
|
||||
// Validate as Nostr event
|
||||
if (eventData && eventData.id && eventData.pubkey &&
|
||||
eventData.created_at && eventData.kind === 21120) {
|
||||
|
||||
// Process the event
|
||||
processEvent(eventData);
|
||||
|
||||
if (rawInputStatus) {
|
||||
rawInputStatus.textContent = 'Event successfully processed!';
|
||||
}
|
||||
|
||||
// Clear the input for convenience
|
||||
rawEventInput.value = '';
|
||||
} else {
|
||||
if (rawInputStatus) {
|
||||
rawInputStatus.textContent = 'Not a valid kind 21120 Nostr event.';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing raw event:', error);
|
||||
if (rawInputStatus) {
|
||||
rawInputStatus.textContent = `Error parsing: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Get DOM elements
|
||||
@ -716,6 +982,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const connectRelayBtn = document.getElementById('connectRelayBtn');
|
||||
|
||||
// Tab functionality
|
||||
setupTabNavigation();
|
||||
|
||||
// QR code scanner setup
|
||||
setupQRScanner();
|
||||
|
||||
// Raw text input setup
|
||||
setupRawTextInput();
|
||||
|
||||
// Connect to relay and automatically start subscription for logged-in user
|
||||
if (connectRelayBtn) {
|
||||
connectRelayBtn.addEventListener('click', async () => {
|
||||
|
@ -471,4 +471,131 @@ pre {
|
||||
.content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab Interface Styles */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-right: 10px;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent-color);
|
||||
border-bottom: 2px solid var(--accent-color);
|
||||
}
|
||||
|
||||
/* Tab content sections */
|
||||
#relay-connection-section,
|
||||
#qr-code-scanner-section,
|
||||
#raw-event-input-section {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#relay-connection-section.active,
|
||||
#qr-code-scanner-section.active,
|
||||
#raw-event-input-section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* QR Scanner Styles */
|
||||
.qr-scanner-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.qr-viewport {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 300px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 3px solid var(--accent-color);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.qr-viewport video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.qr-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 8px 15px;
|
||||
background-color: var(--button-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Raw Input Styles */
|
||||
.raw-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.raw-input-container textarea {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.raw-input-container button {
|
||||
align-self: flex-start;
|
||||
}
|
@ -1158,4 +1158,131 @@ footer {
|
||||
|
||||
.clear-events-button:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
/* Tab Interface Styles */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-right: 10px;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent-color);
|
||||
border-bottom: 2px solid var(--accent-color);
|
||||
}
|
||||
|
||||
/* Tab content sections */
|
||||
#relay-connection-section,
|
||||
#qr-code-scanner-section,
|
||||
#raw-event-input-section {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#relay-connection-section.active,
|
||||
#qr-code-scanner-section.active,
|
||||
#raw-event-input-section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* QR Scanner Styles */
|
||||
.qr-scanner-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.qr-viewport {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 300px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 3px solid var(--accent-color);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.qr-viewport video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.qr-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 8px 15px;
|
||||
background-color: var(--button-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Raw Input Styles */
|
||||
.raw-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.raw-input-container textarea {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.raw-input-container button {
|
||||
align-self: flex-start;
|
||||
}
|
@ -25,7 +25,7 @@ module.exports = {
|
||||
new NodePolyfillPlugin(),
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{ from: 'src/styles.css', to: 'styles.css' },
|
||||
{ from: 'styles.css', to: 'styles.css' },
|
||||
{ from: 'http.png', to: 'http.png' },
|
||||
{ from: 'index.html', to: 'index.html' },
|
||||
{ from: 'help.html', to: 'help.html' },
|
||||
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
},
|
||||
@ -126,6 +127,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
|
@ -24,6 +24,7 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user