This commit is contained in:
Kieran 2023-11-27 13:19:14 +00:00
commit d2b8484697
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
49 changed files with 6715 additions and 0 deletions

11
.eslintrc.cjs Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
};

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# 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?
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["arcanis.vscode-zipfs", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

893
.yarn/releases/yarn-4.0.2.cjs vendored Executable file

File diff suppressed because one or more lines are too long

20
.yarn/sdks/eslint/bin/eslint.js vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = absRequire(`eslint/bin/eslint.js`);

20
.yarn/sdks/eslint/lib/api.js vendored Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint your application uses
module.exports = absRequire(`eslint`);

View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/use-at-your-own-risk
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/use-at-your-own-risk your application uses
module.exports = absRequire(`eslint/use-at-your-own-risk`);

14
.yarn/sdks/eslint/package.json vendored Normal file
View File

@ -0,0 +1,14 @@
{
"name": "eslint",
"version": "8.54.0-sdk",
"main": "./lib/api.js",
"type": "commonjs",
"bin": {
"eslint": "./bin/eslint.js"
},
"exports": {
"./package.json": "./package.json",
".": "./lib/api.js",
"./use-at-your-own-risk": "./lib/unsupported-api.js"
}
}

5
.yarn/sdks/integrations.yml vendored Normal file
View File

@ -0,0 +1,5 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

20
.yarn/sdks/prettier/bin/prettier.cjs vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/bin/prettier.cjs
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier/bin/prettier.cjs your application uses
module.exports = absRequire(`prettier/bin/prettier.cjs`);

20
.yarn/sdks/prettier/index.cjs vendored Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier your application uses
module.exports = absRequire(`prettier`);

7
.yarn/sdks/prettier/package.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"name": "prettier",
"version": "3.1.0-sdk",
"main": "./index.cjs",
"type": "commonjs",
"bin": "./bin/prettier.cjs"
}

20
.yarn/sdks/typescript/bin/tsc vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

20
.yarn/sdks/typescript/bin/tsserver vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

20
.yarn/sdks/typescript/lib/tsc.js vendored Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

252
.yarn/sdks/typescript/lib/tsserver.js vendored Normal file
View File

@ -0,0 +1,252 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = (tsserver) => {
if (!process.versions.pnp) {
return tsserver;
}
const { isAbsolute } = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = (str) => str.startsWith("portal:/");
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(
pnpApi.getDependencyTreeRoots().map((locator) => {
return `${locator.name}@${locator.reference}`;
}),
);
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (
locator &&
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`:
{
str = `^zip:${str}`;
}
break;
case `vscode <1.66`:
{
str = `^/zip/${str}`;
}
break;
case `vscode <1.68`:
{
str = `^/zip${str}`;
}
break;
case `vscode`:
{
str = `^/zip/${str}`;
}
break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
}
break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
}
break;
default:
{
str = `zip:${str}`;
}
break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
}
break;
case `neovim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
}
break;
case `vscode`:
default:
{
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
}
break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === "string";
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (
process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
) ?? []
).map(Number);
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === "string" ? fromEditorPath(value) : value;
});
return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
},
send(/** @type {any} */ msg) {
return originalSend.call(
this,
JSON.parse(
JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
}),
),
);
},
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@ -0,0 +1,252 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = (tsserver) => {
if (!process.versions.pnp) {
return tsserver;
}
const { isAbsolute } = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = (str) => str.startsWith("portal:/");
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(
pnpApi.getDependencyTreeRoots().map((locator) => {
return `${locator.name}@${locator.reference}`;
}),
);
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (
locator &&
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`:
{
str = `^zip:${str}`;
}
break;
case `vscode <1.66`:
{
str = `^/zip/${str}`;
}
break;
case `vscode <1.68`:
{
str = `^/zip${str}`;
}
break;
case `vscode`:
{
str = `^/zip/${str}`;
}
break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
}
break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
}
break;
default:
{
str = `zip:${str}`;
}
break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
}
break;
case `neovim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
}
break;
case `vscode`:
default:
{
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
}
break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === "string";
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (
process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
) ?? []
).map(Number);
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === "string" ? fromEditorPath(value) : value;
});
return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
},
send(/** @type {any} */ msg) {
return originalSend.call(
this,
JSON.parse(
JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
}),
),
);
},
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

20
.yarn/sdks/typescript/lib/typescript.js vendored Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript your application uses
module.exports = absRequire(`typescript`);

10
.yarn/sdks/typescript/package.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"name": "typescript",
"version": "5.3.2-sdk",
"main": "./lib/typescript.js",
"type": "commonjs",
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
}
}

