mirror of
https://github.com/JosunLP/UserScriptProjectTemplate.git
synced 2025-12-06 06:30:05 +00:00
Compare commits
5 commits
dcb2a449aa
...
8ebb187147
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ebb187147 | ||
|
|
0a0b521948 | ||
|
|
d0a6b3dfc2 | ||
|
|
c753e6fb72 | ||
|
|
36e24a25c2 |
6 changed files with 216 additions and 60 deletions
119
README.md
119
README.md
|
|
@ -1,8 +1,24 @@
|
||||||
# UserScript Project Template
|
# UserScript Project Template
|
||||||
|
|
||||||
[](https://github.com/JosunLP/UserScriptProjectTemplate/blob/main/LICENSE)
|
[](https://github.com/JosunLP/UserScriptProjectTemplate/blob/main/LICENSE)
|
||||||
[](https://github.com/JosunLP/UserScriptProjectTemplate/issues)
|
[](https://github.com/JosunLP/UserScriptProjectTemplate/issues)
|
||||||
[](https://github.com/JosunLP/UserScriptProjectTemplate/stargazers)
|
[](https://github.com/JosunLP/UserScriptProjectTemplate/stargazers)
|
||||||
|
[](https://github.com/JosunLP/UserScriptProjectTemplate/network)
|
||||||
|
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://vitejs.dev/)
|
||||||
|
[](https://eslint.org/)
|
||||||
|
[](https://prettier.io/)
|
||||||
|
[](https://nodejs.org/)
|
||||||
|
|
||||||
|
[](https://www.tampermonkey.net/)
|
||||||
|
[](https://www.greasespot.net/)
|
||||||
|
[](https://violentmonkey.github.io/)
|
||||||
|
[](https://github.com/JosunLP/UserScriptProjectTemplate#mobile-browser-support)
|
||||||
|
|
||||||
|
[](https://github.com/JosunLP/UserScriptProjectTemplate/commits)
|
||||||
|
[](https://github.com/JosunLP/UserScriptProjectTemplate/graphs/contributors)
|
||||||
|
[](https://github.com/JosunLP/UserScriptProjectTemplate)
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
|
|
@ -10,19 +26,19 @@ A modern, production-ready template for building UserScripts using TypeScript an
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
• 🚀 **Modern Tech Stack:** TypeScript, Vite, ESLint, Prettier
|
- 🚀 **Modern Tech Stack:** TypeScript, Vite, ESLint, Prettier
|
||||||
• 🛡️ **Type Safety:** Strict TypeScript configuration with comprehensive UserScript API definitions
|
- 🛡️ **Type Safety:** Strict TypeScript configuration with comprehensive UserScript API definitions
|
||||||
• 🔧 **Development Tools:** ESLint, Prettier, automated build pipeline
|
- 🔧 **Development Tools:** ESLint, Prettier, automated build pipeline
|
||||||
• 🎯 **Environment Support:** Separate development and production configurations
|
- 🎯 **Environment Support:** Separate development and production configurations
|
||||||
• 📦 **Modular Architecture:** Component system with reusable utilities
|
- 📦 **Modular Architecture:** Component system with reusable utilities
|
||||||
• 💾 **Storage Management:** Type-safe wrapper for GM_setValue/GM_getValue
|
- 💾 **Storage Management:** Type-safe wrapper for GM_setValue/GM_getValue
|
||||||
• 🛠️ **Build System:** Optimized Vite configuration with automatic header generation
|
- 🛠️ **Build System:** Optimized Vite configuration with automatic header generation
|
||||||
• 🎨 **DOM Utilities:** Helper functions for element manipulation and waiting
|
- 🎨 **DOM Utilities:** Helper functions for element manipulation and waiting
|
||||||
• 🔒 **Error Handling:** Comprehensive error boundary system
|
- 🔒 **Error Handling:** Comprehensive error boundary system
|
||||||
• ⚡ **Event System:** Type-safe event emitter for module communication
|
- ⚡ **Event System:** Type-safe event emitter for module communication
|
||||||
• 📱 **Mobile Support:** Touch-optimized interface with mobile browser detection
|
- 📱 **Mobile Support:** Touch-optimized interface with mobile browser detection
|
||||||
• 🤏 **Touch Gestures:** Built-in touch event handling and gesture recognition
|
- 🤏 **Touch Gestures:** Built-in touch event handling and gesture recognition
|
||||||
• 📲 **Responsive Design:** Mobile-first CSS with safe area support for notched devices
|
- 📲 **Responsive Design:** Mobile-first CSS with safe area support for notched devices
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -64,9 +80,9 @@ src/
|
||||||
|
|
||||||
tools/
|
tools/
|
||||||
├── userScriptHeader.ts # UserScript header generator
|
├── userScriptHeader.ts # UserScript header generator
|
||||||
└── userScriptHeader.js # Compiled header generator
|
|
||||||
|
|
||||||
assets/ # Icons and static resources
|
assets/ # Icons and static resources
|
||||||
|
└── icon.afdesign
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
@ -110,6 +126,25 @@ npm run clean # Clean dist folder
|
||||||
npm run type-check # TypeScript type checking
|
npm run type-check # TypeScript type checking
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Build Optimization
|
||||||
|
|
||||||
|
The template features advanced build optimization for production:
|
||||||
|
|
||||||
|
| Build Type | File Size | Compressed | Features |
|
||||||
|
| --------------- | --------- | ---------- | -------------------------------------- |
|
||||||
|
| **Development** | ~115 KB | ~30 KB | Source maps, debug info, readable code |
|
||||||
|
| **Production** | ~25 KB | ~6 KB | Minified, tree-shaken, optimized |
|
||||||
|
|
||||||
|
**Production optimizations include:**
|
||||||
|
|
||||||
|
- ⚡ **Terser minification** with aggressive compression settings
|
||||||
|
- 🌳 **Tree-shaking** to remove unused code
|
||||||
|
- 🎯 **Dead code elimination** for **DEV** blocks
|
||||||
|
- 📦 **Module inlining** for single-file output
|
||||||
|
- 🔧 **Property mangling** for smaller variable names
|
||||||
|
- 🚀 **ES2020 target** for modern JavaScript features
|
||||||
|
- 💾 **GZIP compression** reducing size by ~75%
|
||||||
|
|
||||||
### Development Workflow
|
### Development Workflow
|
||||||
|
|
||||||
1. **Configure your script** in `header.config.json`
|
1. **Configure your script** in `header.config.json`
|
||||||
|
|
@ -240,10 +275,10 @@ console.log('Portrait mode:', MobileUtils.isPortrait());
|
||||||
|
|
||||||
## UserScript Compatibility
|
## UserScript Compatibility
|
||||||
|
|
||||||
• **Tampermonkey:** Full support with all GM\_\* APIs
|
- **Tampermonkey:** Full support with all GM\_\* APIs
|
||||||
• **Greasemonkey:** Compatible with standard UserScript APIs
|
- **Greasemonkey:** Compatible with standard UserScript APIs
|
||||||
• **Violentmonkey:** Full compatibility
|
- **Violentmonkey:** Full compatibility
|
||||||
• **Safari:** Works with userscript managers
|
- **Safari:** Works with userscript managers
|
||||||
|
|
||||||
### Mobile Browser Support
|
### Mobile Browser Support
|
||||||
|
|
||||||
|
|
@ -261,11 +296,11 @@ console.log('Portrait mode:', MobileUtils.isPortrait());
|
||||||
|
|
||||||
### Mobile Features
|
### Mobile Features
|
||||||
|
|
||||||
• **Touch Gestures:** Tap, swipe, and pinch detection
|
- **Touch Gestures:** Tap, swipe, and pinch detection
|
||||||
• **Responsive Design:** Mobile-first CSS with viewport adaptation
|
- **Responsive Design:** Mobile-first CSS with viewport adaptation
|
||||||
• **Safe Area Support:** Automatic handling of notched devices
|
- **Safe Area Support:** Automatic handling of notched devices
|
||||||
• **Orientation Detection:** Portrait/landscape change handling
|
- **Orientation Detection:** Portrait/landscape change handling
|
||||||
• **Mobile-Optimized UI:** Touch-friendly buttons and menus
|
- **Mobile-Optimized UI:** Touch-friendly buttons and menus
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
@ -278,12 +313,12 @@ console.log('Portrait mode:', MobileUtils.isPortrait());
|
||||||
|
|
||||||
## Development Guidelines
|
## Development Guidelines
|
||||||
|
|
||||||
• Follow TypeScript best practices
|
- Follow TypeScript best practices
|
||||||
• Use meaningful variable and function names
|
- Use meaningful variable and function names
|
||||||
• Add proper error handling
|
- Add proper error handling
|
||||||
• Write self-documenting code
|
- Write self-documenting code
|
||||||
• Follow the established project structure
|
- Follow the established project structure
|
||||||
• Run `npm run validate` before committing
|
- Run `npm run validate` before committing
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
@ -293,23 +328,9 @@ This project is licensed under the [MIT License](https://opensource.org/licenses
|
||||||
|
|
||||||
**_Jonas Pfalzgraf_**
|
**_Jonas Pfalzgraf_**
|
||||||
|
|
||||||
• Email: [info@josunlp.de](mailto:info@josunlp.de)
|
- Email: [info@josunlp.de](mailto:info@josunlp.de)
|
||||||
• GitHub: [@JosunLP](https://github.com/JosunLP)
|
- GitHub: [@JosunLP](https://github.com/JosunLP)
|
||||||
|
- Website: [josunlp.de](https://josunlp.de)
|
||||||
## Changelog
|
|
||||||
|
|
||||||
### v0.0.1 (Current)
|
|
||||||
|
|
||||||
• ✨ Modern TypeScript setup with strict type checking
|
|
||||||
• 🛡️ Comprehensive UserScript API definitions
|
|
||||||
• 🎨 Modular architecture with utilities and components
|
|
||||||
• 🔧 ESLint and Prettier configuration
|
|
||||||
• 📦 Optimized Vite build system
|
|
||||||
• 🚀 Environment-based configuration
|
|
||||||
• 💾 Type-safe storage management
|
|
||||||
• 🎯 Event-driven module system
|
|
||||||
• ⚡ DOM manipulation utilities
|
|
||||||
• 🛠️ Automated header generation
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"dev:header": "npm run build-userScriptHeader",
|
"dev:header": "npm run build-userScriptHeader",
|
||||||
"dev:build": "vite build --mode development && npm run build-userScriptHeader",
|
"dev:build": "vite build --mode development && npm run build-userScriptHeader",
|
||||||
"build": "vite build && npm run build-userScriptHeader",
|
"build": "vite build && npm run build-userScriptHeader",
|
||||||
"build:prod": "vite build --mode production && npm run build-userScriptHeader",
|
"build:prod": "npm run clean && vite build --mode production && npm run build-userScriptHeader",
|
||||||
"build-tooling": "tsc ./tools/userScriptHeader.ts --resolveJsonModule --esModuleInterop",
|
"build-tooling": "tsc ./tools/userScriptHeader.ts --resolveJsonModule --esModuleInterop",
|
||||||
"build-userScriptHeader": "npm run build-tooling && node ./tools/userScriptHeader.js",
|
"build-userScriptHeader": "npm run build-tooling && node ./tools/userScriptHeader.js",
|
||||||
"validate": "npm run type-check && npm run lint",
|
"validate": "npm run type-check && npm run lint",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ class App extends EventEmitter<AppEvents> {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
// Development-only debug information
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('🔧 UserScript starting in development mode');
|
||||||
|
console.log('📦 Version:', __VERSION__);
|
||||||
|
console.log('🕐 Build time:', __BUILD_TIME__);
|
||||||
|
}
|
||||||
|
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ interface ExampleModuleEvents {
|
||||||
export class ExampleModule extends EventEmitter<ExampleModuleEvents> {
|
export class ExampleModule extends EventEmitter<ExampleModuleEvents> {
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
private actionCount = 0;
|
private actionCount = 0;
|
||||||
|
private lastActionTime = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -36,6 +37,7 @@ export class ExampleModule extends EventEmitter<ExampleModuleEvents> {
|
||||||
|
|
||||||
// Load persistent data
|
// Load persistent data
|
||||||
this.actionCount = Storage.get<number>('exampleModule.actionCount', 0) || 0;
|
this.actionCount = Storage.get<number>('exampleModule.actionCount', 0) || 0;
|
||||||
|
this.lastActionTime = Storage.get<number>('exampleModule.lastActionTime', 0) || 0;
|
||||||
|
|
||||||
// Wait for required DOM elements (example)
|
// Wait for required DOM elements (example)
|
||||||
await this.waitForPageElements();
|
await this.waitForPageElements();
|
||||||
|
|
@ -164,9 +166,11 @@ export class ExampleModule extends EventEmitter<ExampleModuleEvents> {
|
||||||
public performAction(trigger: string): void {
|
public performAction(trigger: string): void {
|
||||||
this.actionCount++;
|
this.actionCount++;
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
this.lastActionTime = timestamp;
|
||||||
|
|
||||||
// Store the updated count
|
// Store the updated count and last action time
|
||||||
Storage.set('exampleModule.actionCount', this.actionCount);
|
Storage.set('exampleModule.actionCount', this.actionCount);
|
||||||
|
Storage.set('exampleModule.lastActionTime', timestamp);
|
||||||
|
|
||||||
// Emit event
|
// Emit event
|
||||||
this.emit('actionPerformed', { action: trigger, timestamp });
|
this.emit('actionPerformed', { action: trigger, timestamp });
|
||||||
|
|
@ -205,7 +209,7 @@ export class ExampleModule extends EventEmitter<ExampleModuleEvents> {
|
||||||
const stats = {
|
const stats = {
|
||||||
initialized: this.isInitialized,
|
initialized: this.isInitialized,
|
||||||
actionCount: this.actionCount,
|
actionCount: this.actionCount,
|
||||||
lastAction: Storage.get<number>('exampleModule.lastActionTime', 0),
|
lastAction: this.lastActionTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
const message = [
|
const message = [
|
||||||
|
|
@ -230,6 +234,7 @@ export class ExampleModule extends EventEmitter<ExampleModuleEvents> {
|
||||||
Storage.remove('exampleModule.actionCount');
|
Storage.remove('exampleModule.actionCount');
|
||||||
Storage.remove('exampleModule.lastActionTime');
|
Storage.remove('exampleModule.lastActionTime');
|
||||||
this.actionCount = 0;
|
this.actionCount = 0;
|
||||||
|
this.lastActionTime = 0;
|
||||||
console.log('🧹 Example module data reset');
|
console.log('🧹 Example module data reset');
|
||||||
this.showNotification('Module data reset!');
|
this.showNotification('Module data reset!');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
src/types/userscript.d.ts
vendored
9
src/types/userscript.d.ts
vendored
|
|
@ -94,6 +94,15 @@ declare global {
|
||||||
* UnsafeWindow for accessing page's global scope
|
* UnsafeWindow for accessing page's global scope
|
||||||
*/
|
*/
|
||||||
const unsafeWindow: Window & typeof globalThis;
|
const unsafeWindow: Window & typeof globalThis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build-time constants injected by Vite
|
||||||
|
*/
|
||||||
|
const __DEV__: boolean;
|
||||||
|
const __VERSION__: string;
|
||||||
|
const __DEBUG__: boolean;
|
||||||
|
const __USERSCRIPT__: boolean;
|
||||||
|
const __BUILD_TIME__: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
|
||||||
129
vite.config.ts
129
vite.config.ts
|
|
@ -1,7 +1,7 @@
|
||||||
import { resolve } from "path";
|
import { resolve } from 'path';
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from 'vite';
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
import pkgjsn from "./package.json";
|
import pkgjsn from './package.json';
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const isDev = mode === 'development';
|
const isDev = mode === 'development';
|
||||||
|
|
@ -9,18 +9,106 @@ export default defineConfig(({ mode }) => {
|
||||||
return {
|
return {
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: resolve(__dirname, "src/index.ts"),
|
input: resolve(__dirname, 'src/index.ts'),
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: `${pkgjsn.name}${isDev ? '.dev' : ''}.user.js`,
|
entryFileNames: `${pkgjsn.name}${isDev ? '.dev' : ''}.user.js`,
|
||||||
dir: resolve(__dirname, "dist"),
|
dir: resolve(__dirname, 'dist'),
|
||||||
|
// Disable code splitting - everything in one file for UserScript
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
manualChunks: undefined,
|
||||||
|
// Optimize output format
|
||||||
|
format: 'iife',
|
||||||
|
// Remove unnecessary comments in production
|
||||||
|
banner: isDev ? undefined : '',
|
||||||
|
footer: isDev ? undefined : '',
|
||||||
|
},
|
||||||
|
// Prevent any external dependencies
|
||||||
|
external: [],
|
||||||
|
// Tree-shaking optimizations
|
||||||
|
treeshake: {
|
||||||
|
moduleSideEffects: false,
|
||||||
|
propertyReadSideEffects: false,
|
||||||
|
unknownGlobalSideEffects: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sourcemap: isDev ? "inline" : false,
|
sourcemap: isDev ? 'inline' : false,
|
||||||
minify: isDev ? false : 'terser',
|
minify: isDev ? false : 'terser',
|
||||||
|
// Enhanced Terser options for maximum compression
|
||||||
|
terserOptions: isDev
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
compress: {
|
||||||
|
drop_console: false, // Keep console for UserScript debugging
|
||||||
|
drop_debugger: true,
|
||||||
|
pure_funcs: ['console.debug'],
|
||||||
|
passes: 2,
|
||||||
|
unsafe: true,
|
||||||
|
unsafe_arrows: true,
|
||||||
|
unsafe_comps: true,
|
||||||
|
unsafe_math: true,
|
||||||
|
unsafe_methods: true,
|
||||||
|
unsafe_proto: true,
|
||||||
|
unsafe_regexp: true,
|
||||||
|
unsafe_undefined: true,
|
||||||
|
hoist_funs: true,
|
||||||
|
hoist_props: true,
|
||||||
|
hoist_vars: false,
|
||||||
|
if_return: true,
|
||||||
|
join_vars: true,
|
||||||
|
sequences: true,
|
||||||
|
side_effects: true,
|
||||||
|
switches: true,
|
||||||
|
typeofs: true,
|
||||||
|
booleans: true,
|
||||||
|
collapse_vars: true,
|
||||||
|
comparisons: true,
|
||||||
|
computed_props: true,
|
||||||
|
conditionals: true,
|
||||||
|
dead_code: true,
|
||||||
|
directives: true,
|
||||||
|
evaluate: true,
|
||||||
|
expression: false,
|
||||||
|
global_defs: {},
|
||||||
|
keep_fargs: false,
|
||||||
|
keep_infinity: false,
|
||||||
|
loops: true,
|
||||||
|
negate_iife: true,
|
||||||
|
properties: true,
|
||||||
|
reduce_funcs: true,
|
||||||
|
reduce_vars: true,
|
||||||
|
toplevel: true,
|
||||||
|
unused: true,
|
||||||
|
},
|
||||||
|
mangle: {
|
||||||
|
toplevel: true,
|
||||||
|
safari10: false,
|
||||||
|
properties: {
|
||||||
|
regex: /^_/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
comments: false,
|
||||||
|
beautify: false,
|
||||||
|
},
|
||||||
|
ecma: 2020,
|
||||||
|
toplevel: true,
|
||||||
|
safari10: false,
|
||||||
|
ie8: false,
|
||||||
|
},
|
||||||
|
// Ensure all assets are inlined
|
||||||
|
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
|
||||||
|
// Disable CSS code splitting
|
||||||
|
cssCodeSplit: false,
|
||||||
|
// Target modern browsers for better optimization
|
||||||
|
target: ['es2020', 'chrome80', 'firefox78', 'safari14'],
|
||||||
|
// Report compressed file sizes
|
||||||
|
reportCompressedSize: true,
|
||||||
|
// Chunk size warnings
|
||||||
|
chunkSizeWarningLimit: 500,
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
plugins: [tsconfigPaths()],
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: [".tsx", ".ts", ".js"],
|
extensions: ['.tsx', '.ts', '.js'],
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'),
|
'@': resolve(__dirname, 'src'),
|
||||||
},
|
},
|
||||||
|
|
@ -28,6 +116,31 @@ export default defineConfig(({ mode }) => {
|
||||||
define: {
|
define: {
|
||||||
__DEV__: isDev,
|
__DEV__: isDev,
|
||||||
__VERSION__: JSON.stringify(pkgjsn.version),
|
__VERSION__: JSON.stringify(pkgjsn.version),
|
||||||
|
// Production optimizations
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
|
||||||
|
// Remove debug code in production
|
||||||
|
__DEBUG__: isDev,
|
||||||
|
// UserScript environment flags
|
||||||
|
__USERSCRIPT__: true,
|
||||||
|
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
|
||||||
|
},
|
||||||
|
// Optimize dependencies
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [],
|
||||||
|
exclude: [],
|
||||||
|
},
|
||||||
|
// Enable esbuild optimizations
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2020',
|
||||||
|
legalComments: 'none',
|
||||||
|
...(isDev
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
drop: ['debugger'],
|
||||||
|
minifyIdentifiers: true,
|
||||||
|
minifySyntax: true,
|
||||||
|
minifyWhitespace: true,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue