diff --git a/crates/librqbit/webui/dist/webui/src/index.jsx b/crates/librqbit/webui/dist/webui/src/index.jsx
new file mode 100644
index 0000000..9ec52c2
--- /dev/null
+++ b/crates/librqbit/webui/dist/webui/src/index.jsx
@@ -0,0 +1,332 @@
+import { Fragment, render } from 'preact';
+import { useState } from 'preact/hooks';
+// Define API URL and base path
+const apiUrl = (window.origin === 'null' || window.origin === 'http://localhost:3031') ? 'http://localhost:3030' : '';
+// Helper function for making API requests (async/await)
+async function makeRequest(method, path, data) {
+ const url = apiUrl + path;
+ const options = {
+ method,
+ headers: {
+ 'Accept': 'application/json',
+ },
+ body: data,
+ };
+ try {
+ const response = await fetch(url, options);
+ if (!response.ok) {
+ const errorBody = await response.text();
+ try {
+ const json = JSON.parse(errorBody);
+ displayApiError({
+ status: response.status,
+ statusText: response.statusText,
+ body: json.human_readable !== undefined ? json.human_readable : errorBody,
+ });
+ }
+ catch (e) {
+ displayApiError({
+ status: response.status,
+ statusText: response.statusText,
+ body: errorBody,
+ });
+ }
+ return Promise.reject(errorBody);
+ }
+ const result = await response.json();
+ return result;
+ }
+ catch (error) {
+ console.error(error);
+ displayApiError({
+ status: error.status,
+ statusText: error.statusText,
+ body: error.toString(),
+ });
+ return Promise.reject(`Error: ${error.message}`);
+ }
+}
+// Function to get detailed information about a torrent (async/await)
+async function getTorrentDetails(index) {
+ return makeRequest('GET', `/torrents/${index}`);
+}
+// Function to get detailed statistics about a torrent (async/await)
+async function getTorrentStats(index) {
+ return makeRequest('GET', `/torrents/${index}/stats`);
+}
+function TorrentRow(props) {
+ const { detailsResponse, statsResponse } = this.props;
+ const totalBytes = statsResponse.snapshot.total_bytes;
+ const downloadedBytes = statsResponse.snapshot.have_bytes;
+ const downloadPercentage = (downloadedBytes / totalBytes) * 100;
+ return (
+ {/* Create and render columns */}
+
+
+
+
+
+
+
);
+}
+// Define a Preact component for a column
+const Column = ({ label, value }) => ();
+// Define a Preact component for a column with a progress bar
+const ColumnWithProgressBar = ({ label, percentage }) => (
+
{label}
+
+
{percentage.toFixed(2)}%
+
);
+const DeferredTorrent = ({ torrent }) => {
+ const defaultDetails = {
+ files: []
+ };
+ const defaultStats = {
+ snapshot: {
+ have_bytes: 0,
+ downloaded_and_checked_bytes: 0,
+ downloaded_and_checked_pieces: 0,
+ fetched_bytes: 0,
+ uploaded_bytes: 0,
+ initially_needed_bytes: 0,
+ remaining_bytes: 0,
+ total_bytes: 0,
+ total_piece_download_ms: 0,
+ peer_stats: {
+ queued: 0,
+ connecting: 0,
+ live: 0,
+ seen: 0,
+ dead: 0,
+ not_needed: 0
+ }
+ },
+ average_piece_download_time: {
+ secs: 0,
+ nanos: 0
+ },
+ download_speed: {
+ mbps: 0,
+ human_readable: ''
+ },
+ all_time_download_speed: {
+ mbps: 0,
+ human_readable: ''
+ },
+ time_remaining: {
+ human_readable: ''
+ }
+ };
+ const [detailsResponse, updateDetailsResponse] = useState(defaultDetails);
+ const [statsResponse, updateStatsResponse] = useState(defaultStats);
+ const update = async () => {
+ let a = getTorrentDetails(torrent.id).then((details) => {
+ updateDetailsResponse(details);
+ });
+ let b = getTorrentStats(torrent.id).then((stats) => {
+ updateStatsResponse(stats);
+ });
+ await Promise.all([a, b]);
+ setTimeout(update, 500);
+ };
+ setTimeout(update, 500);
+ return ;
+};
+// Render function to display all torrents
+async function displayTorrents() {
+ const response = await makeRequest('GET', '/torrents');
+ const torrents = response.torrents;
+ // Get the torrents container
+ const torrentsContainer = document.getElementById('output');
+ let root = torrents.map((t) => {
+ (
+
+ );
+ });
+ render(root, torrentsContainer);
+}
+// Function to update HTML for a torrent row
+function updateTorrentRow(torrentRow, detailsResponse, statsResponse) {
+ // Calculate download percentage
+ const totalBytes = statsResponse.snapshot.total_bytes;
+ const downloadedBytes = statsResponse.snapshot.have_bytes;
+ const downloadPercentage = (downloadedBytes / totalBytes) * 100;
+ // Display basic information about the torrent
+ const largestFileName = getLargestFileName(detailsResponse);
+ const downloadSpeed = statsResponse.download_speed.human_readable;
+ const eta = getCompletionETA(statsResponse);
+ const peers = `${statsResponse.snapshot.peer_stats.live} / ${statsResponse.snapshot.peer_stats.seen}`;
+ // Update or create columns in the torrent row
+ updateOrCreateColumnContent(torrentRow, 'Name', largestFileName);
+ updateOrCreateColumnContent(torrentRow, 'Size', `${formatBytesToGB(totalBytes)} GB`);
+ updateOrCreateColumnWithProgressBar(torrentRow, 'Progress', downloadPercentage);
+ updateOrCreateColumnContent(torrentRow, 'Download Speed', downloadSpeed);
+ updateOrCreateColumnContent(torrentRow, 'ETA', eta);
+ updateOrCreateColumnContent(torrentRow, 'Peers', peers);
+}
+// Function to update or create the content of a column in a torrent row
+function updateOrCreateColumnContent(torrentRow, human_label, value) {
+ let label = human_label.toLowerCase().replace(" ", "-");
+ let column = torrentRow.querySelector(`.column-${label}`);
+ // If the column doesn't exist, create a new one
+ if (!column) {
+ column = document.createElement('div');
+ column.classList.add(`column-${label}`, 'me-3', 'p-2');
+ torrentRow.appendChild(column);
+ }
+ // Update the content of the existing or newly created column
+ const contentParagraph = column.querySelector('p:last-child');
+ if (contentParagraph) {
+ contentParagraph.textContent = value;
+ }
+ else {
+ column.innerHTML = `${human_label}
${value}
`;
+ }
+}
+// Function to update or create the content of a progress bar column in a torrent row
+function updateOrCreateColumnWithProgressBar(torrentRow, label, percentage) {
+ let column = torrentRow.querySelector('.column-progress');
+ // If the column doesn't exist, create a new one
+ if (!column) {
+ column = document.createElement('div');
+ column.classList.add('column-progress', 'me-3', 'p-2');
+ torrentRow.appendChild(column);
+ }
+ // Update the value of the progress bar in the existing or newly created column
+ const progressBar = column.querySelector('.progress-bar');
+ const progressPercentage = column.querySelector('p:last-child');
+ if (progressBar && progressPercentage) {
+ progressBar.style.width = `${percentage}%`;
+ progressPercentage.textContent = `${percentage.toFixed(2)}%`;
+ }
+ else {
+ column.innerHTML = `
+ ${label}
+
+ ${percentage.toFixed(2)}%
`;
+ }
+}
+// Function to render HTML for a torrent row
+function renderTorrentRow(torrentsContainer, torrentId, detailsResponse, statsResponse) {
+ // Check if the torrent row already exists
+ let torrentRow = document.getElementById(`torrent-${torrentId}`);
+ // If the torrent row doesn't exist, create a new one
+ if (!torrentRow) {
+ torrentRow = document.createElement('div');
+ torrentRow.id = `torrent-${torrentId}`;
+ torrentRow.classList.add('torrent-row', 'd-flex', 'flex-row', 'p-3', 'bg-light', 'rounded', 'mb-3');
+ torrentsContainer.appendChild(torrentRow);
+ }
+ // Update columns in the torrent row
+ updateTorrentRow(torrentRow, detailsResponse, statsResponse);
+ return torrentRow;
+}
+// Function to create a column div
+function createColumn(label, value, columnClass) {
+ const columnDiv = document.createElement('div');
+ columnDiv.classList.add(columnClass, 'me-3', 'p-2');
+ columnDiv.innerHTML = `${label}
${value}
`;
+ return columnDiv;
+}
+// Function to create a column div with a progress bar
+function createColumnWithProgressBar(label, percentage) {
+ const columnDiv = document.createElement('div');
+ columnDiv.classList.add('column', 'me-3', 'p-2');
+ columnDiv.innerHTML = `
+ ${label}
+
+ ${percentage.toFixed(2)}%
`;
+ return columnDiv;
+}
+// Function to format bytes to GB
+function formatBytesToGB(bytes) {
+ const GB = bytes / (1024 * 1024 * 1024);
+ return GB.toFixed(2);
+}
+// Function to get the name of the largest file in a torrent
+function getLargestFileName(torrentDetails) {
+ const largestFile = torrentDetails.files.reduce((prev, current) => (prev.length > current.length) ? prev : current);
+ return largestFile.name;
+}
+// Function to get the completion ETA of a torrent
+function getCompletionETA(stats) {
+ if (stats.time_remaining) {
+ return stats.time_remaining.human_readable;
+ }
+ else {
+ return 'N/A';
+ }
+}
+// Helper function to display API errors in an alert
+function displayApiError(error) {
+ const errorAlert = document.getElementById('error-alert');
+ if (errorAlert) {
+ errorAlert.innerHTML = `
+
+ Error ${error.status}: ${error.statusText}
+ ${error.body}
+
+
+ `;
+ }
+}
+// Helper function to clear the error alert
+function clearErrorAlert() {
+ const errorAlert = document.getElementById('error-alert');
+ if (errorAlert) {
+ errorAlert.innerHTML = ''; // Clear the content
+ }
+}
+// List all torrents on page load and set up auto-refresh
+async function init() {
+ try {
+ await displayTorrents();
+ autoRefreshTorrents(500); // Set the interval (in milliseconds), e.g., 5000 for every 5 seconds
+ }
+ catch (error) {
+ console.error(error);
+ }
+}
+// Function to refresh torrents at a specified interval
+function autoRefreshTorrents(interval) {
+ setInterval(async () => {
+ await displayTorrents();
+ }, interval);
+}
+// Function to add a torrent from a magnet link
+async function addTorrentFromMagnet() {
+ const magnetLink = prompt('Enter magnet link:');
+ if (magnetLink) {
+ await makeRequest('POST', '/torrents?overwrite=true', magnetLink);
+ await displayTorrents(); // Refresh the torrent list after adding a new torrent
+ }
+}
+// Function to handle file input change
+async function handleFileInputChange() {
+ const fileInput = document.getElementById('file-input');
+ const file = fileInput.files?.[0];
+ if (file) {
+ await makeRequest('POST', '/torrents?overwrite=true', file);
+ await displayTorrents(); // Refresh the torrent list after adding a new torrent
+ }
+}
+// Add event listeners for buttons
+document.getElementById('add-magnet-button')?.addEventListener('click', addTorrentFromMagnet);
+// Update the event listener for the file input button
+const fileInputButton = document.getElementById('upload-file-button');
+fileInputButton?.addEventListener('click', () => {
+ const fileInput = document.getElementById('file-input');
+ fileInput.click();
+});
+document.getElementById('file-input')?.addEventListener('change', handleFileInputChange);
+// Call init function on page load
+document.addEventListener('DOMContentLoaded', init);
diff --git a/crates/librqbit/webui/dist/webui/vite.config.js b/crates/librqbit/webui/dist/webui/vite.config.js
new file mode 100644
index 0000000..7b1c679
--- /dev/null
+++ b/crates/librqbit/webui/dist/webui/vite.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite';
+import preact from '@preact/preset-vite';
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [preact()],
+ server: {
+ port: 3031
+ }
+});
diff --git a/crates/librqbit/webui/webui/.gitignore b/crates/librqbit/webui/webui/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/crates/librqbit/webui/webui/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/crates/librqbit/webui/webui/index.html b/crates/librqbit/webui/webui/index.html
new file mode 100644
index 0000000..c116f87
--- /dev/null
+++ b/crates/librqbit/webui/webui/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ rqbit web 0.0.1-alpha
+
+
+
+
+
+
+
rqbit web 0.0.1-alpha
+
+
+
+
+ Add Torrent from Magnet Link
+
+ Upload .torrent File
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/crates/librqbit/webui/webui/package-lock.json b/crates/librqbit/webui/webui/package-lock.json
new file mode 100644
index 0000000..c2075f3
--- /dev/null
+++ b/crates/librqbit/webui/webui/package-lock.json
@@ -0,0 +1,1449 @@
+{
+ "name": "webui",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "preact": "^10.13.1"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.5.0",
+ "typescript": "^5.3.2",
+ "vite": "^4.3.2"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
+ "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
+ "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/highlight": "^7.23.4",
+ "chalk": "^2.4.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz",
+ "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz",
+ "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==",
+ "dev": true,
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.22.13",
+ "@babel/generator": "^7.23.3",
+ "@babel/helper-compilation-targets": "^7.22.15",
+ "@babel/helper-module-transforms": "^7.23.3",
+ "@babel/helpers": "^7.23.2",
+ "@babel/parser": "^7.23.3",
+ "@babel/template": "^7.22.15",
+ "@babel/traverse": "^7.23.3",
+ "@babel/types": "^7.23.3",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz",
+ "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.23.4",
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "@jridgewell/trace-mapping": "^0.3.17",
+ "jsesc": "^2.5.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz",
+ "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.22.15",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz",
+ "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.22.9",
+ "@babel/helper-validator-option": "^7.22.15",
+ "browserslist": "^4.21.9",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-environment-visitor": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
+ "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-function-name": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
+ "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.22.15",
+ "@babel/types": "^7.23.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-hoist-variables": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
+ "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.22.15",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz",
+ "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.15"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz",
+ "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-module-imports": "^7.22.15",
+ "@babel/helper-simple-access": "^7.22.5",
+ "@babel/helper-split-export-declaration": "^7.22.6",
+ "@babel/helper-validator-identifier": "^7.22.20"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz",
+ "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-simple-access": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz",
+ "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-split-export-declaration": {
+ "version": "7.22.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz",
+ "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
+ "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.22.15",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz",
+ "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.4.tgz",
+ "integrity": "sha512-HfcMizYz10cr3h29VqyfGL6ZWIjTwWfvYBMsBVGwpcbhNGe3wQ1ZXZRPzZoAHhd9OqHadHqjQ89iVKINXnbzuw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.22.15",
+ "@babel/traverse": "^7.23.4",
+ "@babel/types": "^7.23.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/highlight": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
+ "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "chalk": "^2.4.2",
+ "js-tokens": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz",
+ "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==",
+ "dev": true,
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz",
+ "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz",
+ "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.22.5",
+ "@babel/helper-module-imports": "^7.22.15",
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/plugin-syntax-jsx": "^7.23.3",
+ "@babel/types": "^7.23.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-development": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz",
+ "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.22.15",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
+ "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.22.13",
+ "@babel/parser": "^7.22.15",
+ "@babel/types": "^7.22.15"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz",
+ "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.23.4",
+ "@babel/generator": "^7.23.4",
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-function-name": "^7.23.0",
+ "@babel/helper-hoist-variables": "^7.22.5",
+ "@babel/helper-split-export-declaration": "^7.22.6",
+ "@babel/parser": "^7.23.4",
+ "@babel/types": "^7.23.4",
+ "debug": "^4.1.0",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz",
+ "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.23.4",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
+ "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
+ "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
+ "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
+ "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
+ "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
+ "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
+ "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
+ "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
+ "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
+ "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
+ "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
+ "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
+ "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
+ "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
+ "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
+ "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
+ "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
+ "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
+ "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
+ "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
+ "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
+ "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
+ "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
+ "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.4.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.20",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
+ "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@preact/preset-vite": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.7.0.tgz",
+ "integrity": "sha512-m5N0FVtxbCCDxNk55NGhsRpKJChYcupcuQHzMJc/Bll07IKZKn8amwYciyKFS9haU6AgzDAJ/ewvApr6Qg1DHw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.22.15",
+ "@babel/plugin-transform-react-jsx-development": "^7.22.5",
+ "@prefresh/vite": "^2.4.1",
+ "@rollup/pluginutils": "^4.1.1",
+ "babel-plugin-transform-hook-names": "^1.0.2",
+ "debug": "^4.3.4",
+ "kolorist": "^1.8.0",
+ "resolve": "^1.22.8"
+ },
+ "peerDependencies": {
+ "@babel/core": "7.x",
+ "vite": "2.x || 3.x || 4.x || 5.x"
+ }
+ },
+ "node_modules/@prefresh/babel-plugin": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.1.tgz",
+ "integrity": "sha512-uG3jGEAysxWoyG3XkYfjYHgaySFrSsaEb4GagLzYaxlydbuREtaX+FTxuIidp241RaLl85XoHg9Ej6E4+V1pcg==",
+ "dev": true
+ },
+ "node_modules/@prefresh/core": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.2.tgz",
+ "integrity": "sha512-A/08vkaM1FogrCII5PZKCrygxSsc11obExBScm3JF1CryK2uDS3ZXeni7FeKCx1nYdUkj4UcJxzPzc1WliMzZA==",
+ "dev": true,
+ "peerDependencies": {
+ "preact": "^10.0.0"
+ }
+ },
+ "node_modules/@prefresh/utils": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.0.tgz",
+ "integrity": "sha512-KtC/fZw+oqtwOLUFM9UtiitB0JsVX0zLKNyRTA332sqREqSALIIQQxdUCS1P3xR/jT1e2e8/5rwH6gdcMLEmsQ==",
+ "dev": true
+ },
+ "node_modules/@prefresh/vite": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.4.tgz",
+ "integrity": "sha512-7jcz3j5pXufOWTjl31n0Lc3BcU8oGoacoaWx/Ur1QJ+fd4Xu0G7g/ER1xV02x7DCiVoFi7xtSgaophOXoJvpmA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.22.1",
+ "@prefresh/babel-plugin": "0.5.1",
+ "@prefresh/core": "^1.5.1",
+ "@prefresh/utils": "^1.2.0",
+ "@rollup/pluginutils": "^4.2.1"
+ },
+ "peerDependencies": {
+ "preact": "^10.4.0",
+ "vite": ">=2.0.0"
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
+ "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
+ "dev": true,
+ "dependencies": {
+ "estree-walker": "^2.0.1",
+ "picomatch": "^2.2.2"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/babel-plugin-transform-hook-names": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
+ "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==",
+ "dev": true,
+ "peerDependencies": {
+ "@babel/core": "^7.12.10"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz",
+ "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001541",
+ "electron-to-chromium": "^1.4.535",
+ "node-releases": "^2.0.13",
+ "update-browserslist-db": "^1.0.13"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001563",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001563.tgz",
+ "integrity": "sha512-na2WUmOxnwIZtwnFI2CZ/3er0wdNzU7hN+cPYz/z2ajHThnkWjNBOpEPP4n+4r2WPM847JaMotaJE3bnfzjyKw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ]
+ },
+ "node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.4.589",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.589.tgz",
+ "integrity": "sha512-zF6y5v/YfoFIgwf2dDfAqVlPPsyQeWNpEWXbAlDUS8Ax4Z2VoiiZpAPC0Jm9hXEkJm2vIZpwB6rc4KnLTQffbQ==",
+ "dev": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
+ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/android-arm": "0.18.20",
+ "@esbuild/android-arm64": "0.18.20",
+ "@esbuild/android-x64": "0.18.20",
+ "@esbuild/darwin-arm64": "0.18.20",
+ "@esbuild/darwin-x64": "0.18.20",
+ "@esbuild/freebsd-arm64": "0.18.20",
+ "@esbuild/freebsd-x64": "0.18.20",
+ "@esbuild/linux-arm": "0.18.20",
+ "@esbuild/linux-arm64": "0.18.20",
+ "@esbuild/linux-ia32": "0.18.20",
+ "@esbuild/linux-loong64": "0.18.20",
+ "@esbuild/linux-mips64el": "0.18.20",
+ "@esbuild/linux-ppc64": "0.18.20",
+ "@esbuild/linux-riscv64": "0.18.20",
+ "@esbuild/linux-s390x": "0.18.20",
+ "@esbuild/linux-x64": "0.18.20",
+ "@esbuild/netbsd-x64": "0.18.20",
+ "@esbuild/openbsd-x64": "0.18.20",
+ "@esbuild/sunos-x64": "0.18.20",
+ "@esbuild/win32-arm64": "0.18.20",
+ "@esbuild/win32-ia32": "0.18.20",
+ "@esbuild/win32-x64": "0.18.20"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
+ "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
+ "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+ "dev": true,
+ "dependencies": {
+ "hasown": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "node_modules/jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/kolorist": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
+ "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
+ "dev": true
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.13",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
+ "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
+ "dev": true
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.19.2",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.2.tgz",
+ "integrity": "sha512-UA9DX/OJwv6YwP9Vn7Ti/vF80XL+YA5H2l7BpCtUr3ya8LWHFzpiO5R+N7dN16ujpIxhekRFuOOF82bXX7K/lg==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.8",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
+ "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+ "dev": true,
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "3.29.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz",
+ "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==",
+ "dev": true,
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=14.18.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
+ "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
+ "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.1.1",
+ "picocolors": "^1.0.0"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
+ "integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.18.10",
+ "postcss": "^8.4.27",
+ "rollup": "^3.27.1"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ },
+ "peerDependencies": {
+ "@types/node": ">= 14",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+}
diff --git a/crates/librqbit/webui/webui/package.json b/crates/librqbit/webui/webui/package.json
new file mode 100644
index 0000000..6b88995
--- /dev/null
+++ b/crates/librqbit/webui/webui/package.json
@@ -0,0 +1,17 @@
+{
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "preact": "^10.13.1"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.5.0",
+ "typescript": "^5.3.2",
+ "vite": "^4.3.2"
+ }
+}
diff --git a/crates/librqbit/webui/webui/public/vite.svg b/crates/librqbit/webui/webui/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/crates/librqbit/webui/webui/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/crates/librqbit/webui/webui/src/assets/preact.svg b/crates/librqbit/webui/webui/src/assets/preact.svg
new file mode 100644
index 0000000..908f17d
--- /dev/null
+++ b/crates/librqbit/webui/webui/src/assets/preact.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/crates/librqbit/webui/webui/src/index.tsx b/crates/librqbit/webui/webui/src/index.tsx
new file mode 100644
index 0000000..373d6eb
--- /dev/null
+++ b/crates/librqbit/webui/webui/src/index.tsx
@@ -0,0 +1,452 @@
+import { Fragment, h, render, Component } from 'preact';
+import { useEffect, useState } from 'preact/hooks';
+
+// Define API URL and base path
+const apiUrl = (window.origin === 'null' || window.origin === 'http://localhost:3031') ? 'http://localhost:3030' : '';
+
+// Interface for the Torrent API response
+interface Torrent {
+ id: number;
+ info_hash: string;
+}
+
+// Interface for the Torrent Details API response
+interface TorrentDetails {
+ files: {
+ name: string;
+ length: number;
+ included: boolean;
+ }[];
+}
+
+// Interface for the Torrent Stats API response
+interface TorrentStats {
+ snapshot: {
+ have_bytes: number;
+ downloaded_and_checked_bytes: number;
+ downloaded_and_checked_pieces: number;
+ fetched_bytes: number;
+ uploaded_bytes: number;
+ initially_needed_bytes: number;
+ remaining_bytes: number;
+ total_bytes: number;
+ total_piece_download_ms: number;
+ peer_stats: {
+ queued: number;
+ connecting: number;
+ live: number;
+ seen: number;
+ dead: number;
+ not_needed: number;
+ };
+ };
+ average_piece_download_time: {
+ secs: number;
+ nanos: number;
+ };
+ download_speed: {
+ mbps: number;
+ human_readable: string;
+ };
+ all_time_download_speed: {
+ mbps: number;
+ human_readable: string;
+ };
+ time_remaining: {
+ human_readable: string;
+ } | null;
+}
+
+
+// Interface for the API error response
+interface ApiError {
+ status: number;
+ statusText: string;
+ body: string;
+}
+
+// Helper function for making API requests (async/await)
+async function makeRequest(method: string, path: string, data?: any): Promise {
+ const url = apiUrl + path;
+ const options: RequestInit = {
+ method,
+ headers: {
+ 'Accept': 'application/json',
+ },
+ body: data,
+ };
+
+ try {
+ const response = await fetch(url, options);
+ if (!response.ok) {
+ const errorBody = await response.text();
+ try {
+ const json = JSON.parse(errorBody);
+ displayApiError({
+ status: response.status,
+ statusText: response.statusText,
+ body: json.human_readable !== undefined ? json.human_readable : errorBody,
+ });
+ } catch (e) {
+ displayApiError({
+ status: response.status,
+ statusText: response.statusText,
+ body: errorBody,
+ });
+ }
+ return Promise.reject(errorBody);
+ }
+ const result = await response.json();
+ return result;
+ } catch (error) {
+ console.error(error);
+ displayApiError({
+ status: error.status,
+ statusText: error.statusText,
+ body: error.toString(),
+ });
+ return Promise.reject(`Error: ${error.message}`);
+ }
+}
+
+// Function to get detailed information about a torrent (async/await)
+async function getTorrentDetails(index: number): Promise {
+ return makeRequest('GET', `/torrents/${index}`);
+}
+
+// Function to get detailed statistics about a torrent (async/await)
+async function getTorrentStats(index: number): Promise {
+ return makeRequest('GET', `/torrents/${index}/stats`);
+}
+
+function TorrentRow(props) {
+ const { detailsResponse, statsResponse } = this.props;
+ const totalBytes = statsResponse.snapshot.total_bytes;
+ const downloadedBytes = statsResponse.snapshot.have_bytes;
+ const downloadPercentage = (downloadedBytes / totalBytes) * 100;
+
+ return (
+
+ {/* Create and render columns */}
+
+
+
+
+
+
+
+ );
+}
+
+// Define a Preact component for a column
+const Column = ({ label, value }) => (
+
+);
+
+// Define a Preact component for a column with a progress bar
+const ColumnWithProgressBar = ({ label, percentage }) => (
+
+
{label}
+
+
{percentage.toFixed(2)}%
+
+);
+
+const DeferredTorrent = ({ torrent }) => {
+ const defaultDetails: TorrentDetails = {
+ files: []
+ };
+ const defaultStats: TorrentStats = {
+ snapshot: {
+ have_bytes: 0,
+ downloaded_and_checked_bytes: 0,
+ downloaded_and_checked_pieces: 0,
+ fetched_bytes: 0,
+ uploaded_bytes: 0,
+ initially_needed_bytes: 0,
+ remaining_bytes: 0,
+ total_bytes: 0,
+ total_piece_download_ms: 0,
+ peer_stats: {
+ queued: 0,
+ connecting: 0,
+ live: 0,
+ seen: 0,
+ dead: 0,
+ not_needed: 0
+ }
+ },
+ average_piece_download_time: {
+ secs: 0,
+ nanos: 0
+ },
+ download_speed: {
+ mbps: 0,
+ human_readable: ''
+ },
+ all_time_download_speed: {
+ mbps: 0,
+ human_readable: ''
+ },
+ time_remaining: {
+ human_readable: ''
+ }
+ };
+
+ const [detailsResponse, updateDetailsResponse] = useState(defaultDetails);
+ const [statsResponse, updateStatsResponse] = useState(defaultStats);
+
+ const update = async () => {
+ let a = getTorrentDetails(torrent.id).then((details) => {
+ updateDetailsResponse(details);
+ });
+ let b = getTorrentStats(torrent.id).then((stats) => {
+ updateStatsResponse(stats);
+ });
+ await Promise.all([a, b]);
+ // setTimeout(update, 5000);
+ };
+
+ useEffect(() => {
+ let timer = setTimeout(update, 0);
+ return () => clearTimeout(timer);
+ });
+
+ return
+}
+
+const Root = () => {
+ const [torrents, updateTorrents] = useState([]);
+ const update = async () => {
+ let response = await makeRequest('GET', '/torrents');
+ updateTorrents(response.torrents);
+ // setTimeout(update, 500);
+ };
+ useEffect(() => {
+ let timer = setTimeout(update, 0);
+ return () => clearTimeout(timer);
+ });
+
+ return (
+ <>
+ {torrents.map((t: Torrent) => {
+
+
+
+ })}
+ >
+ )
+};
+
+// Render function to display all torrents
+async function displayTorrents() {
+ // Get the torrents container
+ const torrentsContainer = document.getElementById('output');
+ render( , torrentsContainer);
+}
+
+// Function to update HTML for a torrent row
+function updateTorrentRow(torrentRow: HTMLDivElement, detailsResponse: TorrentDetails, statsResponse: TorrentStats) {
+ // Calculate download percentage
+ const totalBytes = statsResponse.snapshot.total_bytes;
+ const downloadedBytes = statsResponse.snapshot.have_bytes;
+ const downloadPercentage = (downloadedBytes / totalBytes) * 100;
+
+ // Display basic information about the torrent
+ const largestFileName = getLargestFileName(detailsResponse);
+ const downloadSpeed = statsResponse.download_speed.human_readable;
+ const eta = getCompletionETA(statsResponse);
+ const peers = `${statsResponse.snapshot.peer_stats.live} / ${statsResponse.snapshot.peer_stats.seen}`;
+
+ // Update or create columns in the torrent row
+ updateOrCreateColumnContent(torrentRow, 'Name', largestFileName);
+ updateOrCreateColumnContent(torrentRow, 'Size', `${formatBytesToGB(totalBytes)} GB`);
+ updateOrCreateColumnWithProgressBar(torrentRow, 'Progress', downloadPercentage);
+ updateOrCreateColumnContent(torrentRow, 'Download Speed', downloadSpeed);
+ updateOrCreateColumnContent(torrentRow, 'ETA', eta);
+ updateOrCreateColumnContent(torrentRow, 'Peers', peers);
+}
+
+// Function to update or create the content of a column in a torrent row
+function updateOrCreateColumnContent(torrentRow: HTMLDivElement, human_label: string, value: string) {
+ let label = human_label.toLowerCase().replace(" ", "-");
+ let column = torrentRow.querySelector(`.column-${label}`);
+
+ // If the column doesn't exist, create a new one
+ if (!column) {
+ column = document.createElement('div');
+ column.classList.add(`column-${label}`, 'me-3', 'p-2');
+ torrentRow.appendChild(column);
+ }
+
+ // Update the content of the existing or newly created column
+ const contentParagraph = column.querySelector('p:last-child');
+ if (contentParagraph) {
+ contentParagraph.textContent = value;
+ } else {
+ column.innerHTML = `${human_label}
${value}
`;
+ }
+}
+
+
+// Function to update or create the content of a progress bar column in a torrent row
+function updateOrCreateColumnWithProgressBar(torrentRow: HTMLDivElement, label: string, percentage: number) {
+ let column = torrentRow.querySelector('.column-progress');
+
+ // If the column doesn't exist, create a new one
+ if (!column) {
+ column = document.createElement('div');
+ column.classList.add('column-progress', 'me-3', 'p-2');
+ torrentRow.appendChild(column);
+ }
+
+ // Update the value of the progress bar in the existing or newly created column
+ const progressBar = column.querySelector('.progress-bar') as HTMLElement;
+ const progressPercentage = column.querySelector('p:last-child') as HTMLElement;
+
+ if (progressBar && progressPercentage) {
+ progressBar.style.width = `${percentage}%`;
+ progressPercentage.textContent = `${percentage.toFixed(2)}%`;
+ } else {
+ column.innerHTML = `
+ ${label}
+
+ ${percentage.toFixed(2)}%
`;
+ }
+}
+
+
+// Function to render HTML for a torrent row
+function renderTorrentRow(torrentsContainer: HTMLElement, torrentId: number, detailsResponse: TorrentDetails, statsResponse: TorrentStats) {
+ // Check if the torrent row already exists
+ let torrentRow = document.getElementById(`torrent-${torrentId}`) as HTMLDivElement;
+
+ // If the torrent row doesn't exist, create a new one
+ if (!torrentRow) {
+ torrentRow = document.createElement('div');
+ torrentRow.id = `torrent-${torrentId}`;
+ torrentRow.classList.add('torrent-row', 'd-flex', 'flex-row', 'p-3', 'bg-light', 'rounded', 'mb-3');
+ torrentsContainer.appendChild(torrentRow);
+ }
+
+ // Update columns in the torrent row
+ updateTorrentRow(torrentRow, detailsResponse, statsResponse);
+
+ return torrentRow;
+}
+
+
+// Function to create a column div
+function createColumn(label: string, value: string, columnClass: string): HTMLDivElement {
+ const columnDiv = document.createElement('div');
+ columnDiv.classList.add(columnClass, 'me-3', 'p-2');
+ columnDiv.innerHTML = `${label}
${value}
`;
+ return columnDiv;
+}
+
+
+// Function to create a column div with a progress bar
+function createColumnWithProgressBar(label: string, percentage: number): HTMLDivElement {
+ const columnDiv = document.createElement('div');
+ columnDiv.classList.add('column', 'me-3', 'p-2');
+ columnDiv.innerHTML = `
+ ${label}
+
+ ${percentage.toFixed(2)}%
`;
+ return columnDiv;
+}
+
+// Function to format bytes to GB
+function formatBytesToGB(bytes: number): string {
+ const GB = bytes / (1024 * 1024 * 1024);
+ return GB.toFixed(2);
+}
+
+// Function to get the name of the largest file in a torrent
+function getLargestFileName(torrentDetails: TorrentDetails): string {
+ if (torrentDetails.files.length == 0) {
+ return 'Loading...';
+ }
+ const largestFile = torrentDetails.files.reduce((prev: any, current: any) => (prev.length > current.length) ? prev : current);
+ return largestFile.name;
+}
+
+// Function to get the completion ETA of a torrent
+function getCompletionETA(stats: TorrentStats): string {
+ if (stats.time_remaining) {
+ return stats.time_remaining.human_readable;
+ } else {
+ return 'N/A';
+ }
+}
+
+// Helper function to display API errors in an alert
+function displayApiError(error: ApiError): void {
+ const errorAlert = document.getElementById('error-alert');
+ if (errorAlert) {
+ errorAlert.innerHTML = `
+
+ Error ${error.status}: ${error.statusText}
+ ${error.body}
+
+
+ `;
+ }
+}
+
+// Helper function to clear the error alert
+function clearErrorAlert(): void {
+ const errorAlert = document.getElementById('error-alert');
+ if (errorAlert) {
+ errorAlert.innerHTML = ''; // Clear the content
+ }
+}
+
+// List all torrents on page load and set up auto-refresh
+async function init(): Promise {
+ console.log('init');
+ await displayTorrents();
+}
+
+// Function to add a torrent from a magnet link
+async function addTorrentFromMagnet(): Promise {
+ const magnetLink = prompt('Enter magnet link:');
+ if (magnetLink) {
+ await makeRequest('POST', '/torrents?overwrite=true', magnetLink);
+ // await displayTorrents(); // Refresh the torrent list after adding a new torrent
+ }
+}
+
+// Function to handle file input change
+async function handleFileInputChange(): Promise {
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const file = fileInput.files?.[0];
+ if (file) {
+ await makeRequest('POST', '/torrents?overwrite=true', file);
+ // await displayTorrents(); // Refresh the torrent list after adding a new torrent
+ }
+}
+
+// Add event listeners for buttons
+document.getElementById('add-magnet-button')?.addEventListener('click', addTorrentFromMagnet);
+
+// Update the event listener for the file input button
+const fileInputButton = document.getElementById('upload-file-button');
+fileInputButton?.addEventListener('click', () => {
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ fileInput.click();
+});
+
+document.getElementById('file-input')?.addEventListener('change', handleFileInputChange);
+
+// Call init function on page load
+document.addEventListener('DOMContentLoaded', init);
\ No newline at end of file
diff --git a/crates/librqbit/webui/webui/src/style.css b/crates/librqbit/webui/webui/src/style.css
new file mode 100644
index 0000000..cb14c0c
--- /dev/null
+++ b/crates/librqbit/webui/webui/src/style.css
@@ -0,0 +1,82 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color: #222;
+ background-color: #ffffff;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ min-height: 100vh;
+}
+
+#app {
+ max-width: 1280px;
+ margin: 0 auto;
+ text-align: center;
+}
+
+img {
+ margin-bottom: 1.5rem;
+}
+
+img:hover {
+ filter: drop-shadow(0 0 2em #673ab8aa);
+}
+
+section {
+ margin-top: 5rem;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ column-gap: 1.5rem;
+}
+
+.resource {
+ padding: 0.75rem 1.5rem;
+ border-radius: 0.5rem;
+ text-align: left;
+ text-decoration: none;
+ color: #222;
+ background-color: #f1f1f1;
+ border: 1px solid transparent;
+}
+
+.resource:hover {
+ border: 1px solid #000;
+ box-shadow: 0 25px 50px -12px #673ab888;
+}
+
+@media (max-width: 639px) {
+ #app {
+ margin: 2rem;
+ }
+ section {
+ margin-top: 5rem;
+ grid-template-columns: 1fr;
+ row-gap: 1rem;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ color: #ccc;
+ background-color: #1a1a1a;
+ }
+ .resource {
+ color: #ccc;
+ background-color: #161616;
+ }
+ .resource:hover {
+ border: 1px solid #bbb;
+ }
+}
diff --git a/crates/librqbit/webui/webui/tsconfig.json b/crates/librqbit/webui/webui/tsconfig.json
new file mode 100644
index 0000000..12bb30b
--- /dev/null
+++ b/crates/librqbit/webui/webui/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "allowJs": true,
+ "checkJs": true,
+
+ /* Preact Config */
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "skipLibCheck": true,
+ "paths": {
+ "react": ["./node_modules/preact/compat/"],
+ "react-dom": ["./node_modules/preact/compat/"]
+ }
+ },
+ "include": ["node_modules/vite/client.d.ts", "**/*"]
+}
diff --git a/crates/librqbit/webui/webui/vite.config.ts b/crates/librqbit/webui/webui/vite.config.ts
new file mode 100644
index 0000000..b741e57
--- /dev/null
+++ b/crates/librqbit/webui/webui/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+import preact from '@preact/preset-vite';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [preact()],
+ server: {
+ port: 3031
+ }
+});