2
.yarnrc.yml Normal file
View File

@ -0,0 +1,2 @@
nodeLinker: pnp
yarnPath: .yarn/releases/yarn-4.0.2.cjs

22
README.md Normal file
View File

@ -0,0 +1,22 @@
```json
{
"kind": 2003,
"content": "<long-description-pre-formatted>",
"tags": [
["title", "<torrent-title>"],
["size", "<size-in-bytes>"],
["btih", "<bittorrent-info-hash>"],
["t", "<top-level-tag>"],
["t", "(optional)<second-level-tag>"],
["t", ...other tags],
["file", "<file-name>", "<file-size-in-bytes>"],
["file", "<file-name>", "<file-size-in-bytes>"],
["file", ...other files],
["imdb", "(optional)<imdb-entry-id>"]
]
}
```
Top Level tags are usually the type of content like `"Video"` / `"Audio"` / `"Application"` and so on.
Second level tags are sub categories like `"Movies"` / `"CAD/CAM"`

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dtan.xyz</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "dtan",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@noble/hashes": "^1.3.2",
"@snort/shared": "^1.0.10",
"@snort/system": "^1.1.5",
"@snort/system-react": "^1.1.5",
"@snort/system-web": "^1.0.3",
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"prettier": "^3.1.0",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
},
"packageManager": "yarn@4.0.2",
"prettier": {
"printWidth": 120
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

158
src/bencode/decode.ts Normal file
View File

@ -0,0 +1,158 @@
import { bytesToHex } from "@noble/hashes/utils";
const INTEGER_START = 0x69; // 'i'
const STRING_DELIM = 0x3a; // ':'
const DICTIONARY_START = 0x64; // 'd'
const LIST_START = 0x6c; // 'l'
const END_OF_TYPE = 0x65; // 'e'
export type BencodeValue = number | Uint8Array | BencodeValue[] | { [key: string]: BencodeValue };
/**
* replaces parseInt(buffer.toString('ascii', start, end)).
* For strings with less then ~30 charachters, this is actually a lot faster.
*
* @param {Uint8Array} buffer
* @param {Number} start
* @param {Number} end
* @return {Number} calculated number
*/
function getIntFromBuffer(buffer: Uint8Array, start: number, end: number) {
let sum = 0;
let sign = 1;
for (let i = start; i < end; i++) {
const num = buffer[i];
if (num < 58 && num >= 48) {
sum = sum * 10 + (num - 48);
continue;
}
if (i === start && num === 43) {
// +
continue;
}
if (i === start && num === 45) {
// -
sign = -1;
continue;
}
if (num === 46) {
// .
// its a float. break here.
break;
}
throw new Error("not a number: buffer[" + i + "] = " + num);
}
return sum * sign;
}
/**
* Decodes bencoded data.
*
* @param {Uint8Array} data
* @param {Number} start (optional)
* @param {Number} end (optional)
* @param {String} encoding (optional)
* @return {Object|Array|Uint8Array|String|Number}
*/
export function decode(data: Uint8Array, start?: number, end?: number, encoding?: string) {
const dec = {
position: 0,
bytes: 0,
encoding,
data: data.subarray(start, end),
} as Decode;
dec.bytes = dec.data.length;
return next(dec);
}
interface Decode {
bytes: number;
position: number;
data: Uint8Array;
encoding?: string;
}
function buffer(dec: Decode) {
let sep = find(dec, STRING_DELIM);
const length = getIntFromBuffer(dec.data, dec.position, sep);
const end = ++sep + length;
dec.position = end;
return dec.data.subarray(sep, end);
}
function next(dec: Decode): BencodeValue {
switch (dec.data[dec.position]) {
case DICTIONARY_START:
return dictionary(dec);
case LIST_START:
return list(dec);
case INTEGER_START:
return integer(dec);
default:
return buffer(dec);
}
}
function find(dec: Decode, chr: number) {
let i = dec.position;
const c = dec.data.length;
const d = dec.data;
while (i < c) {
if (d[i] === chr) return i;
i++;
}
throw new Error('Invalid data: Missing delimiter "' + String.fromCharCode(chr) + '" [0x' + chr.toString(16) + "]");
}
function dictionary(dec: Decode) {
dec.position++;
const dict = {} as Record<string, BencodeValue>;
while (dec.data[dec.position] !== END_OF_TYPE) {
const bf = buffer(dec);
let key = new TextDecoder().decode(bf);
if (key.includes("\uFFFD")) key = bytesToHex(bf);
dict[key] = next(dec);
}
dec.position++;
return dict;
}
function list(dec: Decode) {
dec.position++;
const lst = [] as Array<BencodeValue>;
while (dec.data[dec.position] !== END_OF_TYPE) {
lst.push(next(dec));
}
dec.position++;
return lst;
}
function integer(dec: Decode) {
const end = find(dec, END_OF_TYPE);
const number = getIntFromBuffer(dec.data, dec.position + 1, end);
dec.position += end + 1 - dec.position;
return number;
}
export default decode;

184
src/bencode/encode.ts Normal file
View File

@ -0,0 +1,184 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
function getType(value: any) {
if (ArrayBuffer.isView(value)) return "arraybufferview";
if (Array.isArray(value)) return "array";
if (value instanceof Number) return "number";
if (value instanceof Boolean) return "boolean";
if (value instanceof Set) return "set";
if (value instanceof Map) return "map";
if (value instanceof String) return "string";
if (value instanceof ArrayBuffer) return "arraybuffer";
return typeof value;
}
function text2arr(data: string) {
return new TextEncoder().encode(data);
}
function concat(arrays: Uint8Array[]): Uint8Array {
// Calculate the total length of all arrays
const totalLength = arrays.reduce((acc, value) => acc + value.length, 0);
// Create a new array with total length and fill it with elements of the arrays
const result = new Uint8Array(totalLength);
// Copy each array into the result
let length = 0;
for (const array of arrays) {
result.set(array, length);
length += array.length;
}
return result;
}
/**
* Encodes data in bencode.
*
* @param {Uint8Array|Array|String|Object|Number|Boolean} data
* @return {Uint8Array}
*/
export function encode(data: any, outBuffer?: Uint8Array, offset?: number) {
const buffers = [] as Array<Uint8Array>;
let result = null;
encode._encode(buffers, data);
result = concat(buffers);
encode.bytes = result.length;
if (ArrayBuffer.isView(outBuffer)) {
outBuffer.set(result, offset);
return outBuffer;
}
return result;
}
encode.bytes = -1;
encode._floatConversionDetected = false;
encode._encode = function (buffers: Array<Uint8Array>, data: any) {
if (data == null) {
return;
}
switch (getType(data)) {
case "object":
encode.dict(buffers, data);
break;
case "map":
encode.dictMap(buffers, data);
break;
case "array":
encode.list(buffers, data);
break;
case "set":
encode.listSet(buffers, data);
break;
case "string":
encode.string(buffers, data);
break;
case "number":
encode.number(buffers, data);
break;
case "boolean":
encode.number(buffers, data);
break;
case "arraybufferview":
encode.buffer(buffers, new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
break;
case "arraybuffer":
encode.buffer(buffers, new Uint8Array(data));
break;
}
};
const buffE = new Uint8Array([0x65]);
const buffD = new Uint8Array([0x64]);
const buffL = new Uint8Array([0x6c]);
encode.buffer = function (buffers: Array<Uint8Array>, data: any) {
buffers.push(text2arr(data.length + ":"), data);
};
encode.string = function (buffers: Array<Uint8Array>, data: any) {
buffers.push(text2arr(text2arr(data).byteLength + ":" + data));
};
encode.number = function (buffers: Array<Uint8Array>, data: any) {
if (Number.isInteger(data)) return buffers.push(text2arr("i" + BigInt(data) + "e"));
const maxLo = 0x80000000;
const hi = (data / maxLo) << 0;
const lo = data % maxLo << 0;
const val = hi * maxLo + lo;
buffers.push(text2arr("i" + val + "e"));
if (val !== data && !encode._floatConversionDetected) {
encode._floatConversionDetected = true;
console.warn(
'WARNING: Possible data corruption detected with value "' + data + '":',
'Bencoding only defines support for integers, value was converted to "' + val + '"',
);
console.trace();
}
};
encode.dict = function (buffers: Array<Uint8Array>, data: any) {
buffers.push(buffD);
let j = 0;
let k;
// fix for issue #13 - sorted dicts
const keys = Object.keys(data).sort();
const kl = keys.length;
for (; j < kl; j++) {
k = keys[j];
if (data[k] == null) continue;
encode.string(buffers, k);
encode._encode(buffers, data[k]);
}
buffers.push(buffE);
};
encode.dictMap = function (buffers: Array<Uint8Array>, data: any) {
buffers.push(buffD);
const keys = Array.from(data.keys()).sort();
for (const key of keys) {
if (data.get(key) == null) continue;
ArrayBuffer.isView(key) ? encode._encode(buffers, key) : encode.string(buffers, String(key));
encode._encode(buffers, data.get(key));
}
buffers.push(buffE);
};
encode.list = function (buffers: Array<Uint8Array>, data: any) {
let i = 0;
const c = data.length;
buffers.push(buffL);
for (; i < c; i++) {
if (data[i] == null) continue;
encode._encode(buffers, data[i]);
}
buffers.push(buffE);
};
encode.listSet = function (buffers: Array<Uint8Array>, data: any) {
buffers.push(buffL);
for (const item of data) {
if (item == null) continue;
encode._encode(buffers, item);
}
buffers.push(buffE);
};

2
src/bencode/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./encode";
export * from "./decode";

133
src/const.ts Normal file
View File

@ -0,0 +1,133 @@
import { EventKind } from "@snort/system";
/**
* @constant {number} - Size of 1 kiB
*/
export const kiB = Math.pow(1024, 1);
/**
* @constant {number} - Size of 1 MiB
*/
export const MiB = Math.pow(1024, 2);
/**
* @constant {number} - Size of 1 GiB
*/
export const GiB = Math.pow(1024, 3);
/**
* @constant {number} - Size of 1 TiB
*/
export const TiB = Math.pow(1024, 4);
/**
* @constant {number} - Size of 1 PiB
*/
export const PiB = Math.pow(1024, 5);
/**
* @constant {number} - Size of 1 EiB
*/
export const EiB = Math.pow(1024, 6);
/**
* @constant {number} - Size of 1 ZiB
*/
export const ZiB = Math.pow(1024, 7);
/**
* @constant {number} - Size of 1 YiB
*/
export const YiB = Math.pow(1024, 8);
export interface Category {
name: string;
tag: string;
sub_category?: Array<Category>;
}
export const Categories = [
{
name: "Video",
tag: "video",
sub_category: [
{
name: "Movies",
tag: "movie",
sub_category: [
{
name: "Movies DVDR",
tag: "dvdr",
},
{
name: "HD Movies",
tag: "hd",
},
{
name: "4k Movies",
tag: "4k",
},
],
},
{
name: "TV",
tag: "tv",
sub_category: [
{
name: "HD TV",
tag: "hd",
},
{
name: "4k TV",
tag: "4k",
},
],
},
],
},
{
name: "Audio",
tag: "audio",
sub_category: [
{
name: "Music",
tag: "music",
},
{
name: "Audio Books",
tag: "audio-book",
},
],
},
{
name: "Applications",
tag: "application",
sub_category: [],
},
{
name: "Other",
tag: "other",
sub_category: [
{
name: "Archives",
tag: "archive",
},
],
},
] as Array<Category>;
export const TorrentKind = 2003 as EventKind;
export function FormatBytes(b: number, f?: number) {
f ??= 2;
if (b >= YiB) return (b / YiB).toFixed(f) + " YiB";
if (b >= ZiB) return (b / ZiB).toFixed(f) + " ZiB";
if (b >= EiB) return (b / EiB).toFixed(f) + " EiB";
if (b >= PiB) return (b / PiB).toFixed(f) + " PiB";
if (b >= TiB) return (b / TiB).toFixed(f) + " TiB";
if (b >= GiB) return (b / GiB).toFixed(f) + " GiB";
if (b >= MiB) return (b / MiB).toFixed(f) + " MiB";
if (b >= kiB) return (b / kiB).toFixed(f) + " KiB";
return b.toFixed(f) + " B";
}
export const Trackers = [
"udp://tracker.coppersurfer.tk:6969/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.opentrackr.org:1337",
];

36
src/element/button.tsx Normal file
View File

@ -0,0 +1,36 @@
import classNames from "classnames";
import { HTMLProps, forwardRef, useState } from "react";
type ButtonProps = Omit<HTMLProps<HTMLButtonElement>, "onClick"> & {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => Promise<void> | void;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const [spinning, setSpinning] = useState(false);
async function clicking(e: React.MouseEvent<HTMLButtonElement>) {
if (!props.onClick) return;
e.preventDefault();
try {
setSpinning(true);
await props?.onClick?.(e);
} finally {
setSpinning(false);
}
}
return (
<button
{...props}
type="button"
className={classNames(
"p-2 rounded flex gap-1 items-center justify-center bg-slate-800 hover:bg-slate-600",
props.className,
)}
ref={ref}
onClick={clicking}
>
{spinning ? "Loading.." : props.children}
</button>
);
});

47
src/element/magnet.tsx Normal file
View File

@ -0,0 +1,47 @@
import { TaggedNostrEvent } from "@snort/system";
import { Trackers } from "../const";
import { Link, LinkProps } from "react-router-dom";
type MagnetLinkProps = Omit<LinkProps, "to"> & {
item: TaggedNostrEvent;
size?: number;
};
export function MagnetLink({ item, size, ...props }: MagnetLinkProps) {
const btih = item.tags.find((a) => a[0] === "btih")?.at(1);
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const magnet = {
xt: `urn:btih:${btih}`,
dn: name,
tr: Trackers,
};
const params = Object.entries(magnet)
.map(([k, v]) => {
if (Array.isArray(v)) {
return v.map((a) => `${k}=${encodeURIComponent(a)}`).join("&");
} else {
return `${k}=${v as string}`;
}
})
.flat()
.join("&");
const link = `magnet:?${params}`;
return (
<Link {...props} to={link}>
<svg width={size ?? 20} height={size ?? 20} version="1.1" viewBox="0 0 64 64" fill="currentColor">
<path
d="M54.5,9.5c-4.9-5-11.4-7.8-18.3-7.8c-6.5,0-12.6,2.4-17.2,7L3.6,24.3c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9c2.5,2.5,6.6,2.5,9.1,0
l14.5-14.4c1.8-1.8,4.6-2.1,6.3-0.7c0.9,0.7,1.4,1.8,1.5,3c0.1,1.4-0.5,2.7-1.5,3.7L24.9,45.4c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9
c1.2,1.2,2.9,1.9,4.5,1.9c1.6,0,3.3-0.6,4.5-1.9l15.5-15.5C64.8,35.4,64.5,19.6,54.5,9.5z M15.4,36c-0.7,0.7-2,0.7-2.7,0l-5.9-5.9
c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6L15.4,36z M36.6,57.2c-0.7,0.7-2,0.7-2.7,0L28,51.3c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6
L36.6,57.2z M52.2,41.7L45,48.9l-8.6-8.6l6.3-6.3c1.9-1.9,2.9-4.5,2.8-7.1c-0.1-2.5-1.3-4.7-3.2-6.3c-1.6-1.3-3.5-1.9-5.5-1.9
c-2.5,0-5,1-6.9,2.9l-6.1,6.1l-8.6-8.6l7.2-7.2c3.7-3.6,8.6-5.7,13.9-5.7c0,0,0.1,0,0.1,0c5.7,0,11,2.3,15.1,6.5
C59.5,21,59.9,34,52.2,41.7z"
/>
</svg>
{props.children}
</Link>
);
}

View File

@ -0,0 +1,29 @@
import { NostrLink, NostrPrefix } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { CSSProperties, HTMLProps } from "react";
import { Link } from "react-router-dom";
type ProfileImageProps = HTMLProps<HTMLDivElement> & {
pubkey?: string;
size?: number;
};
export function ProfileImage({ pubkey, size, ...props }: ProfileImageProps) {
const profile = useUserProfile(pubkey);
const v = {
backgroundImage: `url(${profile?.picture})`,
} as CSSProperties;
if (size) {
v.width = `${size}px`;
v.height = `${size}px`;
}
return (
<Link to={pubkey ? `/p/${new NostrLink(NostrPrefix.Profile, pubkey).encode()}` : ""}>
<div
{...props}
className="rounded-full aspect-square w-12 bg-slate-800 border border-slate-200 bg-cover bg-center"
style={v}
></div>
</Link>
);
}

17
src/element/search.tsx Normal file
View File

@ -0,0 +1,17 @@
import { Categories } from "../const";
export function Search() {
return (
<div className="flex flex-col gap-1">
<div className="flex gap-3 flex-wrap">
{Categories.map((a) => (
<div className="flex gap-1" key={a.tag}>
<input type="checkbox" />
<label>{a.name}</label>
</div>
))}
</div>
<input type="text" placeholder="Search.." className="p-3 rounded grow" />
</div>
);
}

View File

@ -0,0 +1,11 @@
.torrent-list {
width: 100%;
border-collapse: collapse;
}
.torrent-list td,
.torrent-list th {
border: 1px solid #333;
padding: 0px 5px;
font-size: 14px;
}

View File

@ -0,0 +1,59 @@
import "./torrent-list.css";
import { hexToBech32 } from "@snort/shared";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { FormatBytes } from "../const";
import { Link } from "react-router-dom";
import { MagnetLink } from "./magnet";
export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
return (
<table className="torrent-list">
<thead>
<tr>
<th>Category</th>
<th>Name</th>
<th>Uploaded</th>
<th></th>
<th>Size</th>
<th>From</th>
</tr>
</thead>
<tbody>
{items.map((a) => (
<TorrentTableEntry item={a} />
))}
</tbody>
</table>
);
}
function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) {
const profile = useUserProfile(item.pubkey);
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const size = Number(item.tags.find((a) => a[0] === "size")?.at(1));
const npub = hexToBech32("npub", item.pubkey);
return (
<tr>
<td>
{item.tags
.filter((a) => a[0] === "t")
.map((a) => a[1])
.join(" > ")}
</td>
<td>
<Link to={`/e/${NostrLink.fromEvent(item).encode()}`} state={item}>
{name}
</Link>
</td>
<td>{new Date(item.created_at * 1000).toLocaleDateString()}</td>
<td>
<MagnetLink item={item} />
</td>
<td>{FormatBytes(size)}</td>
<td>
<Link to={`/p/${npub}`}>{profile?.name ?? npub.slice(0, 12)}</Link>
</td>
</tr>
);
}

18
src/element/trending.tsx Normal file
View File

@ -0,0 +1,18 @@
import { NoteCollection, RequestBuilder } from "@snort/system";
import { TorrentKind } from "../const";
import { useRequestBuilder } from "@snort/system-react";
import { TorrentList } from "./torrent-list";
export function LatestTorrents() {
const sub = new RequestBuilder("torrents:latest");
sub.withFilter().kinds([TorrentKind]).limit(100);
const latest = useRequestBuilder(NoteCollection, sub);
return (
<>
<h3>Latest Torrents</h3>
<TorrentList items={latest.data ?? []} />
</>
);
}

34
src/index.css Normal file
View File

@ -0,0 +1,34 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
background-color: black;
color: white;
font-size: 16px;
font-family: Arial, Helvetica, sans-serif;
}
h1 {
font-size: 32px;
}
h2 {
font-size: 28px;
}
h3 {
font-size: 21px;
}
input[type="text"],
input[type="number"],
textarea {
color: black;
padding: 4px;
border-radius: 4px;
}
a:not([href="/"], :has(button)) {
text-decoration: dotted;
text-decoration-line: underline;
}

42
src/login.tsx Normal file
View File

@ -0,0 +1,42 @@
import { ExternalStore } from "@snort/shared";
import { useSyncExternalStore } from "react";
export interface LoginSession {
publicKey: string;
}
class LoginStore extends ExternalStore<LoginSession | undefined> {
#session?: LoginSession;
constructor() {
super();
const s = window.localStorage.getItem("session");
if (s) {
this.#session = JSON.parse(s);
}
}
takeSnapshot() {
return this.#session ? { ...this.#session } : undefined;
}
login(pubkey: string) {
this.#session = {
publicKey: pubkey,
};
this.#save();
}
#save() {
window.localStorage.setItem("session", JSON.stringify(this.#session));
this.notifyChange();
}
}
export const LoginState = new LoginStore();
export function useLogin() {
return useSyncExternalStore(
(c) => LoginState.hook(c),
() => LoginState.snapshot(),
);
}

52
src/main.tsx Normal file
View File

@ -0,0 +1,52 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { RouteObject, RouterProvider, createBrowserRouter } from "react-router-dom";
import { Layout } from "./page/layout";
import { HomePage } from "./page/home";
import { NostrSystem } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { ProfilePage } from "./page/profile";
import { NewPage } from "./page/new";
import { TorrentPage } from "./page/torrent";
const System = new NostrSystem({});
const Routes = [
{
element: <Layout />,
loader: async () => {
await System.Init();
for (const r of ["wss://nos.lol", "wss://relay.damus.io"]) {
await System.ConnectToRelay(r, { read: true, write: true });
}
return null;
},
children: [
{
path: "/",
element: <HomePage />,
},
{
path: "/p/:id",
element: <ProfilePage />,
},
{
path: "/new",
element: <NewPage />,
},
{
path: "/e/:id",
element: <TorrentPage />,
},
],
},
] as Array<RouteObject>;
const Router = createBrowserRouter(Routes);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<SnortContext.Provider value={System}>
<RouterProvider router={Router} />
</SnortContext.Provider>
</React.StrictMode>,
);

11
src/page/home.tsx Normal file
View File

@ -0,0 +1,11 @@
import { Search } from "../element/search";
import { LatestTorrents } from "../element/trending";
export function HomePage() {
return (
<>
<Search />
<LatestTorrents />
</>
);
}

