feat: add react

This commit is contained in:
kastov 2024-12-05 03:52:54 +03:00
parent 862a935c0b
commit 57645d47c4
53 changed files with 10968 additions and 305 deletions

95
.eslintrc.cjs Normal file
View file

@ -0,0 +1,95 @@
module.exports = {
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"airbnb-base",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:storybook/recommended",
"plugin:perfectionist/recommended-natural-legacy",
"prettier",
],
ignorePatterns: ["dist", ".eslintrc.cjs", "plop", "plop/**", "plopfile.js", ".stylelintrc.js"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh", "import"],
settings: {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"],
},
"import/resolver": {
node: true,
typescript: {
project: ".",
},
},
},
rules: {
"perfectionist/sort-imports": [
"error",
{
type: "line-length",
order: "desc",
ignoreCase: true,
specialCharacters: "keep",
internalPattern: ["^~/.+"],
tsconfigRootDir: ".",
partitionByComment: false,
partitionByNewLine: false,
newlinesBetween: "always",
maxLineLength: undefined,
groups: [
"type",
["builtin", "external"],
"internal-type",
"internal",
["parent-type", "sibling-type", "index-type"],
["parent", "sibling", "index"],
"object",
"unknown",
],
customGroups: { type: {}, value: {} },
environment: "node",
},
],
"perfectionist/sort-objects": ["off"],
indent: ["error", 4, { SwitchCase: 1 }],
"max-classes-per-file": "off",
"import/no-extraneous-dependencies": ["off"],
"import/no-unresolved": "error",
"import/prefer-default-export": "off",
"import/extensions": "off",
"no-bitwise": "off",
"no-plusplus": "off",
"no-restricted-syntax": ["off", "ForInStatement"],
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"no-shadow": ["off"],
"arrow-body-style": ["off"],
"object-curly-spacing": ["error", "always"],
"array-bracket-spacing": ["error", "never"],
"no-underscore-dangle": [
"off",
{
allow: ["_"],
allowAfterThis: true,
allowAfterSuper: true,
allowAfterThisConstructor: true,
enforceInMethodNames: false,
},
],
semi: ["error", "never"],
"comma-dangle": ["off"],
"brace-style": ["error", "1tbs", { allowSingleLine: true }],
"object-curly-newline": ["error", { multiline: true, consistent: true }],
"react-hooks/exhaustive-deps": "off",
"no-empty-pattern": "warn",
"@typescript-eslint/ban-types": [
"error",
{
types: {
"{}": false,
},
},
],
},
};

View file

@ -1,53 +1,50 @@
name: Deploy
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Runs on pushes targeting the default branch
push:
branches: ['main']
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
contents: read
pages: write
id-token: write
# Allow one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
group: 'pages'
cancel-in-progress: true
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Pages
uses: actions/configure-pages@v1
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
check-latest: true
- run: make build
- run: mkdir public && cp index.html main.wasm wasm_exec.js xray.schema.json public
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
path: public/
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Pages
uses: actions/configure-pages@v1
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
check-latest: true
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
- run: make build
- name: Build Vite
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
path: dist/

26
.gitignore vendored
View file

@ -2,3 +2,29 @@
*.js
assets
*.schema.json
# 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?

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

8
.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"singleQuote": true,
"singleAttributePerLine": false,
"tabWidth": 4,
"printWidth": 100,
"semi": false,
"trailingComma": "none"
}

View file

@ -1,4 +1,6 @@
build: main.wasm wasm_exec.js xray.schema.json
mkdir -p public
mv main.wasm wasm_exec.js xray.schema.json public/
.PHONY: build
dev-lite:
@ -36,8 +38,4 @@ assets/Xray-docs-next-main:
cd assets && curl -fL https://github.com/XTLS/Xray-docs-next/archive/refs/heads/main.tar.gz | tar xvzf -
xray.schema.json: scrape-docs.py assets/Xray-docs-next-main
grep -r '' assets/Xray-docs-next-main/docs/en/config/ | cut -d: -f2- | python3 scrape-docs.py > xray.schema.json
serve:
python3 -mhttp.server
grep -r '' assets/Xray-docs-next-main/docs/en/config/ | cut -d: -f2- | python3 scrape-docs.py > xray.schema.json

11
global.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Go: typeof window.Go
onWasmInitialized?: () => void
XrayParseConfig: (config: string) => null | string
}
}
export {}

View file

