initial commit
This commit is contained in:
commit
d3a17fde00
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -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?
|
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
build
|
||||
dist
|
23
.prettierrc
Normal file
23
.prettierrc
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"endOfLine": "lf",
|
||||
"experimentalTernaries": false,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 80,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleAttributePerLine": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false,
|
||||
"vueIndentScriptAndStyle": false
|
||||
}
|
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "964445a2",
|
||||
"configHash": "294caa02",
|
||||
"lockfileHash": "75101775",
|
||||
"browserHash": "3e05e511",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
63
README.md
Normal file
63
README.md
Normal file
@ -0,0 +1,63 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x';
|
||||
import reactDom from 'eslint-plugin-react-dom';
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Internationalization of the app (I18N)
|
||||
|
||||
This template supports internationalization (i18n) using [i18next](https://www.i18next.com/), allowing seamless translation of UI text into multiple languages.
|
||||
The setup includes modularized translation files (namespaces), language detection, context and runtime language switching.
|
||||
|
||||
Files with translation are in `src/i18n/locales/<locale>` folder.
|
||||
|
||||
`core` namespace is already present and active.
|
37
eslint.config.js
Normal file
37
eslint.config.js
Normal file
@ -0,0 +1,37 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
// This disables ESLint rules that would conflict with Prettier
|
||||
name: 'prettier-config',
|
||||
rules: prettierConfig.rules,
|
||||
}
|
||||
);
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Qortal Q-App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
4419
package-lock.json
generated
Normal file
4419
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "q-charts",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"initialize": "node scripts/initialize.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.0.1",
|
||||
"@mui/material": "^7.0.1",
|
||||
"apexcharts": "^4.7.0",
|
||||
"i18next": "^25.1.2",
|
||||
"jotai": "^2.12.4",
|
||||
"qapp-core": "^1.0.31",
|
||||
"react": "^19.0.0",
|
||||
"react-apexcharts": "^1.7.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-router-dom": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
30
scripts/initialize.js
Normal file
30
scripts/initialize.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { writeFile, access } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Resolve __dirname in ES Modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Define the file path (adjusted for new location in scripts/)
|
||||
const filePath = join(__dirname, '..', 'src', 'qapp-config.ts');
|
||||
|
||||
try {
|
||||
// Check if file already exists
|
||||
await access(filePath, constants.F_OK);
|
||||
console.log('⚠️ qapp-config.ts already exists. Skipping creation.');
|
||||
} catch {
|
||||
// File does not exist, proceed to create it
|
||||
const publicSalt = randomBytes(32).toString('base64');
|
||||
const tsContent = `export const publicSalt = "${publicSalt}";\n`;
|
||||
|
||||
try {
|
||||
await writeFile(filePath, tsContent, 'utf8');
|
||||
console.log('✅ qapp-config.ts has been created with a unique public salt.');
|
||||
} catch (error) {
|
||||
console.error('❌ Error writing qapp-config.ts:', error);
|
||||
}
|
||||
}
|
||||
|
3
settings.json
Normal file
3
settings.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"indentRainbow.colorOnWhiteSpaceOnly": true
|
||||
}
|
73
src/App.tsx
Normal file
73
src/App.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QortCandlestickChart, {
|
||||
Candle,
|
||||
} from './components/QortCandlestickChart';
|
||||
import { fetchTrades, aggregateCandles } from './utils/qortTrades';
|
||||
|
||||
const DEFAULT_BLOCKCHAIN = 'LITECOIN';
|
||||
const ONE_HOUR = 60 * 60 * 1000;
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [candles, setCandles] = useState<Candle[]>([]);
|
||||
const [interval, setInterval] = useState(ONE_HOUR);
|
||||
const [minTimestamp, setMinTimestamp] = useState(
|
||||
Date.now() - 30 * 24 * ONE_HOUR
|
||||
); // 30 days ago
|
||||
|
||||
// You’d call this whenever the user changes range, interval, etc
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchTrades({
|
||||
foreignBlockchain: DEFAULT_BLOCKCHAIN,
|
||||
minimumTimestamp: minTimestamp,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
reverse: true,
|
||||
})
|
||||
.then((trades) => {
|
||||
if (!cancelled) setCandles(aggregateCandles(trades, interval));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setCandles([]);
|
||||
|
||||
console.error(e);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [interval, minTimestamp]);
|
||||
|
||||
// Add controls as needed for changing interval and time range
|
||||
const [blockchain, setBlockchain] = useState(DEFAULT_BLOCKCHAIN);
|
||||
return (
|
||||
<div style={{ background: '#10161c', minHeight: '100vh', padding: 32 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>
|
||||
Pair:
|
||||
<select
|
||||
value={blockchain}
|
||||
onChange={(e) => setBlockchain(e.target.value)}
|
||||
>
|
||||
<option value="LITECOIN">LTC</option>
|
||||
<option value="RAVENCOIN">RVN</option>
|
||||
<option value="BITCOIN">BTC</option>
|
||||
<option value="DIGIBYTE">DGB</option>
|
||||
<option value="PIRATECHAIN">ARRR</option>
|
||||
<option value="DOGECOIN">DOGE</option>
|
||||
</select>
|
||||
</label>
|
||||
Interval:
|
||||
<button onClick={() => setInterval(ONE_HOUR)}>1H</button>
|
||||
<button onClick={() => setInterval(24 * ONE_HOUR)}>1D</button>
|
||||
Start Date:
|
||||
<input
|
||||
type="date"
|
||||
value={new Date(minTimestamp).toISOString().slice(0, 10)}
|
||||
onChange={(e) => setMinTimestamp(new Date(e.target.value).getTime())}
|
||||
/>
|
||||
</div>
|
||||
<QortCandlestickChart candles={candles} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default App;
|
23
src/AppWrapper.tsx
Normal file
23
src/AppWrapper.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Routes } from './routes/Routes.tsx';
|
||||
import { GlobalProvider } from 'qapp-core';
|
||||
import { publicSalt } from './qapp-config.ts';
|
||||
|
||||
export const AppWrapper = () => {
|
||||
return (
|
||||
<GlobalProvider
|
||||
config={{
|
||||
appName: 'Q-Charts',
|
||||
auth: {
|
||||
balanceSetting: {
|
||||
interval: 180000,
|
||||
onlyOnMount: false,
|
||||
},
|
||||
authenticateOnMount: true,
|
||||
},
|
||||
publicSalt: publicSalt,
|
||||
}}
|
||||
>
|
||||
<Routes />
|
||||
</GlobalProvider>
|
||||
);
|
||||
};
|
49
src/components/QortCandlestickChart.tsx
Normal file
49
src/components/QortCandlestickChart.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import Chart from 'react-apexcharts';
|
||||
import type { ApexOptions } from 'apexcharts';
|
||||
|
||||
export interface Candle {
|
||||
x: number;
|
||||
y: [number, number, number, number];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
candles: Candle[];
|
||||
}
|
||||
|
||||
const QortCandlestickChart: React.FC<Props> = ({ candles }) => {
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'candlestick',
|
||||
height: 420,
|
||||
background: '#181e24',
|
||||
toolbar: { show: true },
|
||||
},
|
||||
title: {
|
||||
text: 'QORT/LTC Price (1h Candles)',
|
||||
align: 'left',
|
||||
style: { color: '#fff' },
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: { style: { colors: '#ccc' } },
|
||||
},
|
||||
yaxis: {
|
||||
tooltip: { enabled: true },
|
||||
labels: { style: { colors: '#ccc' } },
|
||||
},
|
||||
theme: { mode: 'dark' },
|
||||
};
|
||||
|
||||
const series = [
|
||||
{
|
||||
data: candles,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Chart options={options} series={series} type="candlestick" height={420} />
|
||||
);
|
||||
};
|
||||
|
||||
export default QortCandlestickChart;
|
75
src/hooks/useIframeListener.tsx
Normal file
75
src/hooks/useIframeListener.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
import { To, useNavigate } from 'react-router-dom';
|
||||
import { EnumTheme, themeAtom } from '../state/global/system';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '../i18n/i18n';
|
||||
|
||||
type Language = 'de' | 'en' | 'es' | 'fr' | 'it' | 'ja' | 'ru' | 'zh';
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
interface CustomWindow extends Window {
|
||||
_qdnTheme: Theme;
|
||||
_qdnLang: Language;
|
||||
}
|
||||
const customWindow = window as unknown as CustomWindow;
|
||||
|
||||
export const useIframe = () => {
|
||||
const setTheme = useSetAtom(themeAtom);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
const themeColorDefault = customWindow?._qdnTheme;
|
||||
if (themeColorDefault === 'dark') {
|
||||
setTheme(EnumTheme.DARK);
|
||||
} else if (themeColorDefault === 'light') {
|
||||
setTheme(EnumTheme.LIGHT);
|
||||
}
|
||||
|
||||
const languageDefault = customWindow?._qdnLang;
|
||||
|
||||
if (supportedLanguages?.includes(languageDefault)) {
|
||||
i18n.changeLanguage(languageDefault);
|
||||
}
|
||||
|
||||
function handleNavigation(event: {
|
||||
data: {
|
||||
action: string;
|
||||
path: To;
|
||||
theme: Theme;
|
||||
language: Language;
|
||||
};
|
||||
}) {
|
||||
if (event.data?.action === 'NAVIGATE_TO_PATH' && event.data.path) {
|
||||
navigate(event.data.path); // Navigate directly to the specified path
|
||||
|
||||
// Send a response back to the parent window after navigation is handled
|
||||
window.parent.postMessage(
|
||||
{ action: 'NAVIGATION_SUCCESS', path: event.data.path },
|
||||
'*'
|
||||
);
|
||||
} else if (event.data?.action === 'THEME_CHANGED' && event.data.theme) {
|
||||
const themeColor = event.data.theme;
|
||||
if (themeColor === 'dark') {
|
||||
setTheme(EnumTheme.DARK);
|
||||
} else if (themeColor === 'light') {
|
||||
setTheme(EnumTheme.LIGHT);
|
||||
}
|
||||
} else if (
|
||||
event.data?.action === 'LANGUAGE_CHANGED' &&
|
||||
event.data.language
|
||||
) {
|
||||
if (!supportedLanguages?.includes(event.data.language)) return;
|
||||
i18n.changeLanguage(event.data.language);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleNavigation);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleNavigation);
|
||||
};
|
||||
}, [navigate, setTheme]);
|
||||
return { navigate };
|
||||
};
|
56
src/i18n/i18n.ts
Normal file
56
src/i18n/i18n.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import {
|
||||
capitalizeAll,
|
||||
capitalizeFirstChar,
|
||||
capitalizeFirstWord,
|
||||
} from './processors';
|
||||
|
||||
// Load all locale JSON files
|
||||
const modules = import.meta.glob('./locales/**/*.json', {
|
||||
eager: true,
|
||||
}) as Record<string, any>;
|
||||
|
||||
// Dynamically detect unique language codes
|
||||
export const supportedLanguages: string[] = Array.from(
|
||||
new Set(
|
||||
Object.keys(modules)
|
||||
.map((path) => {
|
||||
const match = path.match(/\.\/locales\/([^/]+)\//);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((lang): lang is string => typeof lang === 'string')
|
||||
)
|
||||
);
|
||||
|
||||
// Construct i18n resources object
|
||||
const resources: Record<string, Record<string, any>> = {};
|
||||
|
||||
for (const path in modules) {
|
||||
// Path format: './locales/en/core.json'
|
||||
const match = path.match(/\.\/locales\/([^/]+)\/([^/]+)\.json$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, lang, ns] = match;
|
||||
resources[lang] = resources[lang] || {};
|
||||
resources[lang][ns] = modules[path].default;
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.use(capitalizeAll as any)
|
||||
.use(capitalizeFirstChar as any)
|
||||
.use(capitalizeFirstWord as any)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
lng: navigator.language,
|
||||
supportedLngs: supportedLanguages,
|
||||
ns: ['core'],
|
||||
defaultNS: 'core',
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
debug: import.meta.env.MODE === 'development',
|
||||
});
|
||||
|
||||
export default i18n;
|
4
src/i18n/locales/en/core.json
Normal file
4
src/i18n/locales/en/core.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"using_theme": "this application is using the theme:",
|
||||
"welcome": "welcome to Qortal"
|
||||
}
|
32
src/i18n/processors.ts
Normal file
32
src/i18n/processors.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export const capitalizeAll = {
|
||||
type: 'postProcessor',
|
||||
name: 'capitalizeAll',
|
||||
process: (value: string) => value.toUpperCase(),
|
||||
};
|
||||
|
||||
export const capitalizeFirstChar = {
|
||||
type: 'postProcessor',
|
||||
name: 'capitalizeFirstChar',
|
||||
process: (value: string) => value.charAt(0).toUpperCase() + value.slice(1),
|
||||
};
|
||||
|
||||
export const capitalizeFirstWord = {
|
||||
type: 'postProcessor',
|
||||
name: 'capitalizeFirstWord',
|
||||
process: (value: string) => {
|
||||
if (!value?.trim()) return value;
|
||||
|
||||
const trimmed = value.trimStart();
|
||||
const firstSpaceIndex = trimmed.indexOf(' ');
|
||||
|
||||
if (firstSpaceIndex === -1) {
|
||||
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
|
||||
}
|
||||
|
||||
const firstWord = trimmed.slice(0, firstSpaceIndex);
|
||||
const restOfString = trimmed.slice(firstSpaceIndex);
|
||||
const trailingSpaces = value.slice(trimmed.length);
|
||||
|
||||
return firstWord.toUpperCase() + restOfString + trailingSpaces;
|
||||
},
|
||||
};
|
29
src/index.css
Normal file
29
src/index.css
Normal file
@ -0,0 +1,29 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('./styles/fonts/Inter-SemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('./styles/fonts/Inter-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('./styles/fonts/Inter-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('./styles/fonts/Inter-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
:root {
|
||||
line-height: 1.2;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Inter';
|
||||
}
|
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import ThemeProviderWrapper from './styles/theme/theme-provider.tsx';
|
||||
import { AppWrapper } from './AppWrapper.tsx';
|
||||
import './index.css';
|
||||
import './i18n/i18n.ts';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProviderWrapper>
|
||||
<AppWrapper />
|
||||
</ThemeProviderWrapper>
|
||||
</StrictMode>
|
||||
);
|
1
src/qapp-config.ts
Normal file
1
src/qapp-config.ts
Normal file
@ -0,0 +1 @@
|
||||
export const publicSalt = '6GsJWj6LbBdjd1f+CLHCLIDBIsegsd4GV4fFt12Csjs=';
|
32
src/routes/Routes.tsx
Normal file
32
src/routes/Routes.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import Layout from '../styles/Layout';
|
||||
import App from '../App';
|
||||
|
||||
// Use a custom type if you need it
|
||||
interface CustomWindow extends Window {
|
||||
_qdnBase: string;
|
||||
}
|
||||
const customWindow = window as unknown as CustomWindow;
|
||||
const baseUrl = customWindow?._qdnBase || '';
|
||||
|
||||
export function Routes() {
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <App />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
basename: baseUrl,
|
||||
}
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
9
src/state/global/system.ts
Normal file
9
src/state/global/system.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export enum EnumTheme {
|
||||
LIGHT = 1,
|
||||
DARK = 2,
|
||||
}
|
||||
|
||||
// Atom to hold the current theme
|
||||
export const themeAtom = atom<EnumTheme>(EnumTheme.DARK);
|
17
src/styles/Layout.tsx
Normal file
17
src/styles/Layout.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useIframe } from '../hooks/useIframeListener';
|
||||
|
||||
const Layout = () => {
|
||||
useIframe();
|
||||
return (
|
||||
<>
|
||||
{/* Add Header here */}
|
||||
<main>
|
||||
<Outlet /> {/* This is where page content will be rendered */}
|
||||
</main>
|
||||
{/* Add Footer here */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
BIN
src/styles/fonts/Inter-Black.ttf
Normal file
BIN
src/styles/fonts/Inter-Black.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Inter-Bold.ttf
Normal file
BIN
src/styles/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Inter-ExtraBold.ttf
Normal file
BIN
src/styles/fonts/Inter-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Inter-ExtraLight.ttf
Normal file
BIN
src/styles/fonts/Inter-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Inter-Light.ttf
Normal file
BIN
src/styles/fonts/Inter-Light.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Inter-Medium.ttf
Normal file
BIN
src/styles/fonts/Inter-Medium.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Inter-Regular.ttf
Normal file
BIN
src/styles/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Inter-SemiBold.ttf
Normal file
BIN
src/styles/fonts/Inter-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Inter-Thin.ttf
Normal file
BIN
src/styles/fonts/Inter-Thin.ttf
Normal file
Binary file not shown.
23
src/styles/theme/theme-provider.tsx
Normal file
23
src/styles/theme/theme-provider.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React, { FC } from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { lightTheme, darkTheme } from './theme';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { EnumTheme, themeAtom } from '../../state/global/system';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
interface ThemeProviderWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ThemeProviderWrapper: FC<ThemeProviderWrapperProps> = ({ children }) => {
|
||||
const [theme] = useAtom(themeAtom);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme === EnumTheme.LIGHT ? lightTheme : darkTheme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProviderWrapper;
|
119
src/styles/theme/theme.ts
Normal file
119
src/styles/theme/theme.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
const commonThemeOptions = {
|
||||
typography: {
|
||||
fontFamily: ['Inter'].join(','),
|
||||
h1: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.4,
|
||||
letterSpacing: '0.2px',
|
||||
},
|
||||
},
|
||||
spacing: 8,
|
||||
shape: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
breakpoints: {
|
||||
values: {
|
||||
xs: 0,
|
||||
sm: 600,
|
||||
md: 900,
|
||||
lg: 1200,
|
||||
xl: 1536,
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPopover: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lightTheme = createTheme({
|
||||
...commonThemeOptions,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: 'rgb(63, 81, 181)',
|
||||
dark: 'rgb(113, 198, 212)',
|
||||
light: 'rgb(180, 200, 235)',
|
||||
},
|
||||
secondary: {
|
||||
main: 'rgba(194, 222, 236, 1)',
|
||||
},
|
||||
background: {
|
||||
default: 'rgba(250, 250, 250, 1)',
|
||||
paper: 'rgb(220, 220, 220)', // darker card background
|
||||
},
|
||||
text: {
|
||||
primary: 'rgba(0, 0, 0, 0.87)', // 87% black (slightly softened)
|
||||
secondary: 'rgba(0, 0, 0, 0.6)', // 60% black
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const darkTheme = createTheme({
|
||||
...commonThemeOptions,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: 'rgb(100, 155, 240)',
|
||||
dark: 'rgb(45, 92, 201)',
|
||||
light: 'rgb(130, 185, 255)',
|
||||
},
|
||||
secondary: {
|
||||
main: 'rgb(69, 173, 255)',
|
||||
},
|
||||
background: {
|
||||
default: 'rgb(49, 51, 56)',
|
||||
paper: 'rgb(62, 64, 68)',
|
||||
},
|
||||
text: {
|
||||
primary: 'rgb(255, 255, 255)',
|
||||
secondary: 'rgb(179, 179, 179)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { lightTheme, darkTheme };
|
131
src/utils/fetchAllTrades.tsx
Normal file
131
src/utils/fetchAllTrades.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
/* eslint-disable prettier/prettier */
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
export interface Trade {
|
||||
tradeTimestamp: number;
|
||||
qortAmount: string;
|
||||
btcAmount: string;
|
||||
foreignAmount: string;
|
||||
}
|
||||
|
||||
interface FetchAllTradesModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: (allTrades: Trade[]) => void;
|
||||
foreignBlockchain: string;
|
||||
batchSize?: number;
|
||||
minTimestamp?: number;
|
||||
}
|
||||
|
||||
const FetchAllTradesModal: React.FC<FetchAllTradesModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
foreignBlockchain,
|
||||
batchSize = 200,
|
||||
minTimestamp = 0,
|
||||
}) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [totalFetched, setTotalFetched] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!open) return;
|
||||
|
||||
async function fetchAllTrades() {
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setTotalFetched(0);
|
||||
setIsFetching(true);
|
||||
|
||||
let allTrades: Trade[] = [];
|
||||
let offset = 0;
|
||||
let keepFetching = true;
|
||||
|
||||
try {
|
||||
while (keepFetching && !cancelled) {
|
||||
const params = new URLSearchParams({
|
||||
foreignBlockchain,
|
||||
offset: offset.toString(),
|
||||
limit: batchSize.toString(),
|
||||
minimumTimestamp: minTimestamp.toString(),
|
||||
reverse: 'false',
|
||||
});
|
||||
const url = `/crosschain/trades?${params.toString()}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const trades: Trade[] = await resp.json();
|
||||
|
||||
allTrades = allTrades.concat(trades);
|
||||
setTotalFetched(allTrades.length);
|
||||
setProgress(trades.length);
|
||||
|
||||
offset += trades.length;
|
||||
if (trades.length < batchSize) keepFetching = false;
|
||||
}
|
||||
if (!cancelled) {
|
||||
onComplete(allTrades);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e));
|
||||
} finally {
|
||||
if (!cancelled) setIsFetching(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAllTrades();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, foreignBlockchain, batchSize, minTimestamp, onComplete]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={isFetching ? undefined : onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Fetching All Trades</DialogTitle>
|
||||
<DialogContent>
|
||||
{isFetching ? (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
Obtaining all trades for <b>{foreignBlockchain}</b>.<br />
|
||||
This could take a while, please be patient...
|
||||
</Typography>
|
||||
<Typography gutterBottom>
|
||||
<b>{totalFetched}</b> trades fetched (last batch: {progress})
|
||||
</Typography>
|
||||
<CircularProgress />
|
||||
</>
|
||||
) : error ? (
|
||||
<Typography color="error" gutterBottom>
|
||||
Error: {error}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography color="success.main" gutterBottom>
|
||||
Fetch complete.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
disabled={isFetching}
|
||||
variant="contained"
|
||||
color={isFetching ? 'inherit' : 'primary'}
|
||||
>
|
||||
{isFetching ? 'Fetching...' : 'Close'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FetchAllTradesModal;
|
98
src/utils/qortTrades.ts
Normal file
98
src/utils/qortTrades.ts
Normal file
@ -0,0 +1,98 @@
|
||||
// src/utils/qortTrades.ts
|
||||
export interface Trade {
|
||||
tradeTimestamp: number;
|
||||
qortAmount: string;
|
||||
btcAmount: string;
|
||||
foreignAmount: string;
|
||||
}
|
||||
|
||||
export interface FetchTradesOptions {
|
||||
foreignBlockchain: string;
|
||||
minimumTimestamp: number;
|
||||
buyerPublicKey?: string;
|
||||
sellerPublicKey?: string;
|
||||
limit?: number; // default 1000
|
||||
offset?: number;
|
||||
reverse?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchTrades({
|
||||
foreignBlockchain,
|
||||
minimumTimestamp,
|
||||
buyerPublicKey,
|
||||
sellerPublicKey,
|
||||
limit = 1000,
|
||||
offset = 0,
|
||||
reverse = false,
|
||||
}: FetchTradesOptions): Promise<Trade[]> {
|
||||
const params = new URLSearchParams({
|
||||
foreignBlockchain,
|
||||
minimumTimestamp: String(minimumTimestamp),
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
reverse: String(reverse),
|
||||
});
|
||||
if (buyerPublicKey) params.append('buyerPublicKey', buyerPublicKey);
|
||||
if (sellerPublicKey) params.append('sellerPublicKey', sellerPublicKey);
|
||||
|
||||
const url = `crosschain/trades?${params.toString()}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
// Candle chart utility
|
||||
export type Candle = { x: number; y: [number, number, number, number] };
|
||||
|
||||
export function aggregateCandles(
|
||||
trades: Trade[],
|
||||
intervalMs: number
|
||||
): Candle[] {
|
||||
const sorted = trades
|
||||
.slice()
|
||||
.sort((a, b) => a.tradeTimestamp - b.tradeTimestamp);
|
||||
const candles: Candle[] = [];
|
||||
let current: {
|
||||
bucket: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
} | null = null;
|
||||
|
||||
const getPrice = (trade: Trade) => {
|
||||
const qort = parseFloat(trade.qortAmount);
|
||||
const ltc = parseFloat(trade.foreignAmount);
|
||||
return qort > 0 ? ltc / qort : null;
|
||||
};
|
||||
|
||||
for (const trade of sorted) {
|
||||
const price = getPrice(trade);
|
||||
if (!price) continue;
|
||||
const bucket = Math.floor(trade.tradeTimestamp / intervalMs) * intervalMs;
|
||||
if (!current || current.bucket !== bucket) {
|
||||
if (current)
|
||||
candles.push({
|
||||
x: current.bucket,
|
||||
y: [current.open, current.high, current.low, current.close],
|
||||
});
|
||||
current = {
|
||||
bucket,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
};
|
||||
} else {
|
||||
current.high = Math.max(current.high, price);
|
||||
current.low = Math.min(current.low, price);
|
||||
current.close = price;
|
||||
}
|
||||
}
|
||||
if (current)
|
||||
candles.push({
|
||||
x: current.bucket,
|
||||
y: [current.open, current.high, current.low, current.close],
|
||||
});
|
||||
return candles;
|
||||
}
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["qapp-core/global"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["qapp-core/global"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
11
vite.config.ts
Normal file
11
vite.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '',
|
||||
optimizeDeps: {
|
||||
include: ['@mui/material', '@mui/styled-engine', '@mui/system'],
|
||||
},
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user