42
src/page/layout.tsx Normal file
View File

@ -0,0 +1,42 @@
import { Link, Outlet } from "react-router-dom";
import { Button } from "../element/button";
import { LoginSession, LoginState, useLogin } from "../login";
import { ProfileImage } from "../element/profile-image";
export function Layout() {
const login = useLogin();
async function DoLogin() {
if ("nostr" in window) {
const pubkey = await window.nostr?.getPublicKey();
if (pubkey) {
LoginState.login(pubkey);
}
}
}
return (
<div className="container mx-auto">
<header className="flex justify-between items-center p-1">
<Link to={"/"}>
<h1 className="font-bold uppercase">dtan.xyz</h1>
</Link>
{login ? <LoggedInHeader login={login} /> : <Button onClick={DoLogin}>Login</Button>}
</header>
<div className="p-1">
<Outlet />
</div>
</div>
);
}
function LoggedInHeader({ login }: { login: LoginSession }) {
return (
<div className="flex items-center gap-3">
<ProfileImage pubkey={login.publicKey} />
<Link to="/new">
<Button>+ Create</Button>
</Link>
</div>
);
}

245
src/page/new.tsx Normal file
View File

@ -0,0 +1,245 @@
import { ReactNode, useContext, useState } from "react";
import { Categories, Category, TorrentKind } from "../const";
import { Button } from "../element/button";
import { EventPublisher, Nip7Signer } from "@snort/system";
import { useLogin } from "../login";
import { dedupe } from "@snort/shared";
import * as bencode from "../bencode";
import { sha1 } from "@noble/hashes/sha1";
import { bytesToHex } from "@noble/hashes/utils";
import { SnortContext } from "@snort/system-react";
async function openFile(): Promise<File | undefined> {
return new Promise((resolve) => {
const elm = document.createElement("input");
let lock = false;
elm.type = "file";
elm.accept = ".torrent";
const handleInput = (e: Event) => {
lock = true;
const elm = e.target as HTMLInputElement;
if ((elm.files?.length ?? 0) > 0) {
resolve(elm.files![0]);
} else {
resolve(undefined);
}
};
elm.onchange = (e) => handleInput(e);
elm.click();
window.addEventListener(
"focus",
() => {
setTimeout(() => {
if (!lock) {
console.debug("FOCUS WINDOW UPLOAD");
resolve(undefined);
}
}, 300);
},
{ once: true },
);
});
}
export function NewPage() {
const login = useLogin();
const system = useContext(SnortContext);
const [obj, setObj] = useState({
name: "",
desc: "",
btih: "",
tags: [] as Array<string>,
files: [] as Array<{
name: string;
size: number;
}>,
});
async function loadTorrent() {
const f = await openFile();
if (f) {
const buf = await f.arrayBuffer();
const torrent = bencode.decode(new Uint8Array(buf)) as Record<string, bencode.BencodeValue>;
const infoBuf = bencode.encode(torrent["info"]);
console.debug(torrent);
const dec = new TextDecoder();
const info = torrent["info"] as {
files?: Array<{ length: number; path: Array<Uint8Array> }>;
length: number;
name: Uint8Array;
};
setObj({
name: dec.decode(info.name),
desc: dec.decode(torrent["comment"] as Uint8Array | undefined) ?? "",
btih: bytesToHex(sha1(infoBuf)),
tags: [],
files: (info.files ?? [{ length: info.length, path: [info.name] }]).map((a) => ({
size: a.length,
name: dec.decode(a.path[0]),
})),
});
}
}
async function publish() {
if (!login) return;
const signer = new Nip7Signer();
const builder = new EventPublisher(signer, login.publicKey);
const ev = await builder.generic((eb) => {
const v = eb
.kind(TorrentKind)
.content(obj.desc)
.tag(["title", obj.name])
.tag(["size", String(obj.files.reduce((acc, v) => (acc += v.size), 0))])
.tag(["btih", obj.btih]);
obj.tags.forEach((t) => v.tag(["t", t]));
obj.files.forEach((f) => v.tag(["file", f.name, String(f.size)]));
return v;
});
console.debug(ev);
if (ev) {
await system.BroadcastEvent(ev);
}
}
function renderCategories(a: Category, tags: Array<string>): ReactNode {
return (
<>
<div className="flex gap-1 bg-slate-500 p-1 rounded">
<input
type="radio"
value={tags.join(",")}
name="category"
checked={obj.tags.join(",") === tags.join(",")}
onChange={(e) =>
setObj((o) => ({
...o,
tags: e.target.checked ? dedupe(e.target.value.split(",")) : [],
}))
}
/>
<label>{a?.name}</label>
</div>
{a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))}
</>
);
}
return (
<>
<h1>New</h1>
<div className="flex gap-1">
<Button onClick={loadTorrent}>Import from Torrent</Button>
<Button>Import from Magnet</Button>
</div>
<h2>Torrent Info</h2>
<form className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="flex-1 flex flex-col gap-1">
<label>Title</label>
<input
type="text"
placeholder="raw noods"
value={obj.name}
onChange={(e) => setObj((o) => ({ ...o, name: e.target.value }))}
/>
<label>Info Hash</label>
<input
type="text"
placeholder="hex"
value={obj.btih}
onChange={(e) => setObj((o) => ({ ...o, btih: e.target.value }))}
/>
<label>Category</label>
<div className="flex flex-col gap-1">
{Categories.map((a) => (
<div className="flex flex-col gap-1">
<div className="font-bold bg-slate-800 p-1">{a.name}</div>
<div className="flex gap-1 flex-wrap">{renderCategories(a, [a.tag])}</div>
</div>
))}
</div>
</div>
<div className="flex-1 flex flex-col gap-1">
<label>Description</label>
<textarea
rows={20}
className="font-mono text-xs"
value={obj.desc}
onChange={(e) => setObj((o) => ({ ...o, desc: e.target.value }))}
></textarea>
</div>
</div>
<h2>Files</h2>
<div className="flex flex-col gap-2">
{obj.files.map((a, i) => (
<div className="flex gap-1">
<input
type="text"
value={a.name}
className="flex-1"
placeholder="collection1/IMG_00001.jpg"
onChange={(e) =>
setObj((o) => ({
...o,
files: o.files.map((f, ii) => {
if (ii === i) {
return { ...f, name: e.target.value };
}
return f;
}),
}))
}
/>
<input
type="number"
value={a.size}
min={0}
placeholder="69000"
onChange={(e) =>
setObj((o) => ({
...o,
files: o.files.map((f, ii) => {
if (ii === i) {
return { ...f, size: Number(e.target.value) };
}
return f;
}),
}))
}
/>
<Button
onClick={() =>
setObj((o) => ({
...o,
files: o.files.filter((_, ii) => i !== ii),
}))
}
>
Remove
</Button>
</div>
))}
</div>
<Button
onClick={() =>
setObj((o) => ({
...o,
files: [...o.files, { name: "", size: 0 }],
}))
}
>
Add File
</Button>
<Button onClick={publish}>Publish</Button>
</form>
</>
);
}