@ -1,262 +1,13 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xray Config Validator</title>
<script src="wasm_exec.js"></script>
<script>
const isReady = new Promise((resolve) => {
window.onWasmInitialized = resolve;
});
const go = new Go();
(async () => {
const result = await WebAssembly.instantiateStreaming(fetch('main.wasm'), go.importObject);
go.run(result.instance);
await isReady;
document.getElementById("version-info").innerText = `Xray version ${XrayGetVersion()}`;
})();
</script>
<style>
* {
box-sizing: border-box;
}
html, body {
height: 100%;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.container {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
#editor {
flex: 1;
border-radius: 5px;
border: 1px solid #ced4da;
height: 500px;
}
#output {
padding: 10px;
line-height: 1.2;
margin-top: 20px;
border-radius: 5px;
border: 1px solid #ced4da;
text-align: center;
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
}
#output[data-status="ok"] {
background-color: #d4edda;
color: #155724;
}
#output[data-status="error"] {
background-color: #f8d7da;
color: #721c24;
}
#output[data-status="warn"] {
background-color: #fff3cd;
color: #856404;
}
#nav {
text-align: right;
}
h1 {
font-size: 1.8rem;
font-weight: 500;
line-height: 1.2;
margin-top: 0;
text-align: center;
}
a {
color: inherit;
}
.cool-color {
background-image: linear-gradient(to right,#E70000, #FF8C00, #FFEF00, #00811F, #0044FF, #760089);
-webkit-background-clip: text;
color:transparent;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #121212;
color: #ffffff;
}
#editor {
border: 1px solid #555;
}
#output {
border: 1px solid #555;
}
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.33.0/min/vs/loader.min.js"></script>
</head>
<body>
<div class="container">
<nav id="nav">
<span id="version-info">Loading Xray&hellip;</span> |
<a href="https://github.com/mmmray/xray-online">GitHub</a>
<h1>Xray Config Validator</h1>
</nav>
<div id="editor"></div>
<div id="output">
Start typing your Xray config in the editor above.<br>
Hover over fields for documentation. <span class="cool-color">Autocomplete</span> is available.
</div>
</div>
<script>
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.0/min/vs' }});
require(['vs/editor/editor.main'], function() {
const editorElement = document.getElementById('editor');
const output = document.getElementById("output");
function updateEditorTheme() {
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = prefersDarkMode ? 'vs-dark' : 'vs-light';
monaco.editor.setTheme(theme);
}
const editor = monaco.editor.create(editorElement, {
value: `{
"inbounds": [
{
"listen": "127.0.0.1",
"port": 10808,
"protocol": "socks",
"settings": {
"udp": true
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
}
],
"outbounds": [
{
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "",
"port": 443,
"users": [
{
"id": "user",
"encryption": "none",
"flow": "xtls-rprx-vision"
}
]
}
]
},
"streamSettings": {
"network": "tcp",
"security": "tls",
"tlsSettings": {
"serverName": "",
"allowInsecure": false,
"fingerprint": "chrome"
}
},
"tag": "proxy"
}
]
}`,
language: 'json',
theme: 'vs-light',
automaticLayout: true
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateEditorTheme);
updateEditorTheme();
function validateJSON(text) {
try {
JSON.parse(text);
return null;
} catch (error) {
return error.message;
}
}
fetch('xray.schema.json')
.then(response => response.json())
.then(schema => {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: true,
schemas: [{
uri: "https://xray-config-schema",
fileMatch: ["*"],
schema: schema
}]
});
})
.catch(error => console.error('Scheme load error:', error));
editor.onDidChangeModelContent(function() {
const jsonText = editor.getValue();
const validationError = validateJSON(jsonText);
if (validationError) {
output.dataset.status = "error";
output.innerText = "Validation Error: " + validationError;
} else {
if (typeof XrayParseConfig === 'undefined') {
output.dataset.status = "warn";
output.innerText = "Xray is still loading. Check the browser console.";
} else {
let validationResult = XrayParseConfig(jsonText);
if (validationResult) {
output.dataset.status = "error";
output.innerText = validationResult;
} else {
output.dataset.status = "ok";
output.innerText = "Valid Xray Config!";
}
}
}
});
window.addEventListener("resize", function() {
editor.layout();
});
});
</script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/wasm_exec.js"></script>
<title>Xray Monaco Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9641
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

78
package.json Normal file
View file

@ -0,0 +1,78 @@
{
"name": "xray-monaco-edtitor",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@mantine/carousel": "^7.12.2",
"@mantine/charts": "^7.12.2",
"@mantine/code-highlight": "^7.12.2",
"@mantine/core": "^7.14.3",
"@mantine/dates": "^7.12.2",
"@mantine/dropzone": "^7.12.2",
"@mantine/form": "^7.12.2",
"@mantine/hooks": "^7.12.2",
"@mantine/modals": "^7.12.2",
"@mantine/notifications": "^7.12.2",
"@mantine/nprogress": "^7.12.2",
"@monaco-editor/react": "^4.6.0",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"motion": "^11.13.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/js": "^9.16.0",
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@types/byte-size": "^8.1.2",
"@types/bytes": "^3.1.4",
"@types/color-hash": "^2.0.0",
"@types/crypto-js": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^20.11.19",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react-swc": "^3.7.0",
"cross-env": "^7.0.3",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-perfectionist": "^4.1.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-storybook": "^0.8.0",
"globals": "^15.12.0",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^25.0.0",
"postcss": "^8.4.45",
"postcss-preset-mantine": "1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.3.3",
"prop-types": "^15.8.1",
"rollup-plugin-visualizer": "^5.12.0",
"stylelint": "^16.9.0",
"stylelint-config-standard-scss": "^13.1.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0",
"vite": "6.0.1",
"vite-plugin-javascript-obfuscator": "^3.1.0",
"vite-plugin-preload": "^0.4.0",
"vite-plugin-webfont-dl": "^3.9.4",
"vite-tsconfig-paths": "^5.0.1",
"vite-plugin-wasm": "^3.3.0"
}
}

19
postcss.config.cjs Normal file
View file

@ -0,0 +1,19 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {
autoRem: true
},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '30em',
'mantine-breakpoint-sm': '40em',
'mantine-breakpoint-md': '48em',
'mantine-breakpoint-lg': '64em',
'mantine-breakpoint-xl': '80em',
'mantine-breakpoint-2xl': '96em',
'mantine-breakpoint-3xl': '120em',
'mantine-breakpoint-4xl': '160em'
}
}
}
}

24
src/app.tsx Normal file
View file

@ -0,0 +1,24 @@
import '@mantine/carousel/styles.layer.css'
import '@mantine/charts/styles.layer.css'
import '@mantine/code-highlight/styles.layer.css'
import '@mantine/core/styles.layer.css'
import '@mantine/dates/styles.layer.css'
import '@mantine/dropzone/styles.layer.css'
import '@mantine/notifications/styles.layer.css'
import '@mantine/nprogress/styles.layer.css'
import './global.css'
import { NavigationProgress } from '@mantine/nprogress'
import { MantineProvider } from '@mantine/core'
import { ConfigPageConnector } from '@pages/config/ui/connectors/config.page.connector'
export function App() {
return (
<MantineProvider defaultColorScheme="dark">
<NavigationProgress />
<ConfigPageConnector />
</MantineProvider>
)
}

View file

@ -0,0 +1,29 @@
import { PiCheckSquareOffset } from 'react-icons/pi'
import { Button, Group } from '@mantine/core'
import { Props } from './interfaces'
export function ConfigEditorActionsFeature(props: Props) {
const { editorRef } = props
const formatDocument = () => {
if (!editorRef.current) return
if (typeof editorRef.current !== 'object') return
if (!('getAction' in editorRef.current)) return
if (typeof editorRef.current.getAction !== 'function') return
editorRef.current.getAction('editor.action.formatDocument').run()
}
return (
<Group>
<Button
leftSection={<PiCheckSquareOffset size={16} />}
mb="md"
onClick={formatDocument}
>
Format
</Button>
</Group>
)
}

View file

@ -0,0 +1,2 @@
export * from './config-editor-actions.feature'
export * from './interfaces'

View file

@ -0,0 +1 @@
export * from './props.interface'

View file

@ -0,0 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface Props {
editorRef: any
isConfigValid: boolean
isSaving: boolean
monacoRef: any
setIsSaving: (value: boolean) => void
setResult: (value: string) => void
}

View file

@ -0,0 +1,32 @@
import dayjs from 'dayjs'
export const ConfigValidationFeature = {
validate: (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editorRef: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
monacoRef: any,
setResult: (message: string) => void,
setIsConfigValid: (isValid: boolean) => void
) => {
try {
if (!editorRef.current) return
if (!monacoRef.current) return
if (typeof editorRef.current !== 'object') return
if (typeof monacoRef.current !== 'object') return
if (!('getValue' in editorRef.current)) return
if (typeof editorRef.current.getValue !== 'function') return
const currentValue = editorRef.current.getValue()
const validationResult = window.XrayParseConfig(currentValue)
setResult(
`${dayjs().format('HH:mm:ss')} | ${validationResult || 'Xray config is valid.'}`
)
setIsConfigValid(!validationResult)
} catch (err: unknown) {
setResult(`${dayjs().format('HH:mm:ss')} | Validation error: ${(err as Error).message}`)
setIsConfigValid(false)
}
}
}

View file

@ -0,0 +1 @@
export * from './config-validation.feature'

View file

@ -0,0 +1 @@
export * from './monaco-setup.feature'

View file

@ -0,0 +1,27 @@
import { Monaco } from '@monaco-editor/react'
import axios from 'axios'
export const MonacoSetupFeature = {
setup: async (monaco: Monaco) => {
try {
const response = await axios.get('/xray.schema.json')
const schema = await response.data
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
allowComments: false,
enableSchemaRequest: true,
schemaRequest: 'warning',
schemas: [
{
fileMatch: ['*'],
schema,
uri: 'https://xray-config-schema.json'
}
],
validate: true
})
} catch (error) {
console.error('Failed to load JSON schema:', error)
}
}
}

21
src/global.css Normal file
View file

@ -0,0 +1,21 @@
@layer mantine, mantine-datatable;
html,
body {
min-height: 100vh;
}
#root {
height: 100vh;
@mixin dark {
.mrt-table-head-sort-button {
border-radius: 0;
border: none;
}
.mrt-table-head-cell-filter-label-icon {
border-radius: 0;
border: none;
}
}
}

14
src/main.tsx Normal file
View file

@ -0,0 +1,14 @@
import { createRoot } from 'react-dom/client'
import { StrictMode } from 'react'
import { App } from './app'
const container = document.getElementById('root')
if (!container) throw new Error('Failed to find the root element')
const root = createRoot(container)
root.render(
<StrictMode>
<App />
</StrictMode>
)

View file

@ -0,0 +1,7 @@
.root {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

View file

@ -0,0 +1,23 @@
import { Container } from '@mantine/core'
import { ConfigEditorWidget } from '@widgets/config/config-editor'
import { HeaderWidget } from '@widgets/header'
import { Page } from '@/shared/ui/page'
import { Props } from './interfaces'
export const ConfigPageComponent = (props: Props) => {
const { config } = props
return (
<>
<HeaderWidget />
<Container size="lg">
<Page title="Config">
<ConfigEditorWidget config={config} />
</Page>
</Container>
</>
)
}

View file

@ -0,0 +1 @@
export * from './props.interface'

View file

@ -0,0 +1,3 @@
export interface Props {
config: Record<string, unknown> | string
}

View file

@ -0,0 +1,53 @@
import { fetchWithProgress } from '@/shared/utils/fetch-with-progress'
import { useEffect, useState } from 'react'
import { LoadingScreen } from '@/shared/ui/loading-screen'
import { DEFAULT_CONFIG } from '@/shared/constants'
import { ConfigPageComponent } from '../components/config.page.component'
export function ConfigPageConnector() {
const [downloadProgress, setDownloadProgress] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const config = DEFAULT_CONFIG
useEffect(() => {
const initWasm = async () => {
try {
const go = new window.Go()
const wasmInitialized = new Promise<void>((resolve) => {
window.onWasmInitialized = () => {
console.info('WASM module initialized')
resolve()
}
})
const wasmBytes = await fetchWithProgress('/main.wasm', setDownloadProgress)
const { instance } = await WebAssembly.instantiate(wasmBytes, go.importObject)
go.run(instance)
await wasmInitialized
if (typeof window.XrayParseConfig === 'function') {
setIsLoading(false)
} else {
throw new Error('XrayParseConfig not initialized')
}
} catch (err: unknown) {
console.error('WASM initialization error:', err)
setIsLoading(false)
}
}
initWasm()
return () => {
delete window.onWasmInitialized
}
}, [])
if (isLoading) {
return <LoadingScreen text={`WASM module is loading...`} value={downloadProgress} />
}
return <ConfigPageComponent config={config} />
}

View file

@ -0,0 +1,46 @@
export const DEFAULT_CONFIG = {
inbounds: [
{
listen: '127.0.0.1',
port: 10808,
protocol: 'socks',
settings: {
udp: true
},
sniffing: {
enabled: true,
destOverride: ['http', 'tls']
}
}
],
outbounds: [
{
protocol: 'vless',
settings: {
vnext: [
{
address: '',
port: 443,
users: [
{
id: 'user',
encryption: 'none',
flow: 'xtls-rprx-vision'
}
]
}
]
},
streamSettings: {
network: 'tcp',
security: 'tls',
tlsSettings: {
serverName: '',
allowInsecure: false,
fingerprint: 'chrome'
}
},
tag: 'proxy'
}
]
}

View file

@ -0,0 +1 @@
export * from './default-config'

View file

@ -0,0 +1 @@
export * from './monaco-theme'

View file

@ -0,0 +1,348 @@
export const monacoTheme = {
base: 'vs-dark',
inherit: true,
rules: [
{
background: '24292e',
token: ''
},
{
foreground: '959da5',
token: 'comment'
},
{
foreground: '959da5',
token: 'punctuation.definition.comment'
},
{
foreground: '959da5',
token: 'string.comment'
},
{
foreground: 'c8e1ff',
token: 'constant'
},
{
foreground: 'c8e1ff',
token: 'entity.name.constant'
},
{
foreground: 'c8e1ff',
token: 'variable.other.constant'
},
{
foreground: 'c8e1ff',
token: 'variable.language'
},
{
foreground: 'b392f0',
token: 'entity'
},
{
foreground: 'b392f0',
token: 'entity.name'
},
{
foreground: 'f6f8fa',
token: 'variable.parameter.function'
},
{
foreground: '7bcc72',
token: 'entity.name.tag'
},
{
foreground: 'ea4a5a',
token: 'keyword'
},
{
foreground: 'ea4a5a',
token: 'storage'
},
{
foreground: 'ea4a5a',
token: 'storage.type'
},
{
foreground: 'f6f8fa',
token: 'storage.modifier.package'
},
{
foreground: 'f6f8fa',
token: 'storage.modifier.import'
},
{
foreground: 'f6f8fa',
token: 'storage.type.java'
},
{
foreground: '79b8ff',
token: 'string'
},
{
foreground: '79b8ff',
token: 'punctuation.definition.string'
},
{
foreground: '79b8ff',
token: 'string punctuation.section.embedded source'
},
{
foreground: 'c8e1ff',
token: 'support'
},
{
foreground: 'c8e1ff',
token: 'meta.property-name'
},
{
foreground: 'fb8532',
token: 'variable'
},
{
foreground: 'f6f8fa',
token: 'variable.other'
},
{
foreground: 'd73a49',
fontStyle: 'bold italic underline',
token: 'invalid.broken'
},
{
foreground: 'd73a49',
fontStyle: 'bold italic underline',
token: 'invalid.deprecated'
},
{
foreground: 'fafbfc',
background: 'd73a49',
fontStyle: 'italic underline',
token: 'invalid.illegal'
},
{
foreground: 'fafbfc',
background: 'd73a49',
fontStyle: 'italic underline',
token: 'carriage-return'
},
{
foreground: 'd73a49',
fontStyle: 'bold italic underline',
token: 'invalid.unimplemented'
},
{
foreground: 'd73a49',
token: 'message.error'
},
{
foreground: 'f6f8fa',
token: 'string source'
},
{
foreground: 'c8e1ff',
token: 'string variable'
},
{
foreground: '79b8ff',
token: 'source.regexp'
},
{
foreground: '79b8ff',
token: 'string.regexp'
},
{
foreground: '79b8ff',
token: 'string.regexp.character-class'
},
{
foreground: '79b8ff',
token: 'string.regexp constant.character.escape'
},
{
foreground: '79b8ff',
token: 'string.regexp source.ruby.embedded'
},
{
foreground: '79b8ff',
token: 'string.regexp string.regexp.arbitrary-repitition'
},
{
foreground: '7bcc72',
fontStyle: 'bold',
token: 'string.regexp constant.character.escape'
},
{
foreground: 'c8e1ff',
token: 'support.constant'
},
{
foreground: 'c8e1ff',
token: 'support.variable'
},
{
foreground: 'c8e1ff',
token: 'meta.module-reference'
},
{
foreground: 'fb8532',
token: 'markup.list'
},
{
foreground: '0366d6',
fontStyle: 'bold',
token: 'markup.heading'
},
{
foreground: '0366d6',
fontStyle: 'bold',
token: 'markup.heading entity.name'
},
{
foreground: 'c8e1ff',
token: 'markup.quote'
},
{
foreground: 'f6f8fa',
fontStyle: 'italic',
token: 'markup.italic'
},
{
foreground: 'f6f8fa',
fontStyle: 'bold',
token: 'markup.bold'
},
{
foreground: 'c8e1ff',
token: 'markup.raw'
},
{
foreground: 'b31d28',
background: 'ffeef0',
token: 'markup.deleted'
},
{
foreground: 'b31d28',
background: 'ffeef0',
token: 'meta.diff.header.from-file'
},
{
foreground: 'b31d28',
background: 'ffeef0',
token: 'punctuation.definition.deleted'
},
{
foreground: '176f2c',
background: 'f0fff4',
token: 'markup.inserted'
},
{
foreground: '176f2c',
background: 'f0fff4',
token: 'meta.diff.header.to-file'
},
{
foreground: '176f2c',
background: 'f0fff4',
token: 'punctuation.definition.inserted'
},
{
foreground: 'b08800',
background: 'fffdef',
token: 'markup.changed'
},
{
foreground: 'b08800',
background: 'fffdef',
token: 'punctuation.definition.changed'
},
{
foreground: '2f363d',
background: '959da5',
token: 'markup.ignored'
},
{
foreground: '2f363d',
background: '959da5',
token: 'markup.untracked'
},
{
foreground: 'b392f0',
fontStyle: 'bold',
token: 'meta.diff.range'
},
{
foreground: 'c8e1ff',
token: 'meta.diff.header'
},
{
foreground: '0366d6',
fontStyle: 'bold',
token: 'meta.separator'
},
{
foreground: '0366d6',
token: 'meta.output'
},
{
foreground: 'ffeef0',
token: 'brackethighlighter.tag'
},
{
foreground: 'ffeef0',
token: 'brackethighlighter.curly'
},
{
foreground: 'ffeef0',
token: 'brackethighlighter.round'
},
{
foreground: 'ffeef0',
token: 'brackethighlighter.square'
},
{
foreground: 'ffeef0',
token: 'brackethighlighter.angle'
},
{
foreground: 'ffeef0',
token: 'brackethighlighter.quote'
},
{
foreground: 'd73a49',
token: 'brackethighlighter.unmatched'
},
{
foreground: 'd73a49',
token: 'sublimelinter.mark.error'
},
{
foreground: 'fb8532',
token: 'sublimelinter.mark.warning'
},
{
foreground: '6a737d',
token: 'sublimelinter.gutter-mark'
},
{
foreground: '79b8ff',
fontStyle: 'underline',
token: 'constant.other.reference.link'
},
{
foreground: '79b8ff',
fontStyle: 'underline',
token: 'string.other.link'
}
],
colors: {
'editor.foreground': '#f6f8fa',
'editor.background': '#24292e',
'editor.selectionBackground': '#4c2889',
'editor.inactiveSelectionBackground': '#444d56',
'editor.lineHighlightBackground': '#444d56',
'editorCursor.foreground': '#ffffff',
'editorWhitespace.foreground': '#6a737d',
'editorIndentGuide.background': '#6a737d',
'editorIndentGuide.activeBackground': '#f6f8fa',
'editor.selectionHighlightBorder': '#444d56'
}
}

View file

@ -0,0 +1 @@
export * from './loading-screen'

View file

@ -0,0 +1,28 @@
import { Center, Progress, Stack, Text } from '@mantine/core'
export function LoadingScreen({
height = '100%',
text = undefined,
value = 100
}: {
height?: string
text?: string
value?: number
}) {
return (
<Center h={height}>
<Stack align="center" gap="xs" w="100%">
{text && <Text size="lg">{text}</Text>}
<Progress
animated
color="green"
maw="32rem"
radius="xs"
striped
value={value}
w="80%"
/>
</Stack>
</Center>
)
}

View file

@ -0,0 +1 @@
export * from './page'

View file

@ -0,0 +1,42 @@
import { forwardRef, ReactNode, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { nprogress } from '@mantine/nprogress'
import { Box, BoxProps } from '@mantine/core'
interface PageProps extends BoxProps {
children: ReactNode
meta?: ReactNode
title: string
}
export const Page = forwardRef<HTMLDivElement, PageProps>(
({ children, title = '', meta, ...other }, ref) => {
useEffect(() => {
nprogress.complete()
return () => nprogress.start()
}, [])
return (
<>
<title>{`${title}`}</title>
{meta}
<AnimatePresence mode="wait">
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: 'easeInOut'
}}
>
<Box ref={ref} {...other}>
{children}
</Box>
</motion.div>
</AnimatePresence>
</>
)
}
)