37
src/page/profile.tsx Normal file
View File

@ -0,0 +1,37 @@
import { useUserProfile } from "@snort/system-react";
import { Link, useParams } from "react-router-dom";
import { ProfileImage } from "../element/profile-image";
import { parseNostrLink } from "@snort/system";
import { LatestTorrents } from "../element/trending";
export function ProfilePage() {
const params = useParams();
const id = params.id as string;
const link = parseNostrLink(id);
if (!link) return;
return (
<>
<ProfileSection pubkey={link.id} />
<LatestTorrents />
</>
);
}
export function ProfileSection({ pubkey }: { pubkey: string }) {
const profile = useUserProfile(pubkey);
return (
<div className="flex items-center gap-3">
<ProfileImage pubkey={pubkey} size={240} />
<div className="flex flex-col gap-2">
<h2>{profile?.name}</h2>
<p>{profile?.about}</p>
{profile?.website && (
<Link to={profile.website} target="_blank">
{new URL(profile.website).hostname}
</Link>
)}
</div>
</div>
);
}

49
src/page/torrent.tsx Normal file
View File

@ -0,0 +1,49 @@
import { unwrap } from "@snort/shared";
import { NoteCollection, RequestBuilder, TaggedNostrEvent, parseNostrLink } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useLocation, useParams } from "react-router-dom";
import { FormatBytes, TorrentKind } from "../const";
import { ProfileImage } from "../element/profile-image";
import { MagnetLink } from "../element/magnet";
export function TorrentPage() {
const location = useLocation();
const { id } = useParams();
const evState = "kind" in location.state ? (location.state as TaggedNostrEvent) : undefined;
const rb = new RequestBuilder("torrent:event");
rb.withFilter()
.kinds([TorrentKind])
.link(parseNostrLink(unwrap(id)));
const evNew = useRequestBuilder(NoteCollection, evState ? null : rb);
const ev = evState ?? evNew.data?.at(0);
if (!ev) return;
return <TorrentDetail item={ev} />;
}
export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const size = Number(item.tags.find((a) => a[0] === "size")?.at(1));
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center text-xl">
<ProfileImage pubkey={item.pubkey} />
{name}
</div>
<div className="flex flex-col gap-1 bg-slate-700 p-2 rounded">
<div>Size: {FormatBytes(size)}</div>
<div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div>
<div>
<MagnetLink item={item} className="flex gap-1 items-center">
Get this torrent
</MagnetLink>
</div>
</div>
<h3>Description</h3>
<pre className="font-mono text-xs bg-slate-700 p-2 rounded overflow-y-auto">{item.content}</pre>
</div>
);
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});

3714
yarn.lock Normal file

File diff suppressed because it is too large Load Diff