View file

@ -0,0 +1,26 @@
import { useWindowScroll } from '@mantine/hooks'
import { Box, BoxProps } from '@mantine/core'
import { ReactNode } from 'react'
import clsx from 'clsx'
import classes from './sticky-header.module.css'
interface StickyHeaderProps extends BoxProps {
children?: ReactNode
offset?: number
}
export function StickyHeader({ children, offset = 2, className, ...rest }: StickyHeaderProps) {
const [scroll] = useWindowScroll()
return (
<Box
className={clsx(classes.root, className)}
component="header"
data-sticked={scroll.y > offset}
{...rest}
>
{children}
</Box>
)
}

View file

@ -0,0 +1,20 @@
.root {
position: sticky;
top: 0;
z-index: 199;
display: flex;
align-items: center;
padding: var(--mantine-spacing-md);
backdrop-filter: blur(24px);
@media (min-width: $mantine-breakpoint-md) {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
@media (min-width: $mantine-breakpoint-lg) {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}

View file

@ -0,0 +1,22 @@
import axios from 'axios'
export const fetchWithProgress = async (url: string, onProgress?: (progress: number) => void) => {
try {
const response = await axios.get(url, {
onDownloadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress?.(progress)
} else {
onProgress?.(100)
}
},
responseType: 'arraybuffer'
})
return response.data
} catch (error) {
console.error('Download failed:', error)
throw error
}
}

View file

@ -0,0 +1 @@
export * from './fetch-with-progress.util'

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

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

View file

@ -0,0 +1,101 @@
import { monacoTheme } from '@/shared/lib/monaco-theme'
import Editor, { Monaco } from '@monaco-editor/react'
import { useEffect, useRef, useState } from 'react'
import { Box, Code, Paper } from '@mantine/core'
import { ConfigEditorActionsFeature } from '@features/config/config-editor-actions/ui'
import { ConfigValidationFeature } from '@features/config/config-validation/ui'
import { MonacoSetupFeature } from '@features/config/monaco-setup/lib'
import { Props } from './interfaces'
export function ConfigEditorWidget(props: Props) {
const { config } = props
const [result, setResult] = useState('')
const [isConfigValid, setIsConfigValid] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const editorRef = useRef<unknown>(null)
const monacoRef = useRef<unknown>(null)
useEffect(() => {
if (!monacoRef.current) return
MonacoSetupFeature.setup(monacoRef.current as Monaco)
}, [monacoRef.current])
const handleEditorDidMount = (monaco: Monaco) => {
monaco.editor.defineTheme('GithubDark', {
...monacoTheme
})
}
return (
<Box>
<Paper mb="md" p={0} radius="xs" withBorder>
<Editor
beforeMount={handleEditorDidMount}
defaultLanguage="json"
height="400px"
loading={'Loading editor...'}
onChange={() =>
ConfigValidationFeature.validate(
editorRef,
monacoRef,
setResult,
setIsConfigValid
)
}
onMount={(editor, monaco) => {
editorRef.current = editor
monacoRef.current = monaco
ConfigValidationFeature.validate(
editorRef,
monacoRef,
setResult,
setIsConfigValid
)
}}
options={{
autoClosingBrackets: 'always',
autoClosingQuotes: 'always',
autoIndent: 'full',
automaticLayout: true,
bracketPairColorization: true,
detectIndentation: true,
folding: true,
foldingStrategy: 'indentation',
fontSize: 14,
formatOnPaste: true,
formatOnType: true,
guides: {
bracketPairs: true,
indentation: true
},
insertSpaces: true,
minimap: { enabled: false },
quickSuggestions: true,
scrollBeyondLastLine: false,
tabSize: 2
}}
theme={'GithubDark'}
value={JSON.stringify(config, null, 2)}
/>
</Paper>
<ConfigEditorActionsFeature
editorRef={editorRef}
isConfigValid={isConfigValid}
isSaving={isSaving}
monacoRef={monacoRef}
setIsSaving={setIsSaving}
setResult={setResult}
/>
{result && (
<Code block p="md">
{result}
</Code>
)}
</Box>
)
}

View file

@ -0,0 +1,2 @@
export * from './config-editor.widget'
export * from './interfaces'

View file

@ -0,0 +1 @@
export * from './props.interface'

View file

@ -0,0 +1,3 @@
export interface Props {
config: Record<string, unknown> | string
}

View file

@ -0,0 +1 @@
export * from './ui/header.widget'

View file

@ -0,0 +1,10 @@
.root {
z-index: 198;
display: flex;
justify-content: center;
@media (min-width: $mantine-breakpoint-xl) {
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
}

View file

@ -0,0 +1,25 @@
import { ActionIcon, Group, Title } from '@mantine/core'
import { PiGithubLogo } from 'react-icons/pi'
import { StickyHeader } from '@/shared/ui/sticky-header'
import classes from './header.module.css'
export function HeaderWidget() {
return (
<StickyHeader className={classes.root} px="md">
<Group h="100%" justify="space-between">
<Title order={3}>Xray Config Validator</Title>
<ActionIcon
component="a"
href="https://github.com/mmmray/xray-online"
size="lg"
target="_blank"
variant="subtle"
>
<PiGithubLogo size={24} />
</ActionIcon>
</Group>
</StickyHeader>
)
}

67
tsconfig.json Normal file
View file

@ -0,0 +1,67 @@
{
"compilerOptions": {
"removeComments": true,
"target": "ES2020",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable",
],
"module": "ESNext",
"skipLibCheck": true,
/* ------------------------------------------------------------ */
/* Bundler mode */
/* ------------------------------------------------------------ */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* ------------------------------------------------------------ */
/* ------------------------------------------------------------ */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false, // TURN THIS
"noFallthroughCasesInSwitch": true,
"strictPropertyInitialization": false,
"forceConsistentCasingInFileNames": false,
/* ------------------------------------------------------------ */
/* Paths */
/* ------------------------------------------------------------ */
"baseUrl": ".",
"paths": {
"@entitites/*": [
"./src/entitites/*"
],
"@features/*": [
"./src/features/*"
],
"@pages/*": [
"./src/pages/*"
],
"@widgets/*": [
"./src/widgets/*"
],
"@/*": [
"./src/*"
],
"@public/*": [
"./public/*"
],
"@shared/*": [
"./src/shared/*"
]
}
},
"include": [
"src",
"client.d.ts",
"global.d.ts"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

13
tsconfig.node.json Normal file
View file

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

File diff suppressed because one or more lines are too long

1
tsconfig.tsbuildinfo Normal file
View file

@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/config/config-editor-actions/config-editor-actions.feature.tsx","./src/features/config/config-editor-actions/index.ts","./src/features/config/config-editor-actions/interfaces/index.ts","./src/features/config/config-editor-actions/interfaces/props.interface.tsx","./src/features/config/config-validation/config-validation.feature.tsx","./src/features/config/config-validation/index.ts","./src/features/config/monaco-setup/index.ts","./src/features/config/monaco-setup/monaco-setup.feature.tsx","./src/pages/config/ui/components/config.page.component.tsx","./src/pages/config/ui/components/interfaces/index.ts","./src/pages/config/ui/components/interfaces/props.interface.tsx","./src/pages/config/ui/connectors/config.page.connector.tsx","./src/shared/constants/default-config.tsx","./src/shared/constants/index.ts","./src/shared/monaco-theme/index.ts","./src/shared/monaco-theme/monaco-theme.tsx","./src/shared/sticky-header/index.tsx","./src/shared/ui/loading-screen/index.ts","./src/shared/ui/loading-screen/loading-screen.tsx","./src/shared/ui/page/index.ts","./src/shared/ui/page/page.tsx","./src/shared/utils/fetch-with-progress/fetch-with-progress.util.ts","./src/shared/utils/fetch-with-progress/index.ts","./src/widgets/config/config-editor/config-editor.widget.tsx","./src/widgets/config/config-editor/index.ts","./src/widgets/config/config-editor/interfaces/index.ts","./src/widgets/config/config-editor/interfaces/props.interface.tsx","./src/widgets/header/index.ts","./src/widgets/header/ui/header.widget.tsx","./global.d.ts"],"version":"5.7.2"}

2
vite.config.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

25
vite.config.ts Normal file
View file

@ -0,0 +1,25 @@
import tsconfigPaths from 'vite-tsconfig-paths'
import { fileURLToPath, URL } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
publicDir: 'public',
build: {
target: 'esNext',
outDir: 'dist'
},
resolve: {
alias: {
'@entitites': fileURLToPath(new URL('./src/entitites', import.meta.url)),
'@features': fileURLToPath(new URL('./src/features', import.meta.url)),
'@pages': fileURLToPath(new URL('./src/pages', import.meta.url)),
'@widgets': fileURLToPath(new URL('./src/widgets', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@public': fileURLToPath(new URL('./public', import.meta.url)),
'@shared': fileURLToPath(new URL('./src/shared', import.meta.url))
}
}
})