feat: initial mvp
This commit is contained in:
commit
f21a53f088
84 changed files with 22404 additions and 0 deletions
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?
|
28
README.md
Normal file
28
README.md
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install # or pnpm install or yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm run dev`
|
||||||
|
|
||||||
|
Runs the app in the development mode.<br>
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `dist` folder.<br>
|
||||||
|
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.<br>
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import antfu from '@antfu/eslint-config'
|
||||||
|
import tailwind from 'eslint-plugin-tailwindcss'
|
||||||
|
|
||||||
|
export default antfu({
|
||||||
|
ignores: [
|
||||||
|
'src/components/Editor/utils/*.json',
|
||||||
|
'vendor',
|
||||||
|
],
|
||||||
|
typescript: true,
|
||||||
|
solid: true,
|
||||||
|
yaml: false,
|
||||||
|
rules: {
|
||||||
|
'style/multiline-ternary': 'off',
|
||||||
|
'curly': ['error', 'multi-line'],
|
||||||
|
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||||
|
'n/prefer-global/buffer': 'off',
|
||||||
|
'style/quotes': ['error', 'single', { avoidEscape: true }],
|
||||||
|
'antfu/if-newline': 'off',
|
||||||
|
'import/no-relative-packages': 'error',
|
||||||
|
'style/max-statements-per-line': ['error', { max: 2 }],
|
||||||
|
'ts/no-redeclare': 'off',
|
||||||
|
'unused-imports/no-unused-imports': 'error',
|
||||||
|
'ts/no-empty-object-type': 'off',
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
tw: tailwind,
|
||||||
|
},
|
||||||
|
}, tailwind.configs['flat/recommended'])
|
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/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>@mtcute/repl</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
68
package.json
Normal file
68
package.json
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"name": "mtcute-repl",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@9.5.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@badrap/valita": "^0.4.2",
|
||||||
|
"@corvu/otp-field": "^0.1.4",
|
||||||
|
"@corvu/resizable": "^0.2.3",
|
||||||
|
"@fuman/fetch": "^0.0.8",
|
||||||
|
"@fuman/io": "0.0.8",
|
||||||
|
"@fuman/utils": "0.0.4",
|
||||||
|
"@kobalte/core": "^0.13.7",
|
||||||
|
"@mtcute/convert": "^0.19.4",
|
||||||
|
"@mtcute/web": "^0.19.5",
|
||||||
|
"@nanostores/persistent": "^0.10.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"filesize": "^10.1.6",
|
||||||
|
"idb": "^8.0.1",
|
||||||
|
"lucide-solid": "^0.445.0",
|
||||||
|
"memfs": "^4.17.0",
|
||||||
|
"monaco-editor": "0.52.0",
|
||||||
|
"monaco-editor-core": "0.52.0",
|
||||||
|
"monaco-editor-textmate": "^4.0.0",
|
||||||
|
"monaco-textmate": "^3.0.1",
|
||||||
|
"nanoid": "^5.0.9",
|
||||||
|
"nanostores": "^0.11.3",
|
||||||
|
"onigasm": "^2.2.5",
|
||||||
|
"semver": "^7.6.3",
|
||||||
|
"solid-icons": "^1.1.0",
|
||||||
|
"solid-js": "^1.9.4",
|
||||||
|
"solid-transition-group": "^0.2.3",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"ts-blank-space": "^0.4.4",
|
||||||
|
"uqr": "^0.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@antfu/eslint-config": "^3.13.0",
|
||||||
|
"@catppuccin/vscode": "^3.16.0",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/semver": "^7.5.8",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint-plugin-solid": "^0.14.5",
|
||||||
|
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||||
|
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
||||||
|
"plist2": "^1.1.4",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^5.4.11",
|
||||||
|
"vite-plugin-solid": "^2.11.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"patchedDependencies": {
|
||||||
|
"vite-plugin-externalize-dependencies@1.0.1": "patches/vite-plugin-externalize-dependencies@1.0.1.patch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
patches/vite-plugin-externalize-dependencies@1.0.1.patch
Normal file
16
patches/vite-plugin-externalize-dependencies@1.0.1.patch
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
diff --git a/dist/index.js b/dist/index.js
|
||||||
|
index 4b5eff9ae46b5fa0af75b16ef21f5b93142a7251..bc1651dfd82210b69ec9b5709c5f6a6320ca31b0 100644
|
||||||
|
--- a/dist/index.js
|
||||||
|
+++ b/dist/index.js
|
||||||
|
@@ -14,9 +14,9 @@ const r = /* @__PURE__ */ new Set(), s = (n, e) => e.some((t) => typeof t == "st
|
||||||
|
if (r.size === 0)
|
||||||
|
return e;
|
||||||
|
const t = "@id/", u = new RegExp(
|
||||||
|
- `${n}${t}(${[...r].join(
|
||||||
|
+ `${n}${t}(${[...r].map(it => it.replace(/\?/g, "\\?")).join(
|
||||||
|
"|"
|
||||||
|
- )})`,
|
||||||
|
+ )})(?:\\?external)?`,
|
||||||
|
"g"
|
||||||
|
);
|
||||||
|
return u.test(e) ? e.replace(
|
5815
pnpm-lock.yaml
Normal file
5815
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
12
scripts/generate-grammars.ts
Normal file
12
scripts/generate-grammars.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { writeFile } from 'node:fs/promises'
|
||||||
|
import { ffetchBase as ffetch } from '@fuman/fetch'
|
||||||
|
import { plist2js } from 'plist2'
|
||||||
|
|
||||||
|
const plist = await ffetch('https://raw.githubusercontent.com/microsoft/TypeScript-TmLanguage/refs/heads/master/TypeScript.tmLanguage').text()
|
||||||
|
|
||||||
|
const grammar = plist2js(plist)
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
new URL('../src/components/Editor/utils/typescript.tmLanguage.json', import.meta.url),
|
||||||
|
JSON.stringify(grammar, null, 2),
|
||||||
|
)
|
17
scripts/generate-themes.ts
Normal file
17
scripts/generate-themes.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import latte from '@catppuccin/vscode/themes/latte.json' with { type: 'json' }
|
||||||
|
import mocha from '@catppuccin/vscode/themes/mocha.json' with { type: 'json' }
|
||||||
|
|
||||||
|
import { convertTheme } from 'monaco-vscode-textmate-theme-converter'
|
||||||
|
|
||||||
|
const OUT_DIR = fileURLToPath(new URL('./../src/components/Editor/utils', import.meta.url))
|
||||||
|
|
||||||
|
const latteConverted = convertTheme(latte as any)
|
||||||
|
latteConverted.colors['editor.background'] = '#ffffff'
|
||||||
|
latteConverted.colors['editorGutter.background'] = '#ffffff'
|
||||||
|
|
||||||
|
writeFileSync(join(OUT_DIR, 'latte.json'), JSON.stringify(latteConverted, null, 2))
|
||||||
|
writeFileSync(join(OUT_DIR, 'mocha.json'), JSON.stringify(convertTheme(mocha as any), null, 2))
|
193
scripts/vite-plugin-externalize-dependencies.ts
Normal file
193
scripts/vite-plugin-externalize-dependencies.ts
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
// based on https://github.com/MilanKovacic/vite-plugin-externalize-dependencies/blob/main/src/index.ts
|
||||||
|
// but modified to support `?external` imports
|
||||||
|
|
||||||
|
import type { Plugin as EsbuildPlugin, OnResolveArgs, PluginBuild } from 'esbuild'
|
||||||
|
import type { Plugin, ResolvedConfig, UserConfig } from 'vite'
|
||||||
|
|
||||||
|
type ExternalCriteria = string | RegExp | ((id: string) => boolean)
|
||||||
|
|
||||||
|
interface ModulePrefixTransformPluginOptions {
|
||||||
|
/** The base path of the vite configuration */
|
||||||
|
base: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginOptions {
|
||||||
|
externals: ExternalCriteria[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedExternals = new Set<string>()
|
||||||
|
|
||||||
|
function isExternal(id: string, externals: ExternalCriteria[]): boolean {
|
||||||
|
if (id.endsWith('?external')) return true
|
||||||
|
|
||||||
|
return externals.some((external) => {
|
||||||
|
if (typeof external === 'string') {
|
||||||
|
return id === external || id.startsWith(`${external}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (external instanceof RegExp) {
|
||||||
|
return external.test(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof external === 'function') {
|
||||||
|
return external(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a plugin for esbuild to externalize specific modules.
|
||||||
|
* esbuild is used by Vite during development.
|
||||||
|
* This plugin is injected into optimizeDeps.esbuildOptions.plugins, and runs during the dependency scanning / optimization phase.
|
||||||
|
*
|
||||||
|
* @param options - Plugin options
|
||||||
|
*
|
||||||
|
* @returns The esbuild plugin
|
||||||
|
*/
|
||||||
|
function esbuildPluginExternalize(externals: ExternalCriteria[]): EsbuildPlugin {
|
||||||
|
return {
|
||||||
|
name: 'externalize',
|
||||||
|
setup(build: PluginBuild) {
|
||||||
|
build.onResolve({ filter: /.*/ }, (args: OnResolveArgs) => {
|
||||||
|
if (
|
||||||
|
isExternal(args.path, externals)
|
||||||
|
&& args.kind === 'import-statement'
|
||||||
|
) {
|
||||||
|
resolvedExternals.add(args.path)
|
||||||
|
return {
|
||||||
|
path: args.path,
|
||||||
|
external: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supresses the following error:
|
||||||
|
// The entry point [moduleName] cannot be marked as external
|
||||||
|
if (isExternal(args.path, externals) && args.kind === 'entry-point') {
|
||||||
|
resolvedExternals.add(args.path)
|
||||||
|
return { path: args.path, namespace: 'externalized-modules' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
// Supresses the following error:
|
||||||
|
// Do not know how to load path: [namespace:moduleName]
|
||||||
|
build.onLoad({ filter: /.*/ }, (args) => {
|
||||||
|
if (isExternal(args.path, externals)) {
|
||||||
|
return { contents: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a plugin to remove prefix from imports injected by Vite.
|
||||||
|
* If module is externalized, Vite will prefix imports with "/\@id/" during development.
|
||||||
|
*
|
||||||
|
* @param options - The plugin options
|
||||||
|
*
|
||||||
|
* @returns Vite plugin to remove prefix from imports
|
||||||
|
*/
|
||||||
|
function modulePrefixTransform({
|
||||||
|
base,
|
||||||
|
}: ModulePrefixTransformPluginOptions): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'vite-plugin-remove-prefix',
|
||||||
|
transform: (code: string): string => {
|
||||||
|
// Verify if there are any external modules resolved to avoid having /\/@id\/()/g regex
|
||||||
|
if (resolvedExternals.size === 0) return code
|
||||||
|
|
||||||
|
const viteImportAnalysisModulePrefix = '@id/'
|
||||||
|
const prefixedImportRegex = new RegExp(
|
||||||
|
`${base}${viteImportAnalysisModulePrefix}(${
|
||||||
|
[...resolvedExternals]
|
||||||
|
.map(it => it.replace(/\?/g, '\\?'))
|
||||||
|
.join('|')})`,
|
||||||
|
'g',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (prefixedImportRegex.test(code)) {
|
||||||
|
return code.replace(
|
||||||
|
prefixedImportRegex,
|
||||||
|
(_: string, externalName: string) => externalName.replace(/\?external$/g, ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Vite plugin to externalize specific modules.
|
||||||
|
* This plugin is only used during development.
|
||||||
|
* To externalize modules in production, configure build.rollupOptions.external.
|
||||||
|
*
|
||||||
|
* @param externals - The list of modules to externalize.
|
||||||
|
*
|
||||||
|
* @returns The Vite plugin.
|
||||||
|
*/
|
||||||
|
function vitePluginExternalize(options: PluginOptions): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'vite-plugin-externalize',
|
||||||
|
enforce: 'pre',
|
||||||
|
apply: 'serve',
|
||||||
|
config: (config: UserConfig): Omit<UserConfig, 'plugins'> | null | void => {
|
||||||
|
config.optimizeDeps ??= {}
|
||||||
|
config.optimizeDeps.esbuildOptions ??= {}
|
||||||
|
config.optimizeDeps.esbuildOptions.plugins ??= []
|
||||||
|
|
||||||
|
// Prevent the plugin from being inserted multiple times
|
||||||
|
const pluginName = 'externalize'
|
||||||
|
const isPluginAdded = config.optimizeDeps.esbuildOptions.plugins.some(
|
||||||
|
(plugin: any) => plugin.name === pluginName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isPluginAdded) {
|
||||||
|
config.optimizeDeps.esbuildOptions.plugins.push(
|
||||||
|
esbuildPluginExternalize(options.externals) as any,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
configResolved: (resolvedConfig: ResolvedConfig) => {
|
||||||
|
// Plugins are read-only, and should not be modified,
|
||||||
|
// however modulePrefixTransformPlugin MUST run after vite:import-analysis (which adds the prefix to imports)
|
||||||
|
|
||||||
|
(resolvedConfig.plugins as Plugin[]).push(
|
||||||
|
modulePrefixTransform({ base: resolvedConfig.base ?? '/' }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Supresses the following warning:
|
||||||
|
// Failed to resolve import [dependency] from [sourceFile]. Does the file exist?
|
||||||
|
resolveId: (id: string) => {
|
||||||
|
if (resolvedExternals.has(id)) {
|
||||||
|
return { id, external: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// During subsequent runs after the dependency optimization is completed, esbuild plugin might not be called.
|
||||||
|
// This will cause the resolvedExternals to be empty, and the plugin will not be able to resolve the external modules, which is why a direct check is required.
|
||||||
|
if (isExternal(id, options.externals)) {
|
||||||
|
resolvedExternals.add(id)
|
||||||
|
return { id, external: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
// Supresses the following warning:
|
||||||
|
// The following dependencies are imported but could not be resolved: [dependency] (imported by [sourceFile])
|
||||||
|
load: (id: string) => {
|
||||||
|
if (resolvedExternals.has(id)) {
|
||||||
|
return { code: 'export default {};' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Justification: Vite plugins are expected to provide a default export
|
||||||
|
|
||||||
|
export default vitePluginExternalize
|
65
src/App.tsx
Normal file
65
src/App.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import Resizable from '@corvu/resizable'
|
||||||
|
import { createSignal, lazy } from 'solid-js'
|
||||||
|
|
||||||
|
import { EditorTabs } from './components/editor/EditorTabs.tsx'
|
||||||
|
import { NavbarMenu } from './components/nav/NavbarMenu.tsx'
|
||||||
|
import { Runner } from './components/runner/Runner.tsx'
|
||||||
|
import { SettingsDialog, type SettingsTab } from './components/settings/Settings.tsx'
|
||||||
|
import { Updater } from './components/Updater.tsx'
|
||||||
|
import { ResizableHandle, ResizablePanel } from './lib/components/ui/resizable.tsx'
|
||||||
|
|
||||||
|
const Editor = lazy(() => import('./components/editor/Editor.tsx'))
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [versions, setVersions] = createSignal<Record<string, string> | undefined>(undefined)
|
||||||
|
const [showSettings, setShowSettings] = createSignal(false)
|
||||||
|
const [settingsTab, setSettingsTab] = createSignal<SettingsTab>('accounts')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex h-screen w-screen flex-col overflow-hidden">
|
||||||
|
<nav class="relative flex h-auto w-full shrink-0 flex-row items-center overflow-hidden px-4 py-2">
|
||||||
|
<h1 class="font-mono text-base">
|
||||||
|
@mtcute/
|
||||||
|
<b>playground</b>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<NavbarMenu
|
||||||
|
onShowAccounts={() => {
|
||||||
|
setShowSettings(true)
|
||||||
|
setSettingsTab('accounts')
|
||||||
|
}}
|
||||||
|
onShowSettings={() => {
|
||||||
|
setShowSettings(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
<div class="h-px shrink-0 bg-border" />
|
||||||
|
{versions() === undefined ? (
|
||||||
|
<Updater
|
||||||
|
onComplete={setVersions}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Resizable orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]">
|
||||||
|
<ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}>
|
||||||
|
<EditorTabs />
|
||||||
|
<Editor class="size-full" />
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel
|
||||||
|
class="flex max-h-full flex-col overflow-hidden"
|
||||||
|
minSize={0.2}
|
||||||
|
>
|
||||||
|
<Runner />
|
||||||
|
</ResizablePanel>
|
||||||
|
</Resizable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsDialog
|
||||||
|
show={showSettings()}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
tab={settingsTab()}
|
||||||
|
onTabChange={setSettingsTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
114
src/app.css
Normal file
114
src/app.css
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--info: 204 94% 94%;
|
||||||
|
--info-foreground: 199 89% 48%;
|
||||||
|
|
||||||
|
--success: 149 80% 90%;
|
||||||
|
--success-foreground: 160 84% 39%;
|
||||||
|
|
||||||
|
--warning: 48 96% 89%;
|
||||||
|
--warning-foreground: 25 95% 53%;
|
||||||
|
|
||||||
|
--error: 0 93% 94%;
|
||||||
|
--error-foreground: 0 84% 60%;
|
||||||
|
|
||||||
|
--ring: 240 5.9% 10%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark,
|
||||||
|
[data-kb-theme="dark"] {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--info: 204 94% 94%;
|
||||||
|
--info-foreground: 199 89% 48%;
|
||||||
|
|
||||||
|
--success: 149 80% 90%;
|
||||||
|
--success-foreground: 160 84% 39%;
|
||||||
|
|
||||||
|
--warning: 48 96% 89%;
|
||||||
|
--warning-foreground: 25 95% 53%;
|
||||||
|
|
||||||
|
--error: 0 93% 94%;
|
||||||
|
--error-foreground: 0 84% 60%;
|
||||||
|
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings:
|
||||||
|
"rlig" 1,
|
||||||
|
"calt" 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
@apply px-4;
|
||||||
|
}
|
||||||
|
}
|
14
src/components/AccountAvatar.tsx
Normal file
14
src/components/AccountAvatar.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import type { TelegramAccount } from '../store/accounts.ts'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../lib/components/ui/avatar.tsx'
|
||||||
|
|
||||||
|
export function AccountAvatar(props: {
|
||||||
|
class?: string
|
||||||
|
account: TelegramAccount
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Avatar class={props.class}>
|
||||||
|
<AvatarImage src={`/sw/avatar/${props.account.id}/avatar.jpg`} />
|
||||||
|
<AvatarFallback>{makeAvatarFallbackText(props.account.name)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
99
src/components/Updater.tsx
Normal file
99
src/components/Updater.tsx
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import { asyncPool } from '@fuman/utils'
|
||||||
|
|
||||||
|
import { filesize } from 'filesize'
|
||||||
|
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||||
|
import { Spinner } from '../lib/components/ui/spinner.tsx'
|
||||||
|
import { downloadNpmPackage, getLatestVersions, getPackagesToDownload } from '../lib/vfs/downloader'
|
||||||
|
import { VfsStorage } from '../lib/vfs/storage'
|
||||||
|
import { swClearCache } from '../sw/client.ts'
|
||||||
|
|
||||||
|
export interface UpdaterProps {
|
||||||
|
onComplete: (versions: Record<string, string>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Updater(props: UpdaterProps) {
|
||||||
|
const [downloadedBytes, setDownloadedBytes] = createSignal(0)
|
||||||
|
const [totalBytes, setTotalBytes] = createSignal(Infinity)
|
||||||
|
const [step, setStep] = createSignal('Idle')
|
||||||
|
|
||||||
|
let abortController: AbortController | undefined
|
||||||
|
|
||||||
|
async function runUpdater() {
|
||||||
|
if (abortController) abortController.abort()
|
||||||
|
|
||||||
|
abortController = new AbortController()
|
||||||
|
const signal = abortController.signal
|
||||||
|
|
||||||
|
setStep('Checking for updates...')
|
||||||
|
const vfs = await VfsStorage.create()
|
||||||
|
const latestVersions = await getLatestVersions()
|
||||||
|
const versions = await getPackagesToDownload(latestVersions, vfs)
|
||||||
|
|
||||||
|
if (Object.keys(versions).length === 0) {
|
||||||
|
props.onComplete(latestVersions)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(versions)
|
||||||
|
|
||||||
|
setStep('Downloading...')
|
||||||
|
await swClearCache()
|
||||||
|
await asyncPool(entries, async ([lib, version]) => {
|
||||||
|
let isFirst = true
|
||||||
|
let prevDownloaded = 0
|
||||||
|
|
||||||
|
function onProgress(downloaded: number, total: number) {
|
||||||
|
if (isFirst) {
|
||||||
|
setTotalBytes(prev => prev === Infinity ? total : prev + total)
|
||||||
|
isFirst = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = downloaded - prevDownloaded
|
||||||
|
setDownloadedBytes(prev => prev + diff)
|
||||||
|
prevDownloaded = downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadNpmPackage({
|
||||||
|
packageName: lib,
|
||||||
|
version,
|
||||||
|
storage: vfs,
|
||||||
|
progress: onProgress,
|
||||||
|
filterFiles: (file) => {
|
||||||
|
if (!file.header) return true
|
||||||
|
const name = file.header.name
|
||||||
|
return !name.endsWith('.cjs') && !name.endsWith('.d.cts') && name !== 'LICENSE' && name !== 'README.md'
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
props.onComplete(latestVersions)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
runUpdater()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abortController?.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center gap-2 p-4">
|
||||||
|
<Spinner
|
||||||
|
class="size-10"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
<div class="text-center text-xs text-muted-foreground">
|
||||||
|
{step()}
|
||||||
|
{totalBytes() !== Infinity && (
|
||||||
|
<div>
|
||||||
|
{filesize(downloadedBytes())}
|
||||||
|
{' / '}
|
||||||
|
{filesize(totalBytes())}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
3
src/components/editor/Editor.css
Normal file
3
src/components/editor/Editor.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[data-monaco-root] .squiggly-error {
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
152
src/components/editor/Editor.tsx
Normal file
152
src/components/editor/Editor.tsx
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
import { editor as mEditor, Uri } from 'monaco-editor'
|
||||||
|
import { createEffect, on, onMount } from 'solid-js'
|
||||||
|
import { useColorScheme } from '../../lib/use-color-scheme'
|
||||||
|
import { VfsStorage } from '../../lib/vfs/storage.ts'
|
||||||
|
|
||||||
|
import { $activeTab, $tabs, type EditorTab } from '../../store/tabs.ts'
|
||||||
|
import { useStore } from '../../store/use-store.ts'
|
||||||
|
import { setupMonaco } from './utils/setup.ts'
|
||||||
|
import './Editor.css'
|
||||||
|
|
||||||
|
export interface EditorProps {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CODE = `
|
||||||
|
/**
|
||||||
|
* This playground comes pre-loaded with all @mtcute/* libraries,
|
||||||
|
* as well as a pre-configured Telegram client (available as \`tg\` global variable).
|
||||||
|
*
|
||||||
|
* Exports from this file will become available in the REPL.
|
||||||
|
*/
|
||||||
|
export const self = await tg.getMe()
|
||||||
|
console.log(self)
|
||||||
|
`.trimStart()
|
||||||
|
|
||||||
|
function findChangedTab(a: EditorTab[], b: EditorTab[]) {
|
||||||
|
const set = new Set(a.map(tab => tab.id))
|
||||||
|
for (const tab of b) {
|
||||||
|
if (!set.has(tab.id)) return tab
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Editor(props: EditorProps) {
|
||||||
|
const tabs = useStore($tabs)
|
||||||
|
const activeTab = useStore($activeTab)
|
||||||
|
|
||||||
|
let ref!: HTMLDivElement
|
||||||
|
let editor: mEditor.IStandaloneCodeEditor | undefined
|
||||||
|
|
||||||
|
const scheme = useColorScheme()
|
||||||
|
// const monacoTheme = () => scheme() === 'dark' ? 'ayu-dark' : 'ayu-light'
|
||||||
|
const modelsByTab = new Map<string, mEditor.ITextModel>()
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const vfs = await VfsStorage.create()
|
||||||
|
|
||||||
|
editor = mEditor.create(ref, {
|
||||||
|
model: null,
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
scrollbar: {
|
||||||
|
verticalScrollbarSize: 8,
|
||||||
|
},
|
||||||
|
lightbulb: {
|
||||||
|
enabled: 'onCode' as any,
|
||||||
|
},
|
||||||
|
quickSuggestions: {
|
||||||
|
other: true,
|
||||||
|
comments: true,
|
||||||
|
strings: true,
|
||||||
|
},
|
||||||
|
padding: { top: 8 },
|
||||||
|
acceptSuggestionOnCommitCharacter: true,
|
||||||
|
acceptSuggestionOnEnter: 'on',
|
||||||
|
accessibilitySupport: 'on',
|
||||||
|
inlayHints: {
|
||||||
|
enabled: 'on',
|
||||||
|
},
|
||||||
|
lineNumbersMinChars: 3,
|
||||||
|
// theme: monacoTheme(),
|
||||||
|
theme: 'latte', // todo
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await setupMonaco(vfs)
|
||||||
|
|
||||||
|
for (const tab of tabs()) {
|
||||||
|
const model = mEditor.createModel(tab.main ? DEFAULT_CODE : '', 'typescript', Uri.parse(`file:///${tab.id}.ts`))
|
||||||
|
modelsByTab.set(tab.id, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.setModel(modelsByTab.get(activeTab())!)
|
||||||
|
|
||||||
|
// editor.onDidChangeModelContent(() => {
|
||||||
|
// props.onCodeChange(editor?.getValue() ?? '')
|
||||||
|
// })
|
||||||
|
|
||||||
|
return () => editor?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
// createEffect(on(() => monacoTheme(), (theme) => {
|
||||||
|
// if (!editor) return
|
||||||
|
// editor.updateOptions({ theme })
|
||||||
|
// }))
|
||||||
|
|
||||||
|
createEffect(on(activeTab, (tabId) => {
|
||||||
|
if (!editor) return
|
||||||
|
const model = modelsByTab.get(tabId)
|
||||||
|
if (!model) return
|
||||||
|
editor.setModel(model)
|
||||||
|
}))
|
||||||
|
|
||||||
|
createEffect(on(tabs, (tabs, prevTabs) => {
|
||||||
|
if (!editor || !prevTabs) return
|
||||||
|
if (tabs.length === prevTabs.length) {
|
||||||
|
// verify filenames
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const oldName = prevTabs.find(prevTab => prevTab.id === tab.id)?.fileName
|
||||||
|
if (!oldName) continue // weird flex but ok
|
||||||
|
if (oldName === tab.fileName) continue
|
||||||
|
|
||||||
|
// renamed
|
||||||
|
const oldModel = modelsByTab.get(tab.id)
|
||||||
|
if (!oldModel) continue
|
||||||
|
const newModel = mEditor.createModel(oldModel.getValue(), 'typescript', Uri.parse(`file:///${tab.fileName}`))
|
||||||
|
modelsByTab.set(tab.id, newModel)
|
||||||
|
if (editor.getModel() === oldModel) {
|
||||||
|
editor.setModel(newModel)
|
||||||
|
}
|
||||||
|
oldModel.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabs.length > prevTabs.length) {
|
||||||
|
// a tab was created
|
||||||
|
const changed = findChangedTab(prevTabs, tabs)
|
||||||
|
if (!changed) return
|
||||||
|
const model = mEditor.createModel('', 'typescript', Uri.parse(`file:///${changed.fileName}`))
|
||||||
|
modelsByTab.set(changed.id, model)
|
||||||
|
editor.setModel(model)
|
||||||
|
} else {
|
||||||
|
// a tab was deleted
|
||||||
|
const changed = findChangedTab(tabs, prevTabs)
|
||||||
|
if (!changed) return
|
||||||
|
modelsByTab.delete(changed.id)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-monaco-root
|
||||||
|
class={props.class}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
187
src/components/editor/EditorTabs.tsx
Normal file
187
src/components/editor/EditorTabs.tsx
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
import type { EditorTab } from '../../store/tabs.ts'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { LucidePlus, LucideX } from 'lucide-solid'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { batch, createSignal, For } from 'solid-js'
|
||||||
|
import { Button } from '../../lib/components/ui/button.tsx'
|
||||||
|
import { Tabs, TabsIndicator, TabsList, TabsTrigger } from '../../lib/components/ui/tabs.tsx'
|
||||||
|
import { cn } from '../../lib/utils.ts'
|
||||||
|
import { $activeTab, $tabs } from '../../store/tabs.ts'
|
||||||
|
import { useStore } from '../../store/use-store.ts'
|
||||||
|
|
||||||
|
export interface EditorTabsProps {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorTabs(props: EditorTabsProps) {
|
||||||
|
const tabs = useStore($tabs)
|
||||||
|
const activeTab = useStore($activeTab)
|
||||||
|
|
||||||
|
const [renamingTabId, setRenamingTabId] = createSignal<string | undefined>(undefined)
|
||||||
|
|
||||||
|
let root!: HTMLDivElement
|
||||||
|
let indicator!: HTMLDivElement
|
||||||
|
|
||||||
|
const updateIndicator = () => {
|
||||||
|
// crutch to imperatively update the indicator width
|
||||||
|
// based on https://github.com/kobaltedev/kobalte/issues/273#issuecomment-1741962351
|
||||||
|
const selected = root.querySelector<HTMLElement>(
|
||||||
|
'[role=tab][data-selected]',
|
||||||
|
)!
|
||||||
|
|
||||||
|
indicator.style.width = `${selected.clientWidth}px`
|
||||||
|
indicator.style.transform = `translateX(${selected.offsetLeft}px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewTab = () => {
|
||||||
|
batch(() => {
|
||||||
|
const tabs_ = tabs()
|
||||||
|
const newTabId = nanoid()
|
||||||
|
$tabs.set([
|
||||||
|
...tabs_,
|
||||||
|
{
|
||||||
|
id: newTabId,
|
||||||
|
fileName: `newfile${tabs_.length}.ts`,
|
||||||
|
main: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
$activeTab.set(newTabId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTab = (tabId: string) => {
|
||||||
|
const tabs_ = tabs()
|
||||||
|
if (tabs_.length === 1) return
|
||||||
|
const nextTabs = tabs_.filter(tab => tab.id !== tabId)
|
||||||
|
$tabs.set(nextTabs)
|
||||||
|
if (tabId === activeTab()) {
|
||||||
|
$activeTab.set(nextTabs[0].id)
|
||||||
|
}
|
||||||
|
queueMicrotask(updateIndicator)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyRename = (el: HTMLDivElement) => {
|
||||||
|
let newName = el.textContent
|
||||||
|
if (!newName) return
|
||||||
|
|
||||||
|
if (!newName.endsWith('.ts')) newName += '.ts'
|
||||||
|
newName = newName.replace(/[/';!@#$%^&*()\s]/g, '')
|
||||||
|
|
||||||
|
const tabs_ = tabs()
|
||||||
|
const ourTabIdx = tabs_.findIndex(tab => tab.id === renamingTabId())
|
||||||
|
if (ourTabIdx === -1) return
|
||||||
|
|
||||||
|
$tabs.set([
|
||||||
|
...tabs_.slice(0, ourTabIdx),
|
||||||
|
{
|
||||||
|
...tabs_[ourTabIdx],
|
||||||
|
fileName: newName,
|
||||||
|
},
|
||||||
|
...tabs_.slice(ourTabIdx + 1),
|
||||||
|
])
|
||||||
|
setRenamingTabId(undefined)
|
||||||
|
setTimeout(updateIndicator, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTab = (tab: EditorTab) => {
|
||||||
|
return (
|
||||||
|
<TabsTrigger
|
||||||
|
value={tab.id}
|
||||||
|
class="flex w-fit flex-row items-center gap-1 pl-2 pr-1"
|
||||||
|
onClick={() => $activeTab.set(tab.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={clsx(
|
||||||
|
tab.id === renamingTabId() && 'cursor-text outline-none',
|
||||||
|
)}
|
||||||
|
onClick={(event: MouseEvent) => {
|
||||||
|
if (tab.id === renamingTabId()) {
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tab.id === activeTab() && !tab.main) {
|
||||||
|
setRenamingTabId(tab.id)
|
||||||
|
event.stopPropagation()
|
||||||
|
const target = event.currentTarget as HTMLDivElement
|
||||||
|
target.focus()
|
||||||
|
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(target)
|
||||||
|
// range.collapse(false)
|
||||||
|
|
||||||
|
const sel = window.getSelection()!
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
contentEditable={tab.id === renamingTabId()}
|
||||||
|
onKeyDown={(event: KeyboardEvent) => {
|
||||||
|
if (tab.id !== renamingTabId()) return
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const target = event.currentTarget as HTMLDivElement
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
applyRename(target)
|
||||||
|
target.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
// cancel rename
|
||||||
|
setRenamingTabId(undefined)
|
||||||
|
target.textContent = tab.fileName
|
||||||
|
target.blur()
|
||||||
|
window.getSelection()?.removeAllRanges()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(event: FocusEvent) => {
|
||||||
|
if (tab.id === renamingTabId()) {
|
||||||
|
applyRename(event.currentTarget as HTMLDivElement)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.fileName}
|
||||||
|
</div>
|
||||||
|
{!tab.main && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-5"
|
||||||
|
onClick={(event: MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
closeTab(tab.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LucideX size="0.75rem" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab()}
|
||||||
|
// onChange={tab => setTabs('activeTab', tab)}
|
||||||
|
class={cn('max-w-full overflow-auto', props.class)}
|
||||||
|
ref={root}
|
||||||
|
>
|
||||||
|
<TabsList class="rounded-none border-b bg-transparent px-2">
|
||||||
|
<For each={tabs()}>
|
||||||
|
{renderTab}
|
||||||
|
</For>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="ml-1 size-6 shrink-0"
|
||||||
|
onClick={createNewTab}
|
||||||
|
>
|
||||||
|
<LucidePlus size="0.75rem" />
|
||||||
|
</Button>
|
||||||
|
<TabsIndicator
|
||||||
|
variant="underline"
|
||||||
|
ref={indicator}
|
||||||
|
/>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
70
src/components/editor/utils/custom-worker.ts
Normal file
70
src/components/editor/utils/custom-worker.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import type { Diagnostic, ExportDeclaration, ModifierLike, VariableStatement } from 'typescript'
|
||||||
|
import { initialize, ts, TypeScriptWorker } from 'monaco-editor/esm/vs/language/typescript/ts.worker'
|
||||||
|
import { blankSourceFile } from 'ts-blank-space'
|
||||||
|
|
||||||
|
class CustomTypeScriptWorker extends TypeScriptWorker {
|
||||||
|
async processFile(uri: string, withExports?: boolean) {
|
||||||
|
const sourceFile = this.getLanguageService().getProgram()?.getSourceFile(uri)
|
||||||
|
if (!sourceFile) throw new Error(`File not found: ${uri}`)
|
||||||
|
|
||||||
|
const transformed = blankSourceFile(sourceFile)
|
||||||
|
const exports: string[] = []
|
||||||
|
if (withExports) {
|
||||||
|
for (const statement of sourceFile.statements) {
|
||||||
|
if (statement.kind === ts.typescript.SyntaxKind.ExportDeclaration) {
|
||||||
|
const exportDeclaration = statement as ExportDeclaration
|
||||||
|
const clause = exportDeclaration.exportClause
|
||||||
|
if (!clause || clause.kind !== ts.typescript.SyntaxKind.NamedExports) {
|
||||||
|
throw new Error('Invalid export declaration (export * is not supported)')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of clause.elements) {
|
||||||
|
if (element.kind === ts.typescript.SyntaxKind.ExportSpecifier) {
|
||||||
|
exports.push(element.name.getText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
statement.kind === ts.typescript.SyntaxKind.VariableStatement
|
||||||
|
&& statement.modifiers?.some((it: ModifierLike) => it.kind === ts.typescript.SyntaxKind.ExportKeyword)) {
|
||||||
|
for (const declaration of (statement as VariableStatement).declarationList.declarations) {
|
||||||
|
exports.push(declaration.name.getText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
transformed,
|
||||||
|
exports,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSyntacticDiagnostics(fileName: string): Promise<Diagnostic[]> {
|
||||||
|
const parent = await super.getSyntacticDiagnostics(fileName)
|
||||||
|
|
||||||
|
const sourceFile = this.getLanguageService().getProgram()?.getSourceFile(fileName)
|
||||||
|
if (!sourceFile) return parent
|
||||||
|
|
||||||
|
// there's probably a better way but ts-blank-space's own playground does this,
|
||||||
|
// and ts-blank-space is fast enough for this to not really matter (it basically just traverses the AST once)
|
||||||
|
blankSourceFile(sourceFile, (errorNode) => {
|
||||||
|
parent.push({
|
||||||
|
start: errorNode.getStart(),
|
||||||
|
length: errorNode.getWidth(),
|
||||||
|
messageText: `[ts-blank-space] Unsupported syntax: ${errorNode.getText()}`,
|
||||||
|
category: ts.typescript.DiagnosticCategory.Error,
|
||||||
|
code: 9999,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CustomTypeScriptWorker }
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
self.onmessage = () => {
|
||||||
|
initialize((ctx: any, createData: any) => {
|
||||||
|
return new CustomTypeScriptWorker(ctx, createData)
|
||||||
|
})
|
||||||
|
}
|
2128
src/components/editor/utils/latte.json
Normal file
2128
src/components/editor/utils/latte.json
Normal file
File diff suppressed because it is too large
Load diff
2128
src/components/editor/utils/mocha.json
Normal file
2128
src/components/editor/utils/mocha.json
Normal file
File diff suppressed because it is too large
Load diff
88
src/components/editor/utils/setup.ts
Normal file
88
src/components/editor/utils/setup.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import type { VfsStorage } from '../../../lib/vfs/storage.ts'
|
||||||
|
import { asNonNull, asyncPool, utf8 } from '@fuman/utils'
|
||||||
|
import { editor, languages } from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||||
|
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
||||||
|
import { wireTmGrammars } from 'monaco-editor-textmate'
|
||||||
|
import { Registry } from 'monaco-textmate'
|
||||||
|
import { loadWASM } from 'onigasm'
|
||||||
|
|
||||||
|
import onigasmWasm from 'onigasm/lib/onigasm.wasm?url'
|
||||||
|
import TypeScriptWorker from './custom-worker.ts?worker'
|
||||||
|
import latte from './latte.json'
|
||||||
|
import mocha from './mocha.json'
|
||||||
|
import typescriptTM from './typescript.tmLanguage.json'
|
||||||
|
|
||||||
|
window.MonacoEnvironment = {
|
||||||
|
getWorker: (_, label: string) => {
|
||||||
|
if (label === 'editorWorkerService') {
|
||||||
|
return new EditorWorker()
|
||||||
|
}
|
||||||
|
if (label === 'typescript') {
|
||||||
|
return new TypeScriptWorker()
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown worker: ${label}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadingWasm: Promise<void>
|
||||||
|
|
||||||
|
const registry = new Registry({
|
||||||
|
async getGrammarDefinition() {
|
||||||
|
return {
|
||||||
|
format: 'json',
|
||||||
|
content: typescriptTM,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const grammars = new Map()
|
||||||
|
grammars.set('typescript', 'source.tsx')
|
||||||
|
grammars.set('javascript', 'source.tsx')
|
||||||
|
grammars.set('css', 'source.css')
|
||||||
|
|
||||||
|
editor.defineTheme('latte', latte as any)
|
||||||
|
editor.defineTheme('mocha', mocha as any)
|
||||||
|
|
||||||
|
const compilerOptions: languages.typescript.CompilerOptions = {
|
||||||
|
strict: true,
|
||||||
|
target: languages.typescript.ScriptTarget.ESNext,
|
||||||
|
module: languages.typescript.ModuleKind.ESNext,
|
||||||
|
moduleResolution: languages.typescript.ModuleResolutionKind.NodeJs,
|
||||||
|
moduleDetection: 3, // force
|
||||||
|
jsx: languages.typescript.JsxEmit.Preserve,
|
||||||
|
allowNonTsExtensions: true,
|
||||||
|
allowImportingTsExtensions: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
languages.typescript.typescriptDefaults.setCompilerOptions(compilerOptions)
|
||||||
|
languages.typescript.javascriptDefaults.setCompilerOptions(compilerOptions)
|
||||||
|
|
||||||
|
export async function setupMonaco(vfs: VfsStorage) {
|
||||||
|
if (!loadingWasm) loadingWasm = loadWASM(onigasmWasm)
|
||||||
|
await loadingWasm
|
||||||
|
|
||||||
|
const libs = await vfs.getAvailableLibs()
|
||||||
|
const extraLibs: {
|
||||||
|
content: string
|
||||||
|
filePath?: string
|
||||||
|
}[] = []
|
||||||
|
|
||||||
|
await asyncPool(libs, async (lib) => {
|
||||||
|
const { files } = asNonNull(await vfs.readLibrary(lib))
|
||||||
|
for (const file of files) {
|
||||||
|
const { path, contents } = file
|
||||||
|
if (!path.endsWith('.d.ts')) continue
|
||||||
|
|
||||||
|
extraLibs.push({ content: utf8.decoder.decode(contents), filePath: `file:///node_modules/${lib}/${path}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
extraLibs.push({
|
||||||
|
content: 'declare const tg: import("@mtcute/web").TelegramClient',
|
||||||
|
filePath: 'file:///tg.d.ts',
|
||||||
|
})
|
||||||
|
|
||||||
|
languages.typescript.typescriptDefaults.setExtraLibs(extraLibs)
|
||||||
|
|
||||||
|
await wireTmGrammars({ languages } as any, registry, grammars)
|
||||||
|
}
|
5899
src/components/editor/utils/typescript.tmLanguage.json
Normal file
5899
src/components/editor/utils/typescript.tmLanguage.json
Normal file
File diff suppressed because one or more lines are too long
13
src/components/editor/utils/worker.d.ts
vendored
Normal file
13
src/components/editor/utils/worker.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
declare module 'monaco-editor/esm/vs/language/typescript/ts.worker' {
|
||||||
|
import type typescript from 'typescript'
|
||||||
|
|
||||||
|
export class TypeScriptWorker {
|
||||||
|
constructor(ctx: any, createData: any)
|
||||||
|
getCompilationSettings(): ts.CompilerOptions
|
||||||
|
getLanguageService(): ts.LanguageService
|
||||||
|
getSyntacticDiagnostics(fileName: string): Promise<ts.Diagnostic[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialize(callback: (ctx: any, createData: any) => TypeScriptWorker): void
|
||||||
|
export const ts: { typescript: typeof typescript }
|
||||||
|
}
|
547
src/components/login/Login.tsx
Normal file
547
src/components/login/Login.tsx
Normal file
|
@ -0,0 +1,547 @@
|
||||||
|
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
|
||||||
|
import { base64 } from '@fuman/utils'
|
||||||
|
import { tl } from '@mtcute/web'
|
||||||
|
import { checkPassword, downloadAsBuffer, resendCode, sendCode, signIn, signInQr } from '@mtcute/web/methods.js'
|
||||||
|
import { LucideChevronRight } from 'lucide-solid'
|
||||||
|
import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
|
||||||
|
import { renderSVG } from 'uqr'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../../lib/components/ui/avatar.tsx'
|
||||||
|
import { Button } from '../../lib/components/ui/button.tsx'
|
||||||
|
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../lib/components/ui/otp-field.tsx'
|
||||||
|
import { Spinner } from '../../lib/components/ui/spinner.tsx'
|
||||||
|
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
|
||||||
|
import { TransitionSlideLtr } from '../../lib/components/ui/transition.tsx'
|
||||||
|
import { cn } from '../../lib/utils.ts'
|
||||||
|
import { PhoneInput } from './PhoneInput.tsx'
|
||||||
|
|
||||||
|
export type LoginStep =
|
||||||
|
| 'qr'
|
||||||
|
| 'phone'
|
||||||
|
| 'otp'
|
||||||
|
| 'password'
|
||||||
|
| 'done'
|
||||||
|
export interface StepContext {
|
||||||
|
qr: void
|
||||||
|
phone: void
|
||||||
|
otp: {
|
||||||
|
phone: string
|
||||||
|
code: SentCode
|
||||||
|
}
|
||||||
|
password: void
|
||||||
|
done: { user: User }
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepProps<T extends LoginStep> = {
|
||||||
|
client: BaseTelegramClient
|
||||||
|
setStep: <T extends LoginStep>(step: T, data?: StepContext[T]) => void
|
||||||
|
} & (StepContext[T] extends void ? {} : { ctx: StepContext[T] })
|
||||||
|
|
||||||
|
function QrLoginStep(props: StepProps<'qr'>) {
|
||||||
|
const [qr, setQr] = createSignal('')
|
||||||
|
const [finalizing, setFinalizing] = createSignal(false)
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
signInQr(props.client, {
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
onUrlUpdated: qr => setQr(renderSVG(qr)),
|
||||||
|
onQrScanned: () => setFinalizing(true),
|
||||||
|
}).then((user) => {
|
||||||
|
props.setStep('done', { user })
|
||||||
|
}).catch((e) => {
|
||||||
|
setFinalizing(false)
|
||||||
|
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
|
||||||
|
props.setStep('password')
|
||||||
|
} else if (abortController.signal.aborted) {
|
||||||
|
// ignore
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
onCleanup(() => abortController.abort())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4">
|
||||||
|
<h2 class="mb-2 text-xl font-bold">
|
||||||
|
Log in with QR code
|
||||||
|
</h2>
|
||||||
|
<div class="flex size-40 flex-col items-center justify-center">
|
||||||
|
{qr() ? (
|
||||||
|
<div
|
||||||
|
class="size-40"
|
||||||
|
// eslint-disable-next-line solid/no-innerhtml
|
||||||
|
innerHTML={qr()}
|
||||||
|
/>
|
||||||
|
) : <Spinner indeterminate class="size-10" />}
|
||||||
|
</div>
|
||||||
|
<ol class="mt-4 list-inside list-decimal text-sm text-muted-foreground">
|
||||||
|
<li>Open Telegram on your phone</li>
|
||||||
|
<li>
|
||||||
|
Go to
|
||||||
|
{' '}
|
||||||
|
<b>Settings > Devices > Link Desktop Device</b>
|
||||||
|
</li>
|
||||||
|
<li>Point your phone at this screen to confirm login</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => props.setStep('phone')}
|
||||||
|
disabled={finalizing()}
|
||||||
|
>
|
||||||
|
Log in with phone number
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneNumberStep(props: StepProps<'phone'>) {
|
||||||
|
const [phone, setPhone] = createSignal('')
|
||||||
|
const [error, setError] = createSignal<string | undefined>()
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setError(undefined)
|
||||||
|
setLoading(true)
|
||||||
|
sendCode(props.client, {
|
||||||
|
phone: phone(),
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
}).then((code) => {
|
||||||
|
setLoading(false)
|
||||||
|
props.setStep('otp', {
|
||||||
|
code,
|
||||||
|
phone: phone(),
|
||||||
|
})
|
||||||
|
}).catch((e) => {
|
||||||
|
setLoading(false)
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
// ignore
|
||||||
|
} else {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCleanup(() => abortController.abort())
|
||||||
|
createEffect(() => inputRef()?.focus())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex h-full flex-col items-center justify-center">
|
||||||
|
<div class="flex-1" />
|
||||||
|
<img
|
||||||
|
class="size-24"
|
||||||
|
src="https://mtcute.dev/mtcute-logo.svg"
|
||||||
|
alt="mtcute logo"
|
||||||
|
/>
|
||||||
|
<h2 class="mt-4 text-xl font-bold">
|
||||||
|
Log in with phone number
|
||||||
|
</h2>
|
||||||
|
<div class="mt-2 text-center text-sm text-muted-foreground">
|
||||||
|
Please confirm your country code
|
||||||
|
<br />
|
||||||
|
and enter your phone number
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextFieldRoot class="mt-4" validationState={error() ? 'invalid' : 'valid'}>
|
||||||
|
<TextFieldLabel>Phone</TextFieldLabel>
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<PhoneInput
|
||||||
|
class="w-[300px]"
|
||||||
|
client={props.client}
|
||||||
|
onChange={setPhone}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
disabled={loading()}
|
||||||
|
ref={setInputRef}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
class="ml-2 size-9"
|
||||||
|
disabled={!phone() || loading()}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
<LucideChevronRight class="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
|
||||||
|
</TextFieldRoot>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<div class="text-center text-sm text-muted-foreground">
|
||||||
|
or,
|
||||||
|
{' '}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="font-medium text-neutral-600 hover:underline"
|
||||||
|
onClick={() => props.setStep('qr')}
|
||||||
|
>
|
||||||
|
log in with QR code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OtpStep(props: StepProps<'otp'>) {
|
||||||
|
const [otp, setOtp] = createSignal('')
|
||||||
|
const [error, setError] = createSignal<string | undefined>()
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [countdown, setCountdown] = createSignal(0)
|
||||||
|
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setError(undefined)
|
||||||
|
setLoading(true)
|
||||||
|
signIn(props.client, {
|
||||||
|
phone: props.ctx.phone,
|
||||||
|
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
||||||
|
phoneCode: otp(),
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
}).then((user) => {
|
||||||
|
setLoading(false)
|
||||||
|
props.setStep('done', { user })
|
||||||
|
}).catch((e) => {
|
||||||
|
setLoading(false)
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
// ignore
|
||||||
|
} else if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
|
||||||
|
props.setStep('password')
|
||||||
|
} else {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleResend = () => {
|
||||||
|
setError(undefined)
|
||||||
|
setLoading(true)
|
||||||
|
resendCode(props.client, {
|
||||||
|
phone: props.ctx.phone,
|
||||||
|
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
}).then((code) => {
|
||||||
|
setLoading(false)
|
||||||
|
props.setStep('otp', {
|
||||||
|
code,
|
||||||
|
phone: props.ctx.phone,
|
||||||
|
})
|
||||||
|
}).catch((e) => {
|
||||||
|
setLoading(false)
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
// ignore
|
||||||
|
} else {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleSetOtp = (otp: string) => {
|
||||||
|
setOtp(otp)
|
||||||
|
if (otp.length === props.ctx.code.length) {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onCleanup(() => abortController.abort())
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setCountdown(props.ctx.code.timeout)
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCountdown(countdown() - 1)
|
||||||
|
if (countdown() <= 0) {
|
||||||
|
clearInterval(interval)
|
||||||
|
setCountdown(0)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
onCleanup(() => clearInterval(interval))
|
||||||
|
})
|
||||||
|
createEffect(() => inputRef()?.focus())
|
||||||
|
|
||||||
|
const description = () => {
|
||||||
|
switch (props.ctx.code.type) {
|
||||||
|
case 'app':
|
||||||
|
return 'We have sent you a one-time code to your Telegram app'
|
||||||
|
case 'sms':
|
||||||
|
case 'sms_word':
|
||||||
|
case 'sms_phrase':
|
||||||
|
return 'We have sent you a one-time code to your phone'
|
||||||
|
case 'fragment':
|
||||||
|
return 'We have sent you a one-time code to your Fragment anonymous number'
|
||||||
|
case 'call':
|
||||||
|
return 'We are calling you to dictate your one-time code'
|
||||||
|
case 'flash_call':
|
||||||
|
case 'missed_call':
|
||||||
|
return `We are calling you, put the last ${props.ctx.code.length} digits of the number we're calling you from`
|
||||||
|
case 'email':
|
||||||
|
return 'We have sent you an email with a one-time code'
|
||||||
|
case 'email_required':
|
||||||
|
return 'Email setup is required, please do it in your Telegram app'
|
||||||
|
default:
|
||||||
|
return `Unknown code type: ${props.ctx.code.type}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<h2 class="text-xl font-bold">
|
||||||
|
{props.ctx.phone}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="cursor-pointer text-xs text-neutral-400 hover:underline"
|
||||||
|
onClick={() => props.setStep('phone')}
|
||||||
|
>
|
||||||
|
Wrong number?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
{description()}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-col">
|
||||||
|
<Show
|
||||||
|
when={props.ctx.code.type !== 'sms_phrase' && props.ctx.code.type !== 'sms_word'}
|
||||||
|
fallback={(
|
||||||
|
<TextFieldRoot class="mt-2" validationState={error() ? 'invalid' : 'valid'}>
|
||||||
|
<TextFieldFrame>
|
||||||
|
<TextField
|
||||||
|
disabled={loading()}
|
||||||
|
placeholder={props.ctx.code.beginning ? `${props.ctx.code.beginning}...` : undefined}
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
value={otp()}
|
||||||
|
onInput={e => setOtp(e.currentTarget.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={setInputRef}
|
||||||
|
/>
|
||||||
|
</TextFieldFrame>
|
||||||
|
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
|
||||||
|
</TextFieldRoot>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<OTPField
|
||||||
|
class="mt-2"
|
||||||
|
maxLength={props.ctx.code.length}
|
||||||
|
value={otp()}
|
||||||
|
onValueChange={handleSetOtp}
|
||||||
|
>
|
||||||
|
<OTPFieldInput
|
||||||
|
disabled={loading()}
|
||||||
|
ref={setInputRef}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<OTPFieldGroup>
|
||||||
|
<For each={Array.from({ length: props.ctx.code.length })}>
|
||||||
|
{(_, i) => (
|
||||||
|
<OTPFieldSlot
|
||||||
|
class={error() ? 'border-error-foreground' : ''}
|
||||||
|
index={i()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</OTPFieldGroup>
|
||||||
|
</OTPField>
|
||||||
|
{error() && (
|
||||||
|
<div class="mt-1 text-sm text-error-foreground">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="mt-2 flex w-full justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={loading() || countdown() > 0}
|
||||||
|
>
|
||||||
|
{props.ctx.code.nextType === 'call' ? 'Call me' : 'Resend'}
|
||||||
|
{countdown() > 0 && (
|
||||||
|
` (${countdown()})`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordStep(props: StepProps<'password'>) {
|
||||||
|
const [password, setPassword] = createSignal('')
|
||||||
|
const [error, setError] = createSignal<string | undefined>()
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
|
|
||||||
|
// todo abort controller
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!password()) {
|
||||||
|
setError('Password is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(undefined)
|
||||||
|
setLoading(true)
|
||||||
|
checkPassword(props.client, password())
|
||||||
|
.then((user) => {
|
||||||
|
setLoading(false)
|
||||||
|
props.setStep('done', { user })
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setLoading(false)
|
||||||
|
if (tl.RpcError.is(e, 'PASSWORD_HASH_INVALID')) {
|
||||||
|
setError('Incorrect password')
|
||||||
|
} else {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createEffect(() => inputRef()?.focus())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<h2 class="text-xl font-bold">
|
||||||
|
2FA password
|
||||||
|
</h2>
|
||||||
|
<div class="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Your account is protected with an additional password.
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-col">
|
||||||
|
<TextFieldRoot validationState={error() ? 'invalid' : 'valid'}>
|
||||||
|
<TextFieldLabel>Password</TextFieldLabel>
|
||||||
|
<TextFieldFrame>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
value={password()}
|
||||||
|
onInput={e => setPassword(e.currentTarget.value)}
|
||||||
|
disabled={loading()}
|
||||||
|
ref={setInputRef}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TextFieldFrame>
|
||||||
|
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
|
||||||
|
</TextFieldRoot>
|
||||||
|
|
||||||
|
<div class="mt-2 flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DoneStep(props: StepProps<'done'>) {
|
||||||
|
const [avatar, setAvatar] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!props.ctx.user.photo) {
|
||||||
|
props.client.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAsBuffer(props.client, props.ctx.user.photo.big)
|
||||||
|
.then((buf) => {
|
||||||
|
const url = URL.createObjectURL(new Blob([buf], { type: 'image/jpeg' }))
|
||||||
|
setAvatar(url)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (avatar()) {
|
||||||
|
URL.revokeObjectURL(avatar()!)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<Avatar class="mb-4 size-24 shadow-sm">
|
||||||
|
{props.ctx.user.photo && (
|
||||||
|
<>
|
||||||
|
{avatar() && <AvatarImage src={avatar()!} />}
|
||||||
|
<AvatarImage src={`data:image/jpeg;base64,${base64.encode(props.ctx.user.photo.thumb!)}`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<AvatarFallback class="text-xl">
|
||||||
|
{makeAvatarFallbackText(props.ctx.user.displayName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="text-center font-medium">
|
||||||
|
Welcome,
|
||||||
|
{' '}
|
||||||
|
{props.ctx.user.displayName}
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm(props: {
|
||||||
|
class?: string
|
||||||
|
client: BaseTelegramClient
|
||||||
|
onStepChange?: (step: LoginStep, ctx: Partial<StepContext>) => void
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = createSignal<LoginStep>('qr')
|
||||||
|
const [ctx, setCtx] = createSignal<Partial<StepContext>>({})
|
||||||
|
|
||||||
|
const setStepWithCtx = <T extends LoginStep>(step: T, data?: StepContext[T]) => {
|
||||||
|
setCtx(ctx => ({ ...ctx, [step]: data }))
|
||||||
|
setStep(step as LoginStep)
|
||||||
|
props.onStepChange?.(step, ctx())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={cn('flex h-full flex-col items-center justify-center gap-4', props.class)}>
|
||||||
|
<TransitionSlideLtr mode="outin">
|
||||||
|
<Switch>
|
||||||
|
<Match when={step() === 'qr'}>
|
||||||
|
<QrLoginStep client={props.client} setStep={setStepWithCtx} />
|
||||||
|
</Match>
|
||||||
|
<Match when={step() === 'phone'}>
|
||||||
|
<PhoneNumberStep client={props.client} setStep={setStepWithCtx} />
|
||||||
|
</Match>
|
||||||
|
<Match when={step() === 'otp'}>
|
||||||
|
<OtpStep client={props.client} setStep={setStepWithCtx} ctx={ctx().otp!} />
|
||||||
|
</Match>
|
||||||
|
<Match when={step() === 'password'}>
|
||||||
|
<PasswordStep client={props.client} setStep={setStepWithCtx} />
|
||||||
|
</Match>
|
||||||
|
<Match when={step() === 'done'}>
|
||||||
|
<DoneStep
|
||||||
|
client={props.client}
|
||||||
|
setStep={setStepWithCtx}
|
||||||
|
ctx={ctx().done!}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</TransitionSlideLtr>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
193
src/components/login/PhoneInput.tsx
Normal file
193
src/components/login/PhoneInput.tsx
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
import type { BaseTelegramClient, tl } from '@mtcute/web'
|
||||||
|
|
||||||
|
import { assert } from '@fuman/utils'
|
||||||
|
import { createSignal, onMount, Show } from 'solid-js'
|
||||||
|
import { CountryIcon } from '../../lib/components/country-icon.tsx'
|
||||||
|
import { TextField, TextFieldFrame } from '../../lib/components/ui/text-field.tsx'
|
||||||
|
import { cn } from '../../lib/utils.ts'
|
||||||
|
|
||||||
|
interface ChosenCode {
|
||||||
|
patterns?: string[]
|
||||||
|
countryCode: string
|
||||||
|
iso2: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCountryCode(country: tl.help.RawCountry, code: tl.help.RawCountryCode): ChosenCode {
|
||||||
|
return {
|
||||||
|
patterns: code.patterns,
|
||||||
|
countryCode: code.countryCode,
|
||||||
|
iso2: country.iso2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhoneInputProps {
|
||||||
|
class?: string
|
||||||
|
phone?: string
|
||||||
|
onChange?: (phone: string) => void
|
||||||
|
onSubmit?: () => void
|
||||||
|
client: BaseTelegramClient
|
||||||
|
disabled?: boolean
|
||||||
|
ref?: (el: HTMLInputElement) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhoneInput(props: PhoneInputProps) {
|
||||||
|
const [countriesList, setCountriesList] = createSignal<tl.help.RawCountry[]>([])
|
||||||
|
const [chosenCode, setChosenCode] = createSignal<ChosenCode | undefined>()
|
||||||
|
const [inputValue, setInputValue] = createSignal('+')
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
Promise.all([
|
||||||
|
props.client.call({ _: 'help.getCountriesList', langCode: 'en', hash: 0 }),
|
||||||
|
props.client.call({ _: 'help.getNearestDc' }),
|
||||||
|
]).then(([countriesList, nearestDc]) => {
|
||||||
|
assert(countriesList._ === 'help.countriesList') // todo caching
|
||||||
|
setCountriesList(countriesList.countries)
|
||||||
|
|
||||||
|
if (inputValue() === '+') {
|
||||||
|
// guess the country code
|
||||||
|
for (const country of countriesList.countries) {
|
||||||
|
if (country.iso2 === nearestDc.country.toUpperCase()) {
|
||||||
|
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
|
||||||
|
setInputValue(`+${country.countryCodes[0].countryCode} `)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleInput = (e: InputEvent) => {
|
||||||
|
const el = e.currentTarget as HTMLInputElement
|
||||||
|
const value = el.value
|
||||||
|
|
||||||
|
if (value === '' || value === '+') {
|
||||||
|
// country code was removed
|
||||||
|
setInputValue('+')
|
||||||
|
el.value = '+'
|
||||||
|
setChosenCode(undefined)
|
||||||
|
props.onChange?.('')
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
setInputValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to find matching country code
|
||||||
|
// first sanitize input
|
||||||
|
const rawPhone = value.slice(1).replace(/\D/g, '')
|
||||||
|
el.value = `+${value.replace(/[^\d ]/g, '')}`
|
||||||
|
|
||||||
|
// pass 1: find matching countries by country code
|
||||||
|
const matching: [tl.help.RawCountry, tl.help.RawCountryCode][] = []
|
||||||
|
let hasPrefixes = false
|
||||||
|
|
||||||
|
for (const country of countriesList()) {
|
||||||
|
for (const code of country.countryCodes) {
|
||||||
|
if (rawPhone.startsWith(code.countryCode)) {
|
||||||
|
matching.push([country, code])
|
||||||
|
}
|
||||||
|
if (code.prefixes) {
|
||||||
|
hasPrefixes = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosenCode: ChosenCode | undefined
|
||||||
|
|
||||||
|
// if we have a prefix in some of the items, try to find matching countries by prefix
|
||||||
|
// (e.g. russia: +7<any>, kazakhstan: +77<any>)
|
||||||
|
if (hasPrefixes && matching.length > 1) {
|
||||||
|
// 1: find a match without a prefix
|
||||||
|
let match = matching.find(it => it[1].prefixes === undefined)
|
||||||
|
// 2: try to refine the match by prefix
|
||||||
|
let foundByPrefix = false
|
||||||
|
for (const item of matching) {
|
||||||
|
const code = item[1]
|
||||||
|
|
||||||
|
if (code.prefixes === undefined) continue
|
||||||
|
for (const prefix of code.prefixes) {
|
||||||
|
const fullPrefix = code.countryCode + prefix
|
||||||
|
if (rawPhone.startsWith(fullPrefix)) {
|
||||||
|
match = item
|
||||||
|
foundByPrefix = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3: if we couldnt refine and the country code is the same as countryCode, do nothing
|
||||||
|
if (!foundByPrefix && match && match[1].countryCode === rawPhone) {
|
||||||
|
match = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
chosenCode = match ? mapCountryCode(match[0], match[1]) : undefined
|
||||||
|
} else if (matching.length === 1) {
|
||||||
|
chosenCode = mapCountryCode(matching[0][0], matching[0][1])
|
||||||
|
}
|
||||||
|
|
||||||
|
setChosenCode(chosenCode)
|
||||||
|
props.onChange?.(rawPhone)
|
||||||
|
|
||||||
|
if (chosenCode && chosenCode.patterns) {
|
||||||
|
// format the number
|
||||||
|
const numberWithoutCode = rawPhone.slice(chosenCode.countryCode.length)
|
||||||
|
|
||||||
|
for (const pattern of chosenCode.patterns) {
|
||||||
|
let numberIdx = 0
|
||||||
|
let formatted = ''
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const patternChar = pattern[i]
|
||||||
|
const numberChar = numberWithoutCode[numberIdx]
|
||||||
|
if (numberChar === undefined) break
|
||||||
|
|
||||||
|
if (patternChar.match(/\d/)) {
|
||||||
|
// these patterns are not supported (yet?)
|
||||||
|
break
|
||||||
|
} else if (patternChar === ' ') {
|
||||||
|
formatted += '-'
|
||||||
|
} else if (patternChar === 'X') {
|
||||||
|
formatted += numberChar
|
||||||
|
numberIdx++
|
||||||
|
} else {
|
||||||
|
console.warn('Unexpected pattern char %s in %s', patternChar, pattern)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formatted && numberIdx === numberWithoutCode.length) {
|
||||||
|
el.value = `+${chosenCode.countryCode} ${formatted}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && chosenCode() !== undefined) {
|
||||||
|
props.onSubmit?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextFieldFrame class={cn('flex items-center', props.class)}>
|
||||||
|
<Show
|
||||||
|
when={chosenCode()}
|
||||||
|
fallback={(
|
||||||
|
<div class="w-6">
|
||||||
|
🏳️
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CountryIcon class="mt-0.5 w-6 select-none" country={chosenCode()!.iso2} />
|
||||||
|
</Show>
|
||||||
|
<TextField
|
||||||
|
class="ml-0.5 w-full"
|
||||||
|
value={inputValue()}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={props.disabled}
|
||||||
|
ref={props.ref}
|
||||||
|
/>
|
||||||
|
</TextFieldFrame>
|
||||||
|
)
|
||||||
|
}
|
98
src/components/nav/NavbarMenu.tsx
Normal file
98
src/components/nav/NavbarMenu.tsx
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
|
||||||
|
import { ChevronDownIcon, ExternalLinkIcon, LucideCheck, SettingsIcon, UsersIcon } from 'lucide-solid'
|
||||||
|
import { SiGithub } from 'solid-icons/si'
|
||||||
|
import { For } from 'solid-js'
|
||||||
|
import { Button } from '../../lib/components/ui/button.tsx'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuGroupLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '../../lib/components/ui/dropdown-menu.tsx'
|
||||||
|
import { cn } from '../../lib/utils.ts'
|
||||||
|
import { $accounts, $activeAccount, $activeAccountId } from '../../store/accounts.ts'
|
||||||
|
import { useStore } from '../../store/use-store.ts'
|
||||||
|
import { AccountAvatar } from '../AccountAvatar.tsx'
|
||||||
|
|
||||||
|
export function NavbarMenu(props: {
|
||||||
|
onShowAccounts: () => void
|
||||||
|
onShowSettings: () => void
|
||||||
|
}) {
|
||||||
|
const activeAccount = useStore($activeAccount)
|
||||||
|
const accounts = useStore($accounts)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
as={(props: DropdownMenuTriggerProps) => (
|
||||||
|
<Button
|
||||||
|
class="z-10 ml-auto px-2"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{activeAccount() != null && (
|
||||||
|
<>
|
||||||
|
<AccountAvatar class="size-6" account={activeAccount()!} />
|
||||||
|
<span class="ml-2 max-w-[120px] truncate">
|
||||||
|
{activeAccount()!.name}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon class="ml-2 size-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent class="w-[200px]">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuGroupLabel>Accounts</DropdownMenuGroupLabel>
|
||||||
|
<For each={accounts()}>
|
||||||
|
{account => (
|
||||||
|
<DropdownMenuItem onClick={() => $activeAccountId.set(account.id)}>
|
||||||
|
<AccountAvatar
|
||||||
|
class={cn(
|
||||||
|
'size-4',
|
||||||
|
account.id !== activeAccount()?.id && 'opacity-50',
|
||||||
|
)}
|
||||||
|
account={account}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
'ml-2',
|
||||||
|
account.id === activeAccount()?.id ? 'font-semibold' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{account.name}
|
||||||
|
</span>
|
||||||
|
{account.id === activeAccount()?.id && <LucideCheck class="ml-auto size-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<DropdownMenuItem onClick={props.onShowAccounts}>
|
||||||
|
<UsersIcon class="mr-2 size-4" />
|
||||||
|
Manage accounts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={props.onShowSettings}>
|
||||||
|
<SettingsIcon class="mr-2 size-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
as="a"
|
||||||
|
class="cursor-pointer"
|
||||||
|
href="https://github.com/mtcute/repl"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<SiGithub class="mr-2 size-4" />
|
||||||
|
GitHub
|
||||||
|
<ExternalLinkIcon class="ml-auto size-4" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
19
src/components/runner/Actions.tsx
Normal file
19
src/components/runner/Actions.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
export function Actions(props: {
|
||||||
|
class?: string
|
||||||
|
devtoolsIframe: HTMLIFrameElement | undefined
|
||||||
|
}) {
|
||||||
|
const [running, setRunning] = createSignal(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={props.class}>
|
||||||
|
|
||||||
|
{/* <iframe
|
||||||
|
ref={setRunnerIframe}
|
||||||
|
src="about:blank"
|
||||||
|
class="size-full"
|
||||||
|
/> */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
84
src/components/runner/Devtools.tsx
Normal file
84
src/components/runner/Devtools.tsx
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { createEffect, createSignal, onCleanup } from 'solid-js'
|
||||||
|
import { cn } from '../../lib/utils.ts'
|
||||||
|
|
||||||
|
const HTML = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>DevTools</title>
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<script src="https://unpkg.com/@ungap/custom-elements/es.js"></script>
|
||||||
|
<script type="module" src="https://cdn.jsdelivr.net/npm/chii@1.12.3/public/front_end/entrypoints/chii_app/chii_app.js"></script>
|
||||||
|
<body class="undocked" id="-blink-dev-tools">
|
||||||
|
`
|
||||||
|
|
||||||
|
const INJECTED_SCRIPT = `
|
||||||
|
async function waitForElement(selector, container) {
|
||||||
|
let tabbedPane;
|
||||||
|
while(!tabbedPane) {
|
||||||
|
tabbedPane = container.querySelector(selector);
|
||||||
|
if (!tabbedPane) await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
return tabbedPane;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideBySelector(root, selector) {
|
||||||
|
const el = root.shadowRoot.querySelector(selector);
|
||||||
|
if (!el) return;
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusConsole(tabbedPane) {
|
||||||
|
const consoleTab = tabbedPane.shadowRoot.querySelector('#tab-console');
|
||||||
|
|
||||||
|
// tabs get focused on mousedown instead of click
|
||||||
|
consoleTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||||
|
consoleTab.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
(async ()=> {
|
||||||
|
const tabbedPane = await waitForElement('.tabbed-pane', document.body);
|
||||||
|
focusConsole(tabbedPane);
|
||||||
|
hideBySelector(tabbedPane, '.tabbed-pane-header');
|
||||||
|
|
||||||
|
const consoleToolbar = await waitForElement('.console-main-toolbar', document.body);
|
||||||
|
hideBySelector(consoleToolbar, 'devtools-issue-counter');
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
export function Devtools(props: {
|
||||||
|
class?: string
|
||||||
|
iframeRef?: (el: HTMLIFrameElement) => void
|
||||||
|
}) {
|
||||||
|
const [innerRef, setInnerRef] = createSignal<HTMLIFrameElement | undefined>()
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(new Blob([HTML], { type: 'text/html' }))
|
||||||
|
onCleanup(() => URL.revokeObjectURL(url))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const _innerRef = innerRef()
|
||||||
|
if (_innerRef) {
|
||||||
|
props.iframeRef?.(_innerRef)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleLoad() {
|
||||||
|
const _innerRef = innerRef()
|
||||||
|
if (!_innerRef) return
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.textContent = INJECTED_SCRIPT
|
||||||
|
_innerRef.contentWindow?.document.body.appendChild(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={cn('relative', props.class)}>
|
||||||
|
<iframe
|
||||||
|
ref={setInnerRef}
|
||||||
|
src={`${url}#?embedded=${encodeURIComponent(location.origin)}`}
|
||||||
|
title="Devtools"
|
||||||
|
class="absolute inset-0 block size-full"
|
||||||
|
onLoad={handleLoad}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
235
src/components/runner/Runner.tsx
Normal file
235
src/components/runner/Runner.tsx
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
|
||||||
|
import type { ConnectionState } from '@mtcute/web'
|
||||||
|
import type { CustomTypeScriptWorker } from '../editor/utils/custom-worker.ts'
|
||||||
|
import { LucideCheck, LucidePlay, LucidePlug, LucideRefreshCw, LucideUnplug } from 'lucide-solid'
|
||||||
|
import { languages, Uri } from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||||
|
import { Button } from '../../lib/components/ui/button.tsx'
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuGroupLabel, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '../../lib/components/ui/dropdown-menu.tsx'
|
||||||
|
import { cn } from '../../lib/utils.ts'
|
||||||
|
import { $activeAccountId } from '../../store/accounts.ts'
|
||||||
|
import { $tabs } from '../../store/tabs.ts'
|
||||||
|
import { useStore } from '../../store/use-store.ts'
|
||||||
|
import { swForgetScript, swUploadScript } from '../../sw/client.ts'
|
||||||
|
import { Devtools } from './Devtools.tsx'
|
||||||
|
|
||||||
|
export function Runner() {
|
||||||
|
const [devtoolsIframe, setDevtoolsIframe] = createSignal<HTMLIFrameElement | undefined>()
|
||||||
|
const [runnerLoaded, setRunnerLoaded] = createSignal(false)
|
||||||
|
const [running, setRunning] = createSignal(false)
|
||||||
|
const [connectionState, setConnectionState] = createSignal<ConnectionState>('offline')
|
||||||
|
const currentAccountId = useStore($activeAccountId)
|
||||||
|
|
||||||
|
let currentScriptId: string | undefined
|
||||||
|
|
||||||
|
let runnerIframeRef!: HTMLIFrameElement
|
||||||
|
|
||||||
|
function handleMessage(e: MessageEvent) {
|
||||||
|
if (e.source === runnerIframeRef.contentWindow) {
|
||||||
|
// event from runner iframe
|
||||||
|
switch (e.data.event) {
|
||||||
|
case 'TO_DEVTOOLS': {
|
||||||
|
devtoolsIframe()?.contentWindow!.postMessage(e.data.value, '*')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'SCRIPT_END': {
|
||||||
|
setRunning(false)
|
||||||
|
swForgetScript(currentScriptId!)
|
||||||
|
currentScriptId = undefined
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'CONNECTION_STATE': {
|
||||||
|
setConnectionState(e.data.value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.source === devtoolsIframe()?.contentWindow) {
|
||||||
|
const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
|
||||||
|
|
||||||
|
if (data.method === 'Page.getResourceTree' && !runnerLoaded()) {
|
||||||
|
// for some reason, responding to this method is required for console.log to work
|
||||||
|
// and since our runner might not be loaded yet, noone will respond to this message
|
||||||
|
e.source!.postMessage(JSON.stringify({ id: data.id, result: {} }), '*')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.method === 'Page.reload') {
|
||||||
|
location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runnerIframeRef.contentWindow!.postMessage({ event: 'FROM_DEVTOOLS', value: e.data }, '*')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
window.addEventListener('message', handleMessage)
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener('message', handleMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(on(currentAccountId, (accountId) => {
|
||||||
|
if (!runnerLoaded()) return
|
||||||
|
runnerIframeRef.contentWindow!.postMessage({
|
||||||
|
event: 'ACCOUNT_CHANGED',
|
||||||
|
accountId,
|
||||||
|
}, '*')
|
||||||
|
}, { defer: true }))
|
||||||
|
|
||||||
|
async function handleRun() {
|
||||||
|
const getWorker = await languages.typescript.getTypeScriptWorker()
|
||||||
|
const worker = await getWorker(Uri.parse('file:///main.ts')) as unknown as CustomTypeScriptWorker
|
||||||
|
|
||||||
|
const tabs = $tabs.get()
|
||||||
|
const processed = await Promise.all(tabs.map(tab => worker.processFile(`file:///${tab.fileName}`, tab.main)))
|
||||||
|
|
||||||
|
const files: Record<string, string> = {}
|
||||||
|
let exports: string[] | undefined
|
||||||
|
for (let i = 0; i < processed.length; i++) {
|
||||||
|
const processedFile = processed[i]
|
||||||
|
const tab = tabs[i]
|
||||||
|
files[tab.fileName.replace(/\.ts$/, '.js')] = processedFile.transformed
|
||||||
|
if (tab.main) {
|
||||||
|
exports = processedFile.exports
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentScriptId = nanoid()
|
||||||
|
await swUploadScript(currentScriptId, files)
|
||||||
|
|
||||||
|
runnerIframeRef.contentWindow!.postMessage({
|
||||||
|
event: 'RUN',
|
||||||
|
scriptId: currentScriptId,
|
||||||
|
exports,
|
||||||
|
}, '*')
|
||||||
|
setRunning(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect() {
|
||||||
|
runnerIframeRef.contentWindow!.postMessage({ event: 'DISCONNECT' }, '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConnect() {
|
||||||
|
runnerIframeRef.contentWindow!.postMessage({ event: 'RECONNECT' }, '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRestart() {
|
||||||
|
runnerIframeRef.contentWindow!.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="flex shrink-0 flex-row p-1">
|
||||||
|
<iframe
|
||||||
|
class="invisible size-0"
|
||||||
|
src="/sw/runtime/_iframe.html"
|
||||||
|
ref={runnerIframeRef}
|
||||||
|
onLoad={() => {
|
||||||
|
runnerIframeRef.contentWindow!.postMessage({
|
||||||
|
event: 'INIT',
|
||||||
|
accountId: currentAccountId(),
|
||||||
|
}, '*')
|
||||||
|
setRunnerLoaded(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="flex w-full grow-0 flex-row">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={!runnerLoaded() || running()}
|
||||||
|
>
|
||||||
|
<LucidePlay
|
||||||
|
class="mr-2 size-3"
|
||||||
|
/>
|
||||||
|
Run
|
||||||
|
</Button>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
as={(props: DropdownMenuTriggerProps) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
offline: 'Disconnected',
|
||||||
|
connecting: 'Connecting...',
|
||||||
|
updating: 'Updating...',
|
||||||
|
connected: 'Ready',
|
||||||
|
}[connectionState()]}
|
||||||
|
<div class={cn(
|
||||||
|
'ml-2 size-2 rounded-full',
|
||||||
|
{
|
||||||
|
connected: 'bg-green-500',
|
||||||
|
updating: 'bg-yellow-500',
|
||||||
|
connecting: 'bg-yellow-500',
|
||||||
|
offline: 'bg-neutral-400',
|
||||||
|
}[connectionState()],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={connectionState() === 'offline' ? handleConnect : handleDisconnect}
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{connectionState() === 'offline' ? (
|
||||||
|
<LucidePlug
|
||||||
|
class="mr-2 size-3"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LucideUnplug
|
||||||
|
class="mr-2 size-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{connectionState() === 'offline' ? 'Connect' : 'Disconnect'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleRestart}
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
<LucideRefreshCw
|
||||||
|
class="mr-2 size-3"
|
||||||
|
/>
|
||||||
|
Restart runner
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuGroupLabel class="text-xs">
|
||||||
|
Auto-disconnect after
|
||||||
|
</DropdownMenuGroupLabel>
|
||||||
|
<DropdownMenuItem class="text-xs">
|
||||||
|
1 minute
|
||||||
|
<LucideCheck class="ml-auto size-3" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem class="text-xs">
|
||||||
|
5 minutes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem class="text-xs">
|
||||||
|
15 minutes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem class="text-xs">
|
||||||
|
Never
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-px shrink-0 bg-border" />
|
||||||
|
<Devtools
|
||||||
|
class="size-full grow-0"
|
||||||
|
iframeRef={setDevtoolsIframe}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
132
src/components/runner/iframe.ts
Normal file
132
src/components/runner/iframe.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import { TelegramClient } from '@mtcute/web?external'
|
||||||
|
|
||||||
|
type ConnectionState = import('@mtcute/web').ConnectionState
|
||||||
|
type TelegramClientOptions = import('@mtcute/web').TelegramClientOptions
|
||||||
|
|
||||||
|
declare const chobitsu: any
|
||||||
|
|
||||||
|
declare const window: typeof globalThis & {
|
||||||
|
__currentScript: any
|
||||||
|
__handleScriptEnd: (error: any) => void
|
||||||
|
tg: import('@mtcute/web').TelegramClient
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToDevtools(message: any) {
|
||||||
|
window.parent.postMessage({ event: 'TO_DEVTOOLS', value: message }, '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToChobitsu(message: any) {
|
||||||
|
message.id = `__chobitsu_manual__${Date.now()}`
|
||||||
|
chobitsu.sendRawMessage(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
chobitsu.setOnMessage((message: string) => {
|
||||||
|
if (message.includes('__chobitsu_manual__')) return
|
||||||
|
sendToDevtools(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
let lastAccountId: string | undefined
|
||||||
|
let lastConnectionState: ConnectionState | undefined
|
||||||
|
|
||||||
|
function initClient(accountId: string) {
|
||||||
|
lastAccountId = accountId
|
||||||
|
|
||||||
|
let extraConfig: Partial<TelegramClientOptions> | undefined
|
||||||
|
|
||||||
|
const storedAccounts = localStorage.getItem('repl:accounts')
|
||||||
|
if (storedAccounts) {
|
||||||
|
const accounts = JSON.parse(storedAccounts)
|
||||||
|
const ourAccount = accounts.find((it: any) => it.id === accountId)
|
||||||
|
|
||||||
|
if (ourAccount && ourAccount.testMode) {
|
||||||
|
extraConfig = {
|
||||||
|
testMode: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.tg = new TelegramClient({
|
||||||
|
apiId: import.meta.env.VITE_API_ID,
|
||||||
|
apiHash: import.meta.env.VITE_API_HASH,
|
||||||
|
storage: `mtcute:${accountId}`,
|
||||||
|
...extraConfig,
|
||||||
|
})
|
||||||
|
window.tg.onConnectionState.add((state) => {
|
||||||
|
lastConnectionState = state
|
||||||
|
window.parent.postMessage({ event: 'CONNECTION_STATE', value: state }, '*')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', ({ data }) => {
|
||||||
|
if (data.event === 'INIT') {
|
||||||
|
sendToDevtools({
|
||||||
|
method: 'Page.frameNavigated',
|
||||||
|
params: {
|
||||||
|
frame: {
|
||||||
|
id: '1',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
securityOrigin: location.origin,
|
||||||
|
url: location.href,
|
||||||
|
},
|
||||||
|
type: 'Navigation',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
sendToDevtools({ method: 'Runtime.executionContextsCleared' })
|
||||||
|
sendToChobitsu({ method: 'Runtime.enable' })
|
||||||
|
sendToChobitsu({ method: 'DOMStorage.enable' })
|
||||||
|
sendToDevtools({ method: 'DOM.documentUpdated' })
|
||||||
|
|
||||||
|
initClient(data.accountId)
|
||||||
|
window.tg.connect()
|
||||||
|
} else if (data.event === 'RUN') {
|
||||||
|
const el = document.createElement('script')
|
||||||
|
el.type = 'module'
|
||||||
|
let script = `import * as result from "/sw/runtime/script/${data.scriptId}/main.js";`
|
||||||
|
for (const exportName of data.exports ?? []) {
|
||||||
|
script += `window.${exportName} = result.${exportName};`
|
||||||
|
}
|
||||||
|
if (data.exports?.length) {
|
||||||
|
script += `console.log("[mtcute-repl] Script ended, exported variables: ${data.exports.join(', ')}");`
|
||||||
|
} else {
|
||||||
|
script += 'console.log("[mtcute-repl] Script ended");'
|
||||||
|
}
|
||||||
|
script += 'window.__handleScriptEnd();'
|
||||||
|
el.textContent = script
|
||||||
|
el.addEventListener('error', e => window.__handleScriptEnd(e.error))
|
||||||
|
window.__currentScript = el
|
||||||
|
document.body.appendChild(el)
|
||||||
|
} else if (data.event === 'FROM_DEVTOOLS') {
|
||||||
|
chobitsu.sendRawMessage(data.value)
|
||||||
|
} else if (data.event === 'ACCOUNT_CHANGED') {
|
||||||
|
window.tg?.close()
|
||||||
|
initClient(data.accountId)
|
||||||
|
|
||||||
|
if (lastConnectionState !== 'offline') {
|
||||||
|
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, '*')
|
||||||
|
window.tg.connect()
|
||||||
|
}
|
||||||
|
} else if (data.event === 'DISCONNECT') {
|
||||||
|
// todo: we dont have a clean way to disconnect i think
|
||||||
|
window.tg?.close()
|
||||||
|
if (lastAccountId) {
|
||||||
|
initClient(lastAccountId)
|
||||||
|
}
|
||||||
|
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, '*')
|
||||||
|
} else if (data.event === 'RECONNECT') {
|
||||||
|
window.tg.connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
window.__handleScriptEnd = (error) => {
|
||||||
|
if (!window.__currentScript) return
|
||||||
|
window.parent.postMessage({ event: 'SCRIPT_END', error }, '*')
|
||||||
|
window.__currentScript.remove()
|
||||||
|
window.__currentScript = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
if (window.__currentScript) {
|
||||||
|
window.__handleScriptEnd(e.error)
|
||||||
|
}
|
||||||
|
})
|
287
src/components/settings/AccountsTab.tsx
Normal file
287
src/components/settings/AccountsTab.tsx
Normal file
|
@ -0,0 +1,287 @@
|
||||||
|
import type { BaseTelegramClient, User } from '@mtcute/web'
|
||||||
|
import type { TelegramAccount } from '../../store/accounts.ts'
|
||||||
|
import type { LoginStep, StepContext } from '../login/Login.tsx'
|
||||||
|
import { timers } from '@fuman/utils'
|
||||||
|
import {
|
||||||
|
LucideBot,
|
||||||
|
LucideEllipsis,
|
||||||
|
LucideLogIn,
|
||||||
|
LucidePlus,
|
||||||
|
LucideSearch,
|
||||||
|
LucideTrash,
|
||||||
|
LucideUser,
|
||||||
|
LucideX,
|
||||||
|
} from 'lucide-solid'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
|
||||||
|
import { Badge } from '../../lib/components/ui/badge.tsx'
|
||||||
|
import { Button } from '../../lib/components/ui/button.tsx'
|
||||||
|
import { Dialog, DialogContent } from '../../lib/components/ui/dialog.tsx'
|
||||||
|
|
||||||
|
import { TextField, TextFieldFrame, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
|
||||||
|
import { createInternalClient, deleteAccount } from '../../lib/telegram.ts'
|
||||||
|
import { cn } from '../../lib/utils.ts'
|
||||||
|
import { $accounts, $activeAccountId } from '../../store/accounts.ts'
|
||||||
|
import { useStore } from '../../store/use-store.ts'
|
||||||
|
import { AccountAvatar } from '../AccountAvatar.tsx'
|
||||||
|
import { LoginForm } from '../login/Login.tsx'
|
||||||
|
import { ImportDropdown } from './import/ImportDropdown.tsx'
|
||||||
|
|
||||||
|
function AddAccountDialog(props: {
|
||||||
|
show: boolean
|
||||||
|
testMode: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAccountCreated: (accountId: string, user: User, dcId: number) => void
|
||||||
|
}) {
|
||||||
|
const [client, setClient] = createSignal<BaseTelegramClient | undefined>(undefined)
|
||||||
|
|
||||||
|
let accountId: string
|
||||||
|
let closeTimeout: timers.Timer | undefined
|
||||||
|
let finished = false
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
finished = false
|
||||||
|
} else {
|
||||||
|
props.onClose()
|
||||||
|
client()?.close()
|
||||||
|
timers.clearTimeout(closeTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStepChange(step: LoginStep, ctx: Partial<StepContext>) {
|
||||||
|
if (step === 'done') {
|
||||||
|
finished = true
|
||||||
|
const client_ = client()!
|
||||||
|
const dcs = await client_.mt.storage.dcs.fetch()
|
||||||
|
props.onAccountCreated?.(accountId, ctx.done!.user, dcs?.main.id ?? 2)
|
||||||
|
closeTimeout = timers.setTimeout(() => {
|
||||||
|
props.onClose()
|
||||||
|
client_.close()
|
||||||
|
}, 2500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(on(() => props.show, async (show) => {
|
||||||
|
if (!show) {
|
||||||
|
if (!finished && accountId) {
|
||||||
|
await client()?.close()
|
||||||
|
await deleteAccount(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accountId = nanoid()
|
||||||
|
setClient(createInternalClient(accountId, props.testMode))
|
||||||
|
}))
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
timers.clearTimeout(closeTimeout)
|
||||||
|
client()?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.show}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
{props.testMode && (
|
||||||
|
<Badge class="absolute left-4 top-4" variant="secondary">
|
||||||
|
Test server
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div class="flex h-[420px] flex-col justify-center">
|
||||||
|
{client() && (
|
||||||
|
<LoginForm
|
||||||
|
client={client()!}
|
||||||
|
onStepChange={handleStepChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRow(props: {
|
||||||
|
account: TelegramAccount
|
||||||
|
active: boolean
|
||||||
|
onSetActive: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="flex max-w-full flex-row overflow-hidden rounded-md border border-border p-2">
|
||||||
|
<AccountAvatar
|
||||||
|
class="mr-2 size-9 rounded-sm shadow-sm"
|
||||||
|
account={props.account}
|
||||||
|
/>
|
||||||
|
<div class="flex max-w-full flex-col overflow-hidden">
|
||||||
|
<div class="flex items-center gap-1 truncate text-sm font-medium">
|
||||||
|
{props.account.bot ? <LucideBot class="size-4 shrink-0" /> : <LucideUser class="size-4 shrink-0" />}
|
||||||
|
{props.account.name}
|
||||||
|
{props.account.testMode && (
|
||||||
|
<Badge class="h-4 text-xs font-normal" variant="secondary">
|
||||||
|
Test server
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{props.active && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-xs text-muted-foreground">
|
||||||
|
ID:
|
||||||
|
{' '}
|
||||||
|
{props.account.telegramId}
|
||||||
|
{' • '}
|
||||||
|
DC:
|
||||||
|
{' '}
|
||||||
|
{props.account.dcId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<div class="mr-1 flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
>
|
||||||
|
<LucideEllipsis class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
disabled={props.active}
|
||||||
|
onClick={props.onSetActive}
|
||||||
|
>
|
||||||
|
<LucideLogIn class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-8 hover:bg-error"
|
||||||
|
>
|
||||||
|
<LucideTrash class="size-4 text-error-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountsTab() {
|
||||||
|
const accounts = useStore($accounts)
|
||||||
|
const activeAccountId = useStore($activeAccountId)
|
||||||
|
const [showAddAccount, setShowAddAccount] = createSignal(false)
|
||||||
|
const [addAccountTestMode, setAddAccountTestMode] = createSignal(false)
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
|
||||||
|
function handleAddAccount(e: MouseEvent) {
|
||||||
|
setShowAddAccount(true)
|
||||||
|
setAddAccountTestMode(e.ctrlKey || e.metaKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAccountCreated(accountId: string, user: User, dcId: number) {
|
||||||
|
$accounts.set([
|
||||||
|
...$accounts.get(),
|
||||||
|
{
|
||||||
|
id: accountId,
|
||||||
|
name: user.displayName,
|
||||||
|
telegramId: user.id,
|
||||||
|
bot: user.isBot,
|
||||||
|
testMode: addAccountTestMode(),
|
||||||
|
dcId,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
$activeAccountId.set(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAccounts = createMemo(() => {
|
||||||
|
const query = searchQuery().toLowerCase().trim()
|
||||||
|
if (query === '') {
|
||||||
|
return accounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts().filter((account) => {
|
||||||
|
return account.name.toLowerCase().includes(query) || account.telegramId.toString().includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show
|
||||||
|
when={accounts().length !== 0}
|
||||||
|
fallback={(
|
||||||
|
<div class="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||||
|
No accounts yet
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddAccount}
|
||||||
|
>
|
||||||
|
<LucidePlus class="mr-2 size-4" />
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="max-w-full overflow-hidden p-2">
|
||||||
|
<div class="mb-2 flex h-8 items-center gap-2">
|
||||||
|
<TextFieldRoot>
|
||||||
|
<TextFieldFrame class="h-7 items-center shadow-none">
|
||||||
|
<LucideSearch class="mr-2 size-3 shrink-0 text-muted-foreground" />
|
||||||
|
<TextField
|
||||||
|
placeholder="Search"
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={e => setSearchQuery(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<LucideX
|
||||||
|
class={cn(
|
||||||
|
'shrink-0 text-muted-foreground size-3 cursor-pointer hover:text-foreground',
|
||||||
|
searchQuery() === '' && 'opacity-0 pointer-events-none',
|
||||||
|
)}
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
/>
|
||||||
|
</TextFieldFrame>
|
||||||
|
</TextFieldRoot>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleAddAccount}
|
||||||
|
>
|
||||||
|
<LucidePlus class="mr-2 size-3" />
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ImportDropdown />
|
||||||
|
</div>
|
||||||
|
<div class="flex max-w-full flex-col gap-1 overflow-hidden">
|
||||||
|
<For each={filteredAccounts()}>
|
||||||
|
{account => (
|
||||||
|
<AccountRow
|
||||||
|
account={account}
|
||||||
|
active={account.id === activeAccountId()}
|
||||||
|
onSetActive={() => $activeAccountId.set(account.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<AddAccountDialog
|
||||||
|
show={showAddAccount()}
|
||||||
|
testMode={addAccountTestMode()}
|
||||||
|
onClose={() => setShowAddAccount(false)}
|
||||||
|
onAccountCreated={handleAccountCreated}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
83
src/components/settings/Settings.tsx
Normal file
83
src/components/settings/Settings.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import type { JSX } from 'solid-js'
|
||||||
|
import { LucideCode, LucideLibrary, LucideUsers } from 'lucide-solid'
|
||||||
|
import { For } from 'solid-js'
|
||||||
|
import { Button } from '../../lib/components/ui/button.tsx'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
} from '../../lib/components/ui/dialog.tsx'
|
||||||
|
import { cn } from '../../lib/utils.ts'
|
||||||
|
import { AccountsTab } from './AccountsTab.tsx'
|
||||||
|
|
||||||
|
export type SettingsTab =
|
||||||
|
| 'accounts'
|
||||||
|
| 'libraries'
|
||||||
|
| 'about'
|
||||||
|
interface TabDefinition {
|
||||||
|
id: SettingsTab
|
||||||
|
title: string
|
||||||
|
icon: (props: { class?: string }) => JSX.Element
|
||||||
|
content: () => JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: Array<TabDefinition> = [
|
||||||
|
{
|
||||||
|
id: 'accounts',
|
||||||
|
title: 'Accounts',
|
||||||
|
icon: LucideUsers,
|
||||||
|
content: AccountsTab,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'libraries',
|
||||||
|
title: 'Libraries',
|
||||||
|
icon: LucideLibrary,
|
||||||
|
content: () => <div>asd</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
title: 'About',
|
||||||
|
icon: LucideCode,
|
||||||
|
content: () => <div>asd</div>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SettingsDialog(props: {
|
||||||
|
show: boolean
|
||||||
|
onClose: () => void
|
||||||
|
tab: SettingsTab
|
||||||
|
onTabChange: (tab: SettingsTab) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.show}
|
||||||
|
onOpenChange={open => !open && props.onClose()}
|
||||||
|
>
|
||||||
|
<DialogContent class="h-[calc(100vh-96px)] w-[calc(100vw-96px)] max-w-[960px] overflow-auto p-0">
|
||||||
|
<div class="flex max-w-full flex-row overflow-hidden">
|
||||||
|
<div class="flex h-full w-60 flex-col gap-1 p-2">
|
||||||
|
<For each={tabs}>
|
||||||
|
{tab => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => props.onTabChange(tab.id)}
|
||||||
|
class={cn(
|
||||||
|
'text-left justify-start text-sm h-8 text-muted-foreground',
|
||||||
|
props.tab === tab.id && 'bg-accent text-accent-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon class="mr-2 size-4" />
|
||||||
|
{tab.title}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="w-px bg-border" />
|
||||||
|
<div class="size-full max-w-full overflow-hidden">
|
||||||
|
{tabs.find(tab => tab.id === props.tab)?.content()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
160
src/components/settings/import/AuthKeyImportDialog.tsx
Normal file
160
src/components/settings/import/AuthKeyImportDialog.tsx
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import type { InputStringSessionData } from '@mtcute/web/utils.js'
|
||||||
|
import { hex } from '@fuman/utils'
|
||||||
|
import { DC_MAPPING_PROD, DC_MAPPING_TEST } from '@mtcute/convert'
|
||||||
|
import { createEffect, createSignal, on } from 'solid-js'
|
||||||
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
|
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
|
||||||
|
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
|
||||||
|
import { deleteAccount, importAccount } from '../../../lib/telegram.ts'
|
||||||
|
import { $accounts } from '../../../store/accounts.ts'
|
||||||
|
|
||||||
|
export function AuthKeyImportDialog(props: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [authKeyInputRef, setAuthKeyInputRef] = createSignal<HTMLTextAreaElement | undefined>()
|
||||||
|
const [dcId, setDcId] = createSignal<string>('2')
|
||||||
|
const [testMode, setTestMode] = createSignal(false)
|
||||||
|
const [error, setError] = createSignal<string | undefined>()
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
|
||||||
|
let abortController: AbortController | undefined
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!['1', '2', '4', '5'].includes(dcId())) {
|
||||||
|
setError('Invalid datacenter ID (must be 1, 2, 4 or 5)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
abortController?.abort()
|
||||||
|
abortController = new AbortController()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const oldAccounts = $accounts.get()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testMode_ = testMode()
|
||||||
|
const authKey = hex.decode(authKeyInputRef()!.value)
|
||||||
|
if (authKey.length !== 256) {
|
||||||
|
setError('Invalid auth key (must be 256 bytes long)')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: InputStringSessionData = {
|
||||||
|
authKey: hex.decode(authKeyInputRef()!.value),
|
||||||
|
testMode: testMode_,
|
||||||
|
primaryDcs: (testMode_ ? DC_MAPPING_TEST : DC_MAPPING_PROD)[Number(dcId())],
|
||||||
|
}
|
||||||
|
const account = await importAccount(session, abortController.signal)
|
||||||
|
|
||||||
|
// check if account already exists
|
||||||
|
if (oldAccounts.some(it => it.telegramId === account.telegramId)) {
|
||||||
|
deleteAccount(account.id)
|
||||||
|
setError(`Account already exists (user ID: ${account.telegramId})`)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$accounts.set([
|
||||||
|
...$accounts.get(),
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
props.onClose()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
setError(e.message)
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
createEffect(on(() => props.open, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
abortController?.abort()
|
||||||
|
setLoading(false)
|
||||||
|
abortController = undefined
|
||||||
|
setError(undefined)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={open => !open && props.onClose()}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
Import auth key
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription>
|
||||||
|
<TextFieldRoot>
|
||||||
|
<TextFieldLabel class="text-foreground">
|
||||||
|
Datacenter ID
|
||||||
|
</TextFieldLabel>
|
||||||
|
<TextFieldFrame>
|
||||||
|
<TextField
|
||||||
|
class="w-full"
|
||||||
|
value={dcId()}
|
||||||
|
onInput={e => setDcId(e.currentTarget.value.replace(/\D/g, ''))}
|
||||||
|
/>
|
||||||
|
</TextFieldFrame>
|
||||||
|
</TextFieldRoot>
|
||||||
|
|
||||||
|
<TextFieldRoot class="mt-2" validationState={error() ? 'invalid' : 'valid'}>
|
||||||
|
<TextFieldLabel class="flex flex-row items-center justify-between text-foreground">
|
||||||
|
Hex-encoded auth key
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-xs font-normal text-muted-foreground hover:text-neutral-900"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.readText().then((text) => {
|
||||||
|
const input = authKeyInputRef()!
|
||||||
|
input.value = text
|
||||||
|
input.focus()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
paste
|
||||||
|
</a>
|
||||||
|
</TextFieldLabel>
|
||||||
|
<TextFieldFrame class="h-auto">
|
||||||
|
<TextField
|
||||||
|
class="size-full h-40 resize-none font-mono"
|
||||||
|
as="textarea"
|
||||||
|
ref={setAuthKeyInputRef}
|
||||||
|
onInput={() => setError(undefined)}
|
||||||
|
/>
|
||||||
|
</TextFieldFrame>
|
||||||
|
<TextFieldErrorMessage>
|
||||||
|
{error()}
|
||||||
|
</TextFieldErrorMessage>
|
||||||
|
</TextFieldRoot>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
class="mt-2 flex flex-row items-center gap-2"
|
||||||
|
checked={testMode()}
|
||||||
|
onChange={setTestMode}
|
||||||
|
>
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel class="text-foreground">
|
||||||
|
Use test servers
|
||||||
|
</CheckboxLabel>
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="mt-6 w-full"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
{loading() ? 'Checking...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
85
src/components/settings/import/ImportDropdown.tsx
Normal file
85
src/components/settings/import/ImportDropdown.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
|
||||||
|
import type { StringSessionLibName } from './StringSessionImportDialog.tsx'
|
||||||
|
import { LucideChevronRight, LucideDownload, LucideKeyRound, LucideLaptop, LucideTextCursorInput } from 'lucide-solid'
|
||||||
|
import { createSignal, For } from 'solid-js'
|
||||||
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '../../../lib/components/ui/dropdown-menu.tsx'
|
||||||
|
import { AuthKeyImportDialog } from './AuthKeyImportDialog.tsx'
|
||||||
|
import { StringSessionDefs, StringSessionImportDialog } from './StringSessionImportDialog.tsx'
|
||||||
|
|
||||||
|
export function ImportDropdown() {
|
||||||
|
const [showImportStringSession, setShowImportStringSession] = createSignal(false)
|
||||||
|
const [stringSessionLibName, setStringSessionLibName] = createSignal<StringSessionLibName>('mtcute')
|
||||||
|
const [showImportAuthKey, setShowImportAuthKey] = createSignal(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
as={(props: DropdownMenuTriggerProps) => (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<LucideDownload class="mr-2 size-3" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem class="py-1 text-xs" onClick={() => setShowImportAuthKey(true)}>
|
||||||
|
<LucideKeyRound class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
|
Auth key
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger class="py-1 text-xs">
|
||||||
|
<LucideTextCursorInput class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
|
String session
|
||||||
|
<LucideChevronRight class="ml-2 size-3.5" />
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<For each={StringSessionDefs}>
|
||||||
|
{def => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
class="py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setStringSessionLibName(def.name)
|
||||||
|
setShowImportStringSession(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{def.displayName}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuItem class="py-1 text-xs">
|
||||||
|
<LucideLaptop class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
|
Desktop (tdata)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<StringSessionImportDialog
|
||||||
|
open={showImportStringSession()}
|
||||||
|
onClose={() => setShowImportStringSession(false)}
|
||||||
|
chosenLibName={stringSessionLibName()}
|
||||||
|
onChosenLibName={setStringSessionLibName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuthKeyImportDialog
|
||||||
|
open={showImportAuthKey()}
|
||||||
|
onClose={() => setShowImportAuthKey(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
187
src/components/settings/import/StringSessionImportDialog.tsx
Normal file
187
src/components/settings/import/StringSessionImportDialog.tsx
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
import type { StringSessionData } from '@mtcute/web/utils.js'
|
||||||
|
import { readStringSession } from '@mtcute/web/utils.js'
|
||||||
|
import { createEffect, createSignal, on } from 'solid-js'
|
||||||
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../lib/components/ui/select.tsx'
|
||||||
|
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
|
||||||
|
import { deleteAccount, importAccount } from '../../../lib/telegram.ts'
|
||||||
|
import { $accounts } from '../../../store/accounts.ts'
|
||||||
|
|
||||||
|
export type StringSessionLibName =
|
||||||
|
| 'mtcute'
|
||||||
|
| 'pyrogram'
|
||||||
|
| 'telethon'
|
||||||
|
| 'mtkruto'
|
||||||
|
| 'gramjs'
|
||||||
|
|
||||||
|
export const StringSessionDefs: {
|
||||||
|
name: StringSessionLibName
|
||||||
|
displayName: string
|
||||||
|
}[] = [
|
||||||
|
{ name: 'mtcute', displayName: 'mtcute' },
|
||||||
|
{ name: 'telethon', displayName: 'Telethon v1.x' },
|
||||||
|
{ name: 'gramjs', displayName: 'GramJS' },
|
||||||
|
{ name: 'pyrogram', displayName: 'Pyrogram' },
|
||||||
|
{ name: 'mtkruto', displayName: 'MTKruto' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function convert(libName: StringSessionLibName, session: string): Promise<StringSessionData> {
|
||||||
|
switch (libName) {
|
||||||
|
case 'mtcute': {
|
||||||
|
return readStringSession(session)
|
||||||
|
}
|
||||||
|
case 'telethon': {
|
||||||
|
const { convertFromTelethonSession } = await import('@mtcute/convert')
|
||||||
|
return convertFromTelethonSession(session)
|
||||||
|
}
|
||||||
|
case 'gramjs': {
|
||||||
|
const { convertFromGramjsSession } = await import('@mtcute/convert')
|
||||||
|
return convertFromGramjsSession(session)
|
||||||
|
}
|
||||||
|
case 'pyrogram': {
|
||||||
|
const { convertFromPyrogramSession } = await import('@mtcute/convert')
|
||||||
|
return convertFromPyrogramSession(session)
|
||||||
|
}
|
||||||
|
case 'mtkruto': {
|
||||||
|
const { convertFromMtkrutoSession } = await import('@mtcute/convert')
|
||||||
|
return convertFromMtkrutoSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StringSessionImportDialog(props: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
chosenLibName: StringSessionLibName
|
||||||
|
onChosenLibName: (name: StringSessionLibName) => void
|
||||||
|
}) {
|
||||||
|
const [inputRef, setInputRef] = createSignal<HTMLTextAreaElement | undefined>()
|
||||||
|
const [error, setError] = createSignal<string | undefined>()
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
|
||||||
|
let abortController: AbortController | undefined
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
abortController?.abort()
|
||||||
|
abortController = new AbortController()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const oldAccounts = $accounts.get()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const converted = await convert(props.chosenLibName, inputRef()!.value)
|
||||||
|
|
||||||
|
// check if account exists
|
||||||
|
if (converted.self && oldAccounts.some(it => it.telegramId === converted.self!.userId)) {
|
||||||
|
setError(`Account already exists (user ID: ${converted.self!.userId})`)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await importAccount(converted, abortController.signal)
|
||||||
|
|
||||||
|
// check once again if account already exists
|
||||||
|
if (oldAccounts.some(it => it.telegramId === account.telegramId)) {
|
||||||
|
deleteAccount(account.id)
|
||||||
|
setError(`Account already exists (user ID: ${account.telegramId})`)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$accounts.set([
|
||||||
|
...$accounts.get(),
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
props.onClose()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
setError(e.message)
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
createEffect(on(() => props.open, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
abortController?.abort()
|
||||||
|
setLoading(false)
|
||||||
|
abortController = undefined
|
||||||
|
setError(undefined)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={open => !open && props.onClose()}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
Import string session
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription>
|
||||||
|
<div class="mb-1 font-medium text-primary">
|
||||||
|
Library
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
options={StringSessionDefs}
|
||||||
|
optionValue="name"
|
||||||
|
optionTextValue="displayName"
|
||||||
|
value={StringSessionDefs.find(def => def.name === props.chosenLibName)}
|
||||||
|
onChange={e => e && props.onChosenLibName(e.name)}
|
||||||
|
itemComponent={props => <SelectItem item={props.item}>{props.item.rawValue.displayName}</SelectItem>}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue<typeof StringSessionDefs[number]>>
|
||||||
|
{state => state.selectedOption()?.displayName}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<TextFieldRoot class="mt-4" validationState={error() ? 'invalid' : 'valid'}>
|
||||||
|
<TextFieldLabel class="flex flex-row items-center justify-between text-foreground">
|
||||||
|
Session string
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-xs font-normal text-muted-foreground hover:text-neutral-900"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.readText().then((text) => {
|
||||||
|
const input = inputRef()!
|
||||||
|
input.value = text
|
||||||
|
input.focus()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
paste
|
||||||
|
</a>
|
||||||
|
</TextFieldLabel>
|
||||||
|
<TextFieldFrame class="h-auto">
|
||||||
|
<TextField
|
||||||
|
class="size-full h-40 resize-none font-mono"
|
||||||
|
as="textarea"
|
||||||
|
ref={setInputRef}
|
||||||
|
onInput={() => setError(undefined)}
|
||||||
|
/>
|
||||||
|
</TextFieldFrame>
|
||||||
|
<TextFieldErrorMessage>
|
||||||
|
{error()}
|
||||||
|
</TextFieldErrorMessage>
|
||||||
|
</TextFieldRoot>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="mt-6 w-full"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
{loading() ? 'Checking...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
12
src/index.tsx
Normal file
12
src/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
/* @refresh reload */
|
||||||
|
import { render } from 'solid-js/web'
|
||||||
|
|
||||||
|
import { App } from './App'
|
||||||
|
import { registerServiceWorker } from './sw/register.ts'
|
||||||
|
import './app.css'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
|
||||||
|
registerServiceWorker()
|
||||||
|
|
||||||
|
render(() => <App />, root!)
|
48
src/lib/components/country-icon.tsx
Normal file
48
src/lib/components/country-icon.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { onMount } from 'solid-js'
|
||||||
|
|
||||||
|
const HAS_NATIVE_EMOJI = navigator.userAgent.includes('Apple')
|
||||||
|
const EMOJI_POLYFILL_FONT = 'https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2'
|
||||||
|
|
||||||
|
let hasAddedEmojiFont = false
|
||||||
|
|
||||||
|
export function CountryIcon(props: { class?: string, country: string }) {
|
||||||
|
onMount(() => {
|
||||||
|
if (HAS_NATIVE_EMOJI) return
|
||||||
|
|
||||||
|
if (!hasAddedEmojiFont) {
|
||||||
|
hasAddedEmojiFont = true
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.innerHTML = `
|
||||||
|
@font-face {
|
||||||
|
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||||
|
font-family: 'TwemojiCountries';
|
||||||
|
font-display: swap;
|
||||||
|
src: url('${EMOJI_POLYFILL_FONT}') format('woff2');
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emoji = () => {
|
||||||
|
const upper = props.country.toUpperCase()
|
||||||
|
if (upper === 'FT') return '🏴☠️'
|
||||||
|
let res = ''
|
||||||
|
for (let i = 0; i < upper.length; i++) {
|
||||||
|
res += String.fromCodePoint(0x1F1E6 + upper.charCodeAt(i) - 'A'.charCodeAt(0))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class={clsx(
|
||||||
|
'font-[TwemojiCountries]',
|
||||||
|
props.class,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{emoji()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
54
src/lib/components/ui/avatar.tsx
Normal file
54
src/lib/components/ui/avatar.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type { ValidComponent } from 'solid-js'
|
||||||
|
import * as ImagePrimitive from '@kobalte/core/image'
|
||||||
|
import { splitProps } from 'solid-js'
|
||||||
|
|
||||||
|
import { cn } from '../../utils'
|
||||||
|
|
||||||
|
type AvatarRootProps<T extends ValidComponent = 'span'> = ImagePrimitive.ImageRootProps<T> & {
|
||||||
|
class?: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar<T extends ValidComponent = 'span'>(props: PolymorphicProps<T, AvatarRootProps<T>>) {
|
||||||
|
const [local, others] = splitProps(props as AvatarRootProps, ['class'])
|
||||||
|
return (
|
||||||
|
<ImagePrimitive.Root
|
||||||
|
class={cn('relative flex size-10 shrink-0 overflow-hidden rounded-full', local.class)}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvatarImageProps<T extends ValidComponent = 'img'> = ImagePrimitive.ImageImgProps<T> & {
|
||||||
|
class?: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage<T extends ValidComponent = 'img'>(props: PolymorphicProps<T, AvatarImageProps<T>>) {
|
||||||
|
const [local, others] = splitProps(props as AvatarImageProps, ['class'])
|
||||||
|
return <ImagePrimitive.Img class={cn('aspect-square size-full object-cover', local.class)} {...others} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvatarFallbackProps<T extends ValidComponent = 'span'> =
|
||||||
|
ImagePrimitive.ImageFallbackProps<T> & { class?: string | undefined }
|
||||||
|
|
||||||
|
function AvatarFallback<T extends ValidComponent = 'span'>(props: PolymorphicProps<T, AvatarFallbackProps<T>>) {
|
||||||
|
const [local, others] = splitProps(props as AvatarFallbackProps, ['class'])
|
||||||
|
return (
|
||||||
|
<ImagePrimitive.Fallback
|
||||||
|
class={cn('flex size-full items-center justify-center rounded-full bg-muted', local.class)}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeAvatarFallbackText(displayName: string) {
|
||||||
|
let res = ''
|
||||||
|
const words = displayName.split(' ')
|
||||||
|
res += [...words[0]][0]
|
||||||
|
if (words.length > 1) {
|
||||||
|
res += ` ${[...words[1]][0]}`
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarFallback, AvatarImage }
|
46
src/lib/components/ui/badge.tsx
Normal file
46
src/lib/components/ui/badge.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
import { type ComponentProps, splitProps } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
'inline-flex items-center border transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'rounded-md px-2.5 py-0.5 text-xs font-semibold',
|
||||||
|
sm: 'rounded-full px-1.5 text-2xs',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export function Badge(props: ComponentProps<'div'> & VariantProps<typeof badgeVariants>) {
|
||||||
|
const [local, rest] = splitProps(props, ['class', 'variant', 'size'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
badgeVariants({
|
||||||
|
variant: local.variant,
|
||||||
|
size: local.size,
|
||||||
|
}),
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
51
src/lib/components/ui/button.tsx
Normal file
51
src/lib/components/ui/button.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import type { JSX, ValidComponent } from 'solid-js'
|
||||||
|
import * as ButtonPrimitive from '@kobalte/core/button'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
import { splitProps } from 'solid-js'
|
||||||
|
|
||||||
|
import { cn } from '../../utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex select-none items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
xs: 'h-7 rounded-md px-2 text-xs',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'size-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
type ButtonProps<T extends ValidComponent = 'button'> = ButtonPrimitive.ButtonRootProps<T> &
|
||||||
|
VariantProps<typeof buttonVariants> & { class?: string | undefined, children?: JSX.Element }
|
||||||
|
|
||||||
|
function Button<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, ButtonProps<T>>) {
|
||||||
|
const [local, others] = splitProps(props as ButtonProps, ['variant', 'size', 'class'])
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive.Root
|
||||||
|
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ButtonProps }
|
||||||
|
export { Button, buttonVariants }
|
53
src/lib/components/ui/checkbox.tsx
Normal file
53
src/lib/components/ui/checkbox.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import type { CheckboxControlProps } from '@kobalte/core/checkbox'
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type { ValidComponent, VoidProps } from 'solid-js'
|
||||||
|
import { Checkbox as CheckboxPrimitive } from '@kobalte/core/checkbox'
|
||||||
|
import { splitProps } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
export const CheckboxLabel = CheckboxPrimitive.Label
|
||||||
|
export const Checkbox = CheckboxPrimitive
|
||||||
|
export const CheckboxErrorMessage = CheckboxPrimitive.ErrorMessage
|
||||||
|
export const CheckboxDescription = CheckboxPrimitive.Description
|
||||||
|
|
||||||
|
type checkboxControlProps<T extends ValidComponent = 'div'> = VoidProps<
|
||||||
|
CheckboxControlProps<T> & { class?: string }
|
||||||
|
>
|
||||||
|
|
||||||
|
export function CheckboxControl<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, checkboxControlProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as checkboxControlProps, [
|
||||||
|
'class',
|
||||||
|
'children',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CheckboxPrimitive.Input class="[&:focus-visible+div]:outline-none [&:focus-visible+div]:ring-[1.5px] [&:focus-visible+div]:ring-ring [&:focus-visible+div]:ring-offset-2 [&:focus-visible+div]:ring-offset-background" />
|
||||||
|
<CheckboxPrimitive.Control
|
||||||
|
class={cn(
|
||||||
|
'h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring data-[disabled]:cursor-not-allowed data-[checked]:bg-primary data-[checked]:text-primary-foreground data-[disabled]:opacity-50',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator class="flex items-center justify-center text-current">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="size-4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m5 12l5 5L20 7"
|
||||||
|
/>
|
||||||
|
<title>Checkbox</title>
|
||||||
|
</svg>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Control>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
112
src/lib/components/ui/dialog.tsx
Normal file
112
src/lib/components/ui/dialog.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import type {
|
||||||
|
DialogContentProps,
|
||||||
|
DialogDescriptionProps,
|
||||||
|
DialogTitleProps,
|
||||||
|
} from '@kobalte/core/dialog'
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type { ComponentProps, ParentProps, ValidComponent } from 'solid-js'
|
||||||
|
import { Dialog as DialogPrimitive } from '@kobalte/core/dialog'
|
||||||
|
import { LucideX } from 'lucide-solid'
|
||||||
|
import { splitProps } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
export const Dialog = DialogPrimitive
|
||||||
|
export const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
export const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
type dialogContentProps<T extends ValidComponent = 'div'> = ParentProps<
|
||||||
|
DialogContentProps<T> & {
|
||||||
|
class?: string
|
||||||
|
withoutCloseButton?: boolean
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export function DialogContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dialogContentProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dialogContentProps, [
|
||||||
|
'class',
|
||||||
|
'children',
|
||||||
|
'withoutCloseButton',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
class={cn(
|
||||||
|
'fixed inset-0 z-50 bg-background/80 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0',
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
class={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg data-[closed]:duration-200 data-[expanded]:duration-200 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lg md:w-full',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
<DialogPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-[opacity,box-shadow] hover:opacity-100 focus:outline-none focus:ring-[1.5px] focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<LucideX class="size-4" />
|
||||||
|
</DialogPrimitive.CloseButton>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dialogTitleProps<T extends ValidComponent = 'h2'> = DialogTitleProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogTitle<T extends ValidComponent = 'h2'>(props: PolymorphicProps<T, dialogTitleProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dialogTitleProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
class={cn('text-lg font-semibold text-foreground', local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dialogDescriptionProps<T extends ValidComponent = 'p'> =
|
||||||
|
DialogDescriptionProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogDescription<T extends ValidComponent = 'p'>(props: PolymorphicProps<T, dialogDescriptionProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dialogDescriptionProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
class={cn('text-sm text-muted-foreground', local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHeader(props: ComponentProps<'div'>) {
|
||||||
|
const [local, rest] = splitProps(props, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex flex-col space-y-2 text-center sm:text-left',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogFooter(props: ComponentProps<'div'>) {
|
||||||
|
const [local, rest] = splitProps(props, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
280
src/lib/components/ui/dropdown-menu.tsx
Normal file
280
src/lib/components/ui/dropdown-menu.tsx
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
import type {
|
||||||
|
DropdownMenuCheckboxItemProps,
|
||||||
|
DropdownMenuContentProps,
|
||||||
|
DropdownMenuGroupLabelProps,
|
||||||
|
DropdownMenuItemLabelProps,
|
||||||
|
DropdownMenuItemProps,
|
||||||
|
DropdownMenuRadioItemProps,
|
||||||
|
DropdownMenuRootProps,
|
||||||
|
DropdownMenuSeparatorProps,
|
||||||
|
DropdownMenuSubTriggerProps,
|
||||||
|
} from '@kobalte/core/dropdown-menu'
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type { ComponentProps, ParentProps, ValidComponent } from 'solid-js'
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from '@kobalte/core/dropdown-menu'
|
||||||
|
import { mergeProps, splitProps } from 'solid-js'
|
||||||
|
|
||||||
|
import { cn } from '../../utils'
|
||||||
|
|
||||||
|
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
export const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
export const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
export function DropdownMenu(props: DropdownMenuRootProps) {
|
||||||
|
const merge = mergeProps<DropdownMenuRootProps[]>({ gutter: 4 }, props)
|
||||||
|
|
||||||
|
return <DropdownMenuPrimitive {...merge} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuContentProps<T extends ValidComponent = 'div'> =
|
||||||
|
DropdownMenuContentProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuContentProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuContentProps, [
|
||||||
|
'class',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
class={cn(
|
||||||
|
'min-w-8rem z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuItemProps<T extends ValidComponent = 'div'> =
|
||||||
|
DropdownMenuItemProps<T> & {
|
||||||
|
class?: string
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuItemProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuItemProps, [
|
||||||
|
'class',
|
||||||
|
'inset',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
class={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
local.inset && 'pl-8',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuGroupLabelProps<T extends ValidComponent = 'span'> =
|
||||||
|
DropdownMenuGroupLabelProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuGroupLabel<T extends ValidComponent = 'span'>(props: PolymorphicProps<T, dropdownMenuGroupLabelProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuGroupLabelProps, [
|
||||||
|
'class',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.GroupLabel
|
||||||
|
as="div"
|
||||||
|
class={cn('px-2 py-1.5 text-sm font-semibold', local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuItemLabelProps<T extends ValidComponent = 'div'> =
|
||||||
|
DropdownMenuItemLabelProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuItemLabel<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuItemLabelProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuItemLabelProps, [
|
||||||
|
'class',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.ItemLabel
|
||||||
|
as="div"
|
||||||
|
class={cn('px-2 py-1.5 text-sm font-semibold', local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuSeparatorProps<T extends ValidComponent = 'hr'> =
|
||||||
|
DropdownMenuSeparatorProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuSeparator<T extends ValidComponent = 'hr'>(props: PolymorphicProps<T, dropdownMenuSeparatorProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuSeparatorProps, [
|
||||||
|
'class',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
class={cn('-mx-1 my-1 h-px bg-muted', local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuShortcut(props: ComponentProps<'span'>) {
|
||||||
|
const [local, rest] = splitProps(props, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class={cn('ml-auto text-xs tracking-widest opacity-60', local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuSubTriggerProps<T extends ValidComponent = 'div'> =
|
||||||
|
ParentProps<
|
||||||
|
DropdownMenuSubTriggerProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export function DropdownMenuSubTrigger<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuSubTriggerProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuSubTriggerProps, [
|
||||||
|
'class',
|
||||||
|
'children',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
class={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[expanded]:bg-accent',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuSubContentProps<T extends ValidComponent = 'div'> =
|
||||||
|
DropdownMenuSubTriggerProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuSubContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuSubContentProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuSubContentProps, [
|
||||||
|
'class',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
class={cn(
|
||||||
|
'min-w-8rem z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuCheckboxItemProps<T extends ValidComponent = 'div'> =
|
||||||
|
ParentProps<
|
||||||
|
DropdownMenuCheckboxItemProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export function DropdownMenuCheckboxItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuCheckboxItemProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuCheckboxItemProps, [
|
||||||
|
'class',
|
||||||
|
'children',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
class={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex size-4 items-center justify-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="size-4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m5 12l5 5L20 7"
|
||||||
|
/>
|
||||||
|
<title>Checkbox</title>
|
||||||
|
</svg>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
{props.children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dropdownMenuRadioItemProps<T extends ValidComponent = 'div'> = ParentProps<
|
||||||
|
DropdownMenuRadioItemProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export function DropdownMenuRadioItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuRadioItemProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as dropdownMenuRadioItemProps, [
|
||||||
|
'class',
|
||||||
|
'children',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
class={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex size-4 items-center justify-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="size-2"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M0 0h24v24H0z" />
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M7 3.34a10 10 0 1 1-4.995 8.984L2 12l.005-.324A10 10 0 0 1 7 3.34"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<title>Radio</title>
|
||||||
|
</svg>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
{props.children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
19
src/lib/components/ui/label.tsx
Normal file
19
src/lib/components/ui/label.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Component, ComponentProps } from 'solid-js'
|
||||||
|
import { splitProps } from 'solid-js'
|
||||||
|
|
||||||
|
import { cn } from '../../utils'
|
||||||
|
|
||||||
|
const Label: Component<ComponentProps<'label'>> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['class'])
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
79
src/lib/components/ui/otp-field.tsx
Normal file
79
src/lib/components/ui/otp-field.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import type { DynamicProps, RootProps } from '@corvu/otp-field'
|
||||||
|
import type { ComponentProps, ValidComponent } from 'solid-js'
|
||||||
|
import OTPFieldPrimitive from '@corvu/otp-field'
|
||||||
|
import { Show, splitProps } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
export const OTPFieldInput = OTPFieldPrimitive.Input
|
||||||
|
|
||||||
|
type OTPFieldProps<T extends ValidComponent = 'div'> = RootProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OTPField<T extends ValidComponent = 'div'>(props: DynamicProps<T, OTPFieldProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OTPFieldPrimitive
|
||||||
|
class={cn(
|
||||||
|
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OTPFieldGroup(props: ComponentProps<'div'>) {
|
||||||
|
const [local, rest] = splitProps(props, ['class'])
|
||||||
|
|
||||||
|
return <div class={cn('flex items-center', local.class)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OTPFieldSeparator(props: ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/useAriaPropsForRole: []
|
||||||
|
<div role="separator" {...props}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="size-4"
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
>
|
||||||
|
<title>Separator</title>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5 7.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OTPFieldSlot(props: ComponentProps<'div'> & { index: number }) {
|
||||||
|
const [local, rest] = splitProps(props, ['class', 'index'])
|
||||||
|
const context = OTPFieldPrimitive.useContext()
|
||||||
|
const char = () => context.value()[local.index]
|
||||||
|
const hasFakeCaret = () =>
|
||||||
|
context.value().length === local.index && context.isInserting()
|
||||||
|
const isActive = () => context.activeSlots().includes(local.index)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'relative flex size-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-shadow first:rounded-l-md first:border-l last:rounded-r-md',
|
||||||
|
isActive() && 'z-10 ring-[1.5px] ring-ring',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{char()}
|
||||||
|
<Show when={hasFakeCaret()}>
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="h-4 w-px animate-caret-blink bg-foreground" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
62
src/lib/components/ui/resizable.tsx
Normal file
62
src/lib/components/ui/resizable.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import type { DynamicProps, HandleProps, RootProps } from '@corvu/resizable'
|
||||||
|
import type { ValidComponent } from 'solid-js'
|
||||||
|
import ResizablePrimitive from '@corvu/resizable'
|
||||||
|
import { Show, splitProps } from 'solid-js'
|
||||||
|
|
||||||
|
import { cn } from '../../utils'
|
||||||
|
|
||||||
|
type ResizableProps<T extends ValidComponent = 'div'> = RootProps<T> & { class?: string }
|
||||||
|
|
||||||
|
function Resizable<T extends ValidComponent = 'div'>(props: DynamicProps<T, ResizableProps<T>>) {
|
||||||
|
const [, rest] = splitProps(props as ResizableProps, ['class'])
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive
|
||||||
|
class={cn('flex size-full data-[orientation=vertical]:flex-col', props.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel
|
||||||
|
|
||||||
|
type ResizableHandleProps<T extends ValidComponent = 'button'> = HandleProps<T> & {
|
||||||
|
class?: string
|
||||||
|
withHandle?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizableHandle<T extends ValidComponent = 'button'>(props: DynamicProps<T, ResizableHandleProps<T>>) {
|
||||||
|
const [, rest] = splitProps(props as ResizableHandleProps, ['class', 'withHandle'])
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.Handle
|
||||||
|
class={cn(
|
||||||
|
'relative flex w-px shrink-0 items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90',
|
||||||
|
props.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<Show when={props.withHandle}>
|
||||||
|
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="size-2.5"
|
||||||
|
>
|
||||||
|
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</ResizablePrimitive.Handle>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Resizable, ResizableHandle, ResizablePanel }
|
121
src/lib/components/ui/select.tsx
Normal file
121
src/lib/components/ui/select.tsx
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type {
|
||||||
|
SelectContentProps,
|
||||||
|
SelectItemProps,
|
||||||
|
SelectTriggerProps,
|
||||||
|
} from '@kobalte/core/select'
|
||||||
|
import type { ParentProps, ValidComponent } from 'solid-js'
|
||||||
|
import { Select as SelectPrimitive } from '@kobalte/core/select'
|
||||||
|
import { splitProps } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
export const Select = SelectPrimitive
|
||||||
|
export const SelectValue = SelectPrimitive.Value
|
||||||
|
export const SelectDescription = SelectPrimitive.Description
|
||||||
|
export const SelectErrorMessage = SelectPrimitive.ErrorMessage
|
||||||
|
export const SelectItemDescription = SelectPrimitive.ItemDescription
|
||||||
|
export const SelectHiddenSelect = SelectPrimitive.HiddenSelect
|
||||||
|
export const SelectSection = SelectPrimitive.Section
|
||||||
|
|
||||||
|
type selectTriggerProps<T extends ValidComponent = 'button'> = ParentProps<
|
||||||
|
SelectTriggerProps<T> & { class?: string }
|
||||||
|
>
|
||||||
|
|
||||||
|
export function SelectTrigger<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, selectTriggerProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as selectTriggerProps, [
|
||||||
|
'class',
|
||||||
|
'children',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
class={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background transition-shadow placeholder:text-muted-foreground focus:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
as="svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="flex size-4 items-center justify-center opacity-50"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m8 9l4-4l4 4m0 6l-4 4l-4-4"
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectContentProps<T extends ValidComponent = 'div'> =
|
||||||
|
SelectContentProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, selectContentProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as selectContentProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
class={cn(
|
||||||
|
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Listbox class="p-1 focus-visible:outline-none" />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectItemProps<T extends ValidComponent = 'li'> = ParentProps<
|
||||||
|
SelectItemProps<T> & { class?: string }
|
||||||
|
>
|
||||||
|
|
||||||
|
export function SelectItem<T extends ValidComponent = 'li'>(props: PolymorphicProps<T, selectItemProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as selectItemProps, [
|
||||||
|
'class',
|
||||||
|
'children',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
class={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="size-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m5 12l5 5L20 7"
|
||||||
|
/>
|
||||||
|
<title>Checked</title>
|
||||||
|
</svg>
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
<SelectPrimitive.ItemLabel>{local.children}</SelectPrimitive.ItemLabel>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
75
src/lib/components/ui/spinner.tsx
Normal file
75
src/lib/components/ui/spinner.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
export interface SpinnerProps {
|
||||||
|
class?: string
|
||||||
|
indeterminate?: boolean
|
||||||
|
progress?: number // 0-1
|
||||||
|
bgClass?: string
|
||||||
|
strokeClass?: string
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
|
||||||
|
var angleInRadians = (angleInDegrees-90) * Math.PI / 180.0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: centerX + (radius * Math.cos(angleInRadians)),
|
||||||
|
y: centerY + (radius * Math.sin(angleInRadians))
|
||||||
|
};
|
||||||
|
} */
|
||||||
|
|
||||||
|
const HALF_PI = Math.PI / 2
|
||||||
|
function describeArc(
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
radius: number,
|
||||||
|
startAngleRad: number,
|
||||||
|
endAngleRad: number,
|
||||||
|
) {
|
||||||
|
const largeArcFlag = endAngleRad - startAngleRad <= Math.PI ? '0' : '1'
|
||||||
|
|
||||||
|
return [
|
||||||
|
'M',
|
||||||
|
centerX + radius * Math.cos(startAngleRad - HALF_PI),
|
||||||
|
centerY + radius * Math.sin(startAngleRad - HALF_PI),
|
||||||
|
'A',
|
||||||
|
radius,
|
||||||
|
radius,
|
||||||
|
0,
|
||||||
|
largeArcFlag,
|
||||||
|
1,
|
||||||
|
centerX + radius * Math.cos(endAngleRad - HALF_PI),
|
||||||
|
centerY + radius * Math.sin(endAngleRad - HALF_PI),
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner(props: SpinnerProps) {
|
||||||
|
const progress = () => props.indeterminate ? 0.25 : props.progress ?? 0
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" class={cn(props.indeterminate && 'animate-spin', props.class)}>
|
||||||
|
<circle
|
||||||
|
class={cn('fill-transparent stroke-current opacity-10 stroke-2', props.bgClass)}
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
{progress() >= 1
|
||||||
|
? (
|
||||||
|
<circle
|
||||||
|
class={cn('stroke-current stroke-2', props.bgClass)}
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<path
|
||||||
|
class={cn('stroke-current stroke-2', props.strokeClass)}
|
||||||
|
d={describeArc(12, 12, 10, 0, 2 * Math.PI * progress())}
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
125
src/lib/components/ui/tabs.tsx
Normal file
125
src/lib/components/ui/tabs.tsx
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type {
|
||||||
|
TabsContentProps,
|
||||||
|
TabsIndicatorProps,
|
||||||
|
TabsListProps,
|
||||||
|
TabsRootProps,
|
||||||
|
TabsTriggerProps,
|
||||||
|
} from '@kobalte/core/tabs'
|
||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import type { ValidComponent, VoidProps } from 'solid-js'
|
||||||
|
import {
|
||||||
|
Tabs as TabsPrimitive,
|
||||||
|
} from '@kobalte/core/tabs'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
import { splitProps } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
type tabsProps<T extends ValidComponent = 'div'> = TabsRootProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tabsProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as tabsProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsPrimitive
|
||||||
|
class={cn('w-full data-[orientation=vertical]:flex', local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tabsListProps<T extends ValidComponent = 'div'> = TabsListProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsList<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tabsListProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as tabsListProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
class={cn(
|
||||||
|
'relative flex w-full rounded-lg bg-muted p-1 text-muted-foreground data-[orientation=vertical]:flex-col data-[orientation=horizontal]:items-center data-[orientation=vertical]:items-stretch',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tabsContentProps<T extends ValidComponent = 'div'> =
|
||||||
|
TabsContentProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tabsContentProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as tabsContentProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
class={cn(
|
||||||
|
'transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[orientation=horizontal]:mt-2 data-[orientation=vertical]:ml-2',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tabsTriggerProps<T extends ValidComponent = 'button'> =
|
||||||
|
TabsTriggerProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsTrigger<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, tabsTriggerProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as tabsTriggerProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
class={cn(
|
||||||
|
'peer relative z-10 inline-flex h-7 w-full items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium outline-none transition-colors disabled:pointer-events-none disabled:opacity-50 data-[selected]:text-foreground',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsIndicatorVariants = cva(
|
||||||
|
'absolute outline-none transition-all duration-200',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
block:
|
||||||
|
'rounded-md bg-background shadow peer-focus-visible:outline-none peer-focus-visible:ring-[1.5px] peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background data-[orientation=horizontal]:bottom-1 data-[orientation=horizontal]:left-0 data-[orientation=vertical]:right-1 data-[orientation=vertical]:top-0 data-[orientation=horizontal]:h-[calc(100%-0.5rem)] data-[orientation=vertical]:w-[calc(100%-0.5rem)]',
|
||||||
|
underline:
|
||||||
|
'bg-primary data-[orientation=horizontal]:-bottom-px data-[orientation=horizontal]:left-0 data-[orientation=vertical]:-right-px data-[orientation=vertical]:top-0 data-[orientation=horizontal]:h-[2px] data-[orientation=vertical]:w-[2px]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'block',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
type tabsIndicatorProps<T extends ValidComponent = 'div'> = VoidProps<
|
||||||
|
TabsIndicatorProps<T> &
|
||||||
|
VariantProps<typeof tabsIndicatorVariants> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export function TabsIndicator<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tabsIndicatorProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as tabsIndicatorProps, [
|
||||||
|
'class',
|
||||||
|
'variant',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Indicator
|
||||||
|
class={cn(tabsIndicatorVariants({ variant: local.variant }), local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
138
src/lib/components/ui/text-field.tsx
Normal file
138
src/lib/components/ui/text-field.tsx
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type {
|
||||||
|
TextFieldDescriptionProps,
|
||||||
|
TextFieldErrorMessageProps,
|
||||||
|
TextFieldInputProps,
|
||||||
|
TextFieldLabelProps,
|
||||||
|
TextFieldRootProps,
|
||||||
|
} from '@kobalte/core/text-field'
|
||||||
|
import type { JSX, ValidComponent, VoidProps } from 'solid-js'
|
||||||
|
import { useFormControlContext } from '@kobalte/core'
|
||||||
|
import { TextField as TextFieldPrimitive } from '@kobalte/core/text-field'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
import { splitProps } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
type textFieldProps<T extends ValidComponent = 'div'> =
|
||||||
|
TextFieldRootProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextFieldRoot<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, textFieldProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as textFieldProps, ['class'])
|
||||||
|
|
||||||
|
return <TextFieldPrimitive class={cn('space-y-1', local.class)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const textfieldLabel = cva(
|
||||||
|
'text-sm font-medium data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
label: {
|
||||||
|
true: 'data-[invalid]:text-destructive',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
true: 'text-xs text-destructive',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
true: 'font-normal text-muted-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
label: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
type textFieldLabelProps<T extends ValidComponent = 'label'> =
|
||||||
|
TextFieldLabelProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextFieldLabel<T extends ValidComponent = 'label'>(props: PolymorphicProps<T, textFieldLabelProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as textFieldLabelProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextFieldPrimitive.Label
|
||||||
|
class={cn(textfieldLabel(), local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type textFieldErrorMessageProps<T extends ValidComponent = 'div'> =
|
||||||
|
TextFieldErrorMessageProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextFieldErrorMessage<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, textFieldErrorMessageProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as textFieldErrorMessageProps, [
|
||||||
|
'class',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextFieldPrimitive.ErrorMessage
|
||||||
|
class={cn(textfieldLabel({ error: true }), local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type textFieldDescriptionProps<T extends ValidComponent = 'div'> =
|
||||||
|
TextFieldDescriptionProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextFieldDescription<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, textFieldDescriptionProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as textFieldDescriptionProps, [
|
||||||
|
'class',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextFieldPrimitive.Description
|
||||||
|
class={cn(
|
||||||
|
textfieldLabel({ description: true, label: false }),
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFieldFrameOptions {
|
||||||
|
class?: string
|
||||||
|
children?: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextFieldFrame(props: TextFieldFrameOptions) {
|
||||||
|
const context = useFormControlContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-shadow focus-within:outline-none focus-within:ring-[1.5px] focus-within:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
context.validationState() === 'invalid' && 'border-error-foreground',
|
||||||
|
props.class,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type textFieldInputProps<T extends ValidComponent = 'input'> = VoidProps<
|
||||||
|
TextFieldInputProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export function TextField<T extends ValidComponent = 'input'>(props: PolymorphicProps<T, textFieldInputProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as textFieldInputProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextFieldPrimitive.Input
|
||||||
|
class={cn('border-none outline-none placeholder:text-muted-foreground bg-transparent', local.class)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
43
src/lib/components/ui/tooltip.tsx
Normal file
43
src/lib/components/ui/tooltip.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type {
|
||||||
|
TooltipContentProps,
|
||||||
|
TooltipRootProps,
|
||||||
|
} from '@kobalte/core/tooltip'
|
||||||
|
import { Tooltip as TooltipPrimitive } from '@kobalte/core/tooltip'
|
||||||
|
import { mergeProps, splitProps, type ValidComponent } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
export const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
export function Tooltip(props: TooltipRootProps) {
|
||||||
|
const merge = mergeProps<TooltipRootProps[]>(
|
||||||
|
{
|
||||||
|
gutter: 4,
|
||||||
|
flip: false,
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)
|
||||||
|
|
||||||
|
return <TooltipPrimitive {...merge} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type tooltipContentProps<T extends ValidComponent = 'div'> =
|
||||||
|
TooltipContentProps<T> & {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TooltipContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tooltipContentProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as tooltipContentProps, ['class'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
class={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
16
src/lib/components/ui/transition.tsx
Normal file
16
src/lib/components/ui/transition.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import type { JSX } from 'solid-js'
|
||||||
|
import { Transition } from 'solid-transition-group'
|
||||||
|
|
||||||
|
export function TransitionSlideLtr(props: { mode?: 'outin' | 'inout', children: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
mode={props.mode}
|
||||||
|
enterActiveClass="transition-[transform, opacity] duration-150 ease-in-out motion-reduce:transition-none"
|
||||||
|
exitActiveClass="transition-[transform, opacity] duration-150 ease-in-out motion-reduce:transition-none"
|
||||||
|
enterClass="translate-x-5 opacity-0"
|
||||||
|
exitToClass="-translate-x-5 opacity-0"
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Transition>
|
||||||
|
)
|
||||||
|
}
|
2
src/lib/env.ts
Normal file
2
src/lib/env.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export const IS_SAFARI = navigator.userAgent.includes('Safari')
|
||||||
|
export const IS_CHROMIUM = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor)
|
9
src/lib/ffetch.ts
Normal file
9
src/lib/ffetch.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { ffetchAddons, ffetchBase } from '@fuman/fetch'
|
||||||
|
import { ffetchValitaAdapter } from '@fuman/fetch/valita'
|
||||||
|
|
||||||
|
export const ffetch = ffetchBase.extend({
|
||||||
|
retry: {},
|
||||||
|
addons: [
|
||||||
|
ffetchAddons.parser(ffetchValitaAdapter({ mode: 'strip' })),
|
||||||
|
],
|
||||||
|
} as any) // todo
|
83
src/lib/runtime.ts
Normal file
83
src/lib/runtime.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
// eslint-disable-next-line antfu/no-import-dist
|
||||||
|
import chobitsuUrl from '../../vendor/chobitsu/dist/chobitsu.js?url'
|
||||||
|
import runnerScriptUrl from '../components/runner/iframe.ts?url'
|
||||||
|
|
||||||
|
export async function generateImportMap(packageJsons: any[]) {
|
||||||
|
const importMap: Record<string, string> = {}
|
||||||
|
for (const pkg of packageJsons) {
|
||||||
|
const name = pkg.name
|
||||||
|
const swPrefix = `${location.origin}/sw/runtime/${name}/`
|
||||||
|
|
||||||
|
if (pkg.exports) {
|
||||||
|
if ('import' in pkg.exports || 'require' in pkg.exports) {
|
||||||
|
pkg.exports = { '.': pkg.exports }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(pkg.exports)) {
|
||||||
|
let target = pkg.exports[key]
|
||||||
|
if (typeof target === 'object') {
|
||||||
|
// { "import": "./index.js", "require": "./index.cjs" }
|
||||||
|
// or
|
||||||
|
// { "import": { "types": "./index.d.ts", "default": "./index.js" }, "require": "./index.cjs" }
|
||||||
|
if (!('import' in target)) {
|
||||||
|
throw new Error(`Invalid export target (no esm): ${key} in ${name}`)
|
||||||
|
}
|
||||||
|
target = target.import
|
||||||
|
if (typeof target === 'object') {
|
||||||
|
if (!('default' in target)) {
|
||||||
|
throw new Error(`Invalid export target (no defalt): ${key} in ${name}`)
|
||||||
|
}
|
||||||
|
target = target.default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof target !== 'string') {
|
||||||
|
throw new TypeError(`Invalid export target: ${key} in ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
target = target.replace(/^\.\//, '')
|
||||||
|
|
||||||
|
if (target[0] === '.') {
|
||||||
|
throw new Error(`Invalid export target: ${key} in ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === '.') {
|
||||||
|
importMap[name] = `${swPrefix}${target}`
|
||||||
|
} else if (key[0] === '.' && key[1] === '/') {
|
||||||
|
importMap[`${name}/${key.slice(2)}`] = `${swPrefix}${target}`
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid export target: ${key} in ${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pkg.module) {
|
||||||
|
importMap[name] = `${swPrefix}${pkg.module.replace(/^\.\//, '')}`
|
||||||
|
} else if (pkg.main) {
|
||||||
|
let target = pkg.main.replace(/^\.\//, '')
|
||||||
|
if (!target.endsWith('.js')) {
|
||||||
|
target += '.js'
|
||||||
|
}
|
||||||
|
importMap[name] = `${swPrefix}${target}`
|
||||||
|
} else {
|
||||||
|
importMap[name] = `${swPrefix}index.js`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return importMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRunnerHtml(importMap: Record<string, string>) {
|
||||||
|
return `
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script async src="https://ga.jspm.io/npm:es-module-shims@1.7.0/dist/es-module-shims.js"></script>
|
||||||
|
<script type="importmap">${JSON.stringify({ imports: importMap })}</script>
|
||||||
|
<script>
|
||||||
|
Object.keys(console).forEach(method => Object.defineProperty(console, method, { value: () => {} }));
|
||||||
|
</script>
|
||||||
|
<script src="${chobitsuUrl}"></script>
|
||||||
|
<script type="module" src="${runnerScriptUrl}"></script>
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
66
src/lib/telegram.ts
Normal file
66
src/lib/telegram.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import type { InputStringSessionData } from '@mtcute/web/utils.js'
|
||||||
|
import type { TelegramAccount } from '../store/accounts.ts'
|
||||||
|
import { asNonNull } from '@fuman/utils'
|
||||||
|
import { BaseTelegramClient, IdbStorage, TransportError } from '@mtcute/web'
|
||||||
|
import { getMe } from '@mtcute/web/methods.js'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
|
||||||
|
export function createInternalClient(accountId: string, testMode?: boolean) {
|
||||||
|
return new BaseTelegramClient({
|
||||||
|
apiId: Number(import.meta.env.VITE_API_ID),
|
||||||
|
apiHash: import.meta.env.VITE_API_HASH,
|
||||||
|
storage: new IdbStorage(`mtcute:${accountId}`),
|
||||||
|
testMode,
|
||||||
|
logLevel: import.meta.env.DEV ? 5 : 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccount(accountId: string) {
|
||||||
|
const req = indexedDB.deleteDatabase(`mtcute:${accountId}`)
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
req.onsuccess = () => resolve()
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importAccount(
|
||||||
|
session: InputStringSessionData,
|
||||||
|
abortSignal: AbortSignal,
|
||||||
|
): Promise<TelegramAccount> {
|
||||||
|
const accountId = nanoid()
|
||||||
|
const client = createInternalClient(accountId, session.primaryDcs?.main.testMode)
|
||||||
|
|
||||||
|
let is404 = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.importSession(session)
|
||||||
|
if (abortSignal.aborted) throw abortSignal.reason
|
||||||
|
|
||||||
|
// verify auth_key is valid (i.e. we don't get -404)
|
||||||
|
client.onError.add((err) => {
|
||||||
|
if (err instanceof TransportError && err.code === 404) {
|
||||||
|
is404 = true
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
const self = await getMe(client)
|
||||||
|
if (abortSignal.aborted) throw abortSignal.reason
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: accountId,
|
||||||
|
name: self.displayName,
|
||||||
|
telegramId: self.id,
|
||||||
|
bot: self.isBot,
|
||||||
|
testMode: asNonNull(session.primaryDcs).main.testMode ?? false,
|
||||||
|
dcId: asNonNull(session.primaryDcs).main.id,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await deleteAccount(accountId)
|
||||||
|
if (is404) {
|
||||||
|
throw new Error('Invalid session (auth key not found)')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
17
src/lib/use-color-scheme.ts
Normal file
17
src/lib/use-color-scheme.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { createSignal, onMount } from 'solid-js'
|
||||||
|
|
||||||
|
export type ColorScheme = 'light' | 'dark'
|
||||||
|
|
||||||
|
export function useColorScheme() {
|
||||||
|
const [scheme, setScheme] = createSignal<ColorScheme>(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const listener = (e: MediaQueryListEvent) => setScheme(e.matches ? 'dark' : 'light')
|
||||||
|
const media = matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
media.addEventListener('change', listener)
|
||||||
|
|
||||||
|
return () => media.removeEventListener('change', listener)
|
||||||
|
})
|
||||||
|
|
||||||
|
return scheme
|
||||||
|
}
|
23
src/lib/utils.ts
Normal file
23
src/lib/utils.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { ClassValue } from 'clsx'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error('Timeout'))
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
promise.then((res) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
resolve(res)
|
||||||
|
}).catch((err) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
199
src/lib/vfs/downloader.ts
Normal file
199
src/lib/vfs/downloader.ts
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
import type { VfsFile, VfsStorage } from './storage'
|
||||||
|
import { read, webReadableToFuman } from '@fuman/io'
|
||||||
|
import { asyncPool, AsyncQueue, utf8 } from '@fuman/utils'
|
||||||
|
import { ffetch } from '../ffetch.ts'
|
||||||
|
import { GunzipStream } from './gzip.ts'
|
||||||
|
import { extractTar, type TarEntry } from './tar.ts'
|
||||||
|
|
||||||
|
const PACKAGES_TO_SKIP = new Set([
|
||||||
|
'@mtcute/bun',
|
||||||
|
'@mtcute/node',
|
||||||
|
'@mtcute/crypto-node',
|
||||||
|
'@mtcute/deno',
|
||||||
|
'@mtcute/create-bot',
|
||||||
|
'@mtcute/test',
|
||||||
|
])
|
||||||
|
|
||||||
|
export async function getLatestVersions() {
|
||||||
|
const versions = await fetch('https://raw.githubusercontent.com/mtcute/mtcute/refs/heads/master/scripts/latest-versions.json').then(res => res.json())
|
||||||
|
for (const pkg of Object.keys(versions)) {
|
||||||
|
if (PACKAGES_TO_SKIP.has(pkg)) {
|
||||||
|
delete versions[pkg]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return versions
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPackagesToDownload(latestVersions: Record<string, string>, storage: VfsStorage) {
|
||||||
|
const packages: Record<string, string> = {}
|
||||||
|
|
||||||
|
const queue = new AsyncQueue<[string, string]>()
|
||||||
|
const queued: [string, string][] = []
|
||||||
|
|
||||||
|
for (const pkg of Object.keys(latestVersions)) {
|
||||||
|
if (await storage.getExistingLibVersion(pkg) === latestVersions[pkg]) continue
|
||||||
|
queue.enqueue([pkg, latestVersions[pkg]])
|
||||||
|
queued.push([pkg, latestVersions[pkg]])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.queue.length === 0) return packages
|
||||||
|
|
||||||
|
let fetched = 0
|
||||||
|
let total = queue.queue.length
|
||||||
|
|
||||||
|
await asyncPool(queue, async ([pkg, version]) => {
|
||||||
|
let exactVersion: string
|
||||||
|
if (version.match(/^\d+\.\d+\.\d+$/)) {
|
||||||
|
// version is already exact
|
||||||
|
exactVersion = version
|
||||||
|
} else {
|
||||||
|
const res = await ffetch(`https://data.jsdelivr.com/v1/packages/npm/${pkg}/resolved`, {
|
||||||
|
query: { specifier: version },
|
||||||
|
}).json<{ version: string }>()
|
||||||
|
|
||||||
|
exactVersion = res.version
|
||||||
|
}
|
||||||
|
|
||||||
|
packages[pkg] = exactVersion
|
||||||
|
|
||||||
|
const packageJson = await ffetch(`https://cdn.jsdelivr.net/npm/${pkg}@${exactVersion}/package.json`).json<{ dependencies?: Record<string, string> }>()
|
||||||
|
|
||||||
|
for (const [dep, depVersion_] of Object.entries(packageJson.dependencies ?? {})) {
|
||||||
|
const depVersion = depVersion_ as string
|
||||||
|
|
||||||
|
if (queued.some(([pkg]) => pkg === dep)) {
|
||||||
|
// already fetched/queued
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.enqueue([dep, depVersion as string])
|
||||||
|
queued.push([dep, depVersion as string])
|
||||||
|
total += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched += 1
|
||||||
|
|
||||||
|
if (fetched === total) {
|
||||||
|
// we're done
|
||||||
|
queue.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return packages
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchMtcuteTl(file: VfsFile) {
|
||||||
|
if (!file.path.match(/\.(js|json)$/)) return
|
||||||
|
// @mtcute/tl is currently commonjs-only, so we need to add some shims to make it work with esm
|
||||||
|
// based on https://github.com/mtcute/mtcute/blob/master/packages/tl/scripts/build-package.ts
|
||||||
|
let text = utf8.decoder.decode(file.contents)
|
||||||
|
if (text.includes('export const')) {
|
||||||
|
// future-proofing for when we eventually switch to esm
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (file.path) {
|
||||||
|
case 'index.js': {
|
||||||
|
text = [
|
||||||
|
'const exports = {};',
|
||||||
|
text,
|
||||||
|
'export const tl = exports.tl;',
|
||||||
|
'export const mtp = exports.mtp;',
|
||||||
|
].join('')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'binary/reader.js': {
|
||||||
|
text = [
|
||||||
|
'const exports = {};',
|
||||||
|
text,
|
||||||
|
'export const __tlReaderMap = exports.__tlReaderMap;',
|
||||||
|
].join('')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'binary/writer.js': {
|
||||||
|
text = [
|
||||||
|
'const exports = {};',
|
||||||
|
text,
|
||||||
|
'export const __tlWriterMap = exports.__tlWriterMap;',
|
||||||
|
].join('')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'binary/rsa-keys.js': {
|
||||||
|
text = [
|
||||||
|
'const exports = {};',
|
||||||
|
text,
|
||||||
|
'export const __publicKeyIndex = exports.__publicKeyIndex;',
|
||||||
|
].join('')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'package.json': {
|
||||||
|
const json = JSON.parse(text)
|
||||||
|
json.exports = {
|
||||||
|
'.': './index.js',
|
||||||
|
'./binary/reader.js': './binary/reader.js',
|
||||||
|
'./binary/writer.js': './binary/writer.js',
|
||||||
|
'./binary/rsa-keys.js': './binary/rsa-keys.js',
|
||||||
|
}
|
||||||
|
text = JSON.stringify(json, null, 2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.contents = utf8.encoder.encode(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadNpmPackage(params: {
|
||||||
|
packageName: string
|
||||||
|
version: string
|
||||||
|
storage: VfsStorage
|
||||||
|
progress: (downloaded: number, total: number, file: string) => void
|
||||||
|
filterFiles?: (file: TarEntry) => boolean
|
||||||
|
signal: AbortSignal
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
packageName,
|
||||||
|
version,
|
||||||
|
storage,
|
||||||
|
progress,
|
||||||
|
filterFiles,
|
||||||
|
signal,
|
||||||
|
} = params
|
||||||
|
|
||||||
|
const tgzUrl = `https://registry.npmjs.org/${packageName}/-/${packageName.replace(/^.*?\//, '')}-${version}.tgz`
|
||||||
|
|
||||||
|
const response = await fetch(tgzUrl, { signal })
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Failed to download: HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = webReadableToFuman(response.body)
|
||||||
|
const gunzipStream = new GunzipStream(stream)
|
||||||
|
|
||||||
|
const total = Number(response.headers.get('content-length') ?? 0)
|
||||||
|
const files: VfsFile[] = []
|
||||||
|
for await (const file of extractTar(gunzipStream)) {
|
||||||
|
if (!file.content || file.header.type !== 'file') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(gunzipStream.totalRead, total, file.header.name)
|
||||||
|
|
||||||
|
if (filterFiles && !filterFiles(file)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = file.header.name.replace(/^package\//, '')
|
||||||
|
const vfsFile: VfsFile = {
|
||||||
|
path: fileName,
|
||||||
|
contents: await read.async.untilEnd(file.content),
|
||||||
|
}
|
||||||
|
if (packageName === '@mtcute/tl') {
|
||||||
|
patchMtcuteTl(vfsFile)
|
||||||
|
}
|
||||||
|
files.push(vfsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.writeLibrary(packageName, version, files)
|
||||||
|
|
||||||
|
progress(total, total, 'Done!')
|
||||||
|
}
|
74
src/lib/vfs/gzip.ts
Normal file
74
src/lib/vfs/gzip.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import type { IClosable, IReadable } from '@fuman/io'
|
||||||
|
import { Bytes, ReaderWithFinal } from '@fuman/io'
|
||||||
|
import { Deferred, Deque } from '@fuman/utils'
|
||||||
|
import { AsyncGunzip } from 'fflate/browser'
|
||||||
|
|
||||||
|
const INTERNAL_CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
export class GunzipStream implements IReadable, IClosable {
|
||||||
|
#gunzip = new AsyncGunzip(this.#onChunk.bind(this))
|
||||||
|
#buffer = Bytes.alloc()
|
||||||
|
#waiters = new Deque<Deferred<void>>()
|
||||||
|
#error: unknown | null = null
|
||||||
|
#totalRead = 0
|
||||||
|
#reader: ReaderWithFinal
|
||||||
|
#internalBuffer = new Uint8Array(INTERNAL_CHUNK_SIZE)
|
||||||
|
|
||||||
|
constructor(stream: IReadable) {
|
||||||
|
this.#reader = new ReaderWithFinal(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalRead(): number {
|
||||||
|
return this.#totalRead
|
||||||
|
}
|
||||||
|
|
||||||
|
#onChunk(err: unknown, data: Uint8Array) {
|
||||||
|
if (err) {
|
||||||
|
while (this.#waiters.length > 0) {
|
||||||
|
this.#waiters.popFront()!.reject(err)
|
||||||
|
}
|
||||||
|
this.#error = err
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#waiters.length > 0) {
|
||||||
|
this.#waiters.popFront()!.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#buffer.writeSync(data.length).set(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(into: Uint8Array): Promise<number> {
|
||||||
|
if (this.#buffer.available > 0) {
|
||||||
|
return this.#buffer.read(into)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#error) throw this.#error
|
||||||
|
|
||||||
|
const { nread, final } = await this.#reader.readWithFinal(this.#internalBuffer)
|
||||||
|
if (nread === 0) {
|
||||||
|
this.#gunzip.terminate()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#totalRead += nread
|
||||||
|
|
||||||
|
const def = new Deferred<void>()
|
||||||
|
this.#waiters.pushBack(def)
|
||||||
|
|
||||||
|
this.#gunzip.push(this.#internalBuffer.slice(0, nread), final)
|
||||||
|
await def.promise
|
||||||
|
|
||||||
|
if (this.#buffer.available === 0) {
|
||||||
|
// try reading again
|
||||||
|
return this.read(into)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.#buffer.read(into)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.#gunzip.terminate()
|
||||||
|
}
|
||||||
|
}
|
54
src/lib/vfs/storage.ts
Normal file
54
src/lib/vfs/storage.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import type { DBSchema, IDBPDatabase } from 'idb'
|
||||||
|
import { openDB } from 'idb'
|
||||||
|
|
||||||
|
export interface VfsFile {
|
||||||
|
path: string
|
||||||
|
contents: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Schema extends DBSchema {
|
||||||
|
libs: {
|
||||||
|
key: string
|
||||||
|
value: {
|
||||||
|
// we do not support multiple versions of the same lib (for now?)
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
files: VfsFile[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VfsStorage {
|
||||||
|
constructor(private db: IDBPDatabase<Schema>) {}
|
||||||
|
|
||||||
|
static async create() {
|
||||||
|
const db = await openDB<Schema>('mtcute-repl-vfs', 1, {
|
||||||
|
upgrade(db) {
|
||||||
|
db.createObjectStore('libs', { keyPath: 'name' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new VfsStorage(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableLibs() {
|
||||||
|
return this.db.getAllKeys('libs')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExistingLibVersion(lib: string) {
|
||||||
|
const obj = await this.db.get('libs', lib)
|
||||||
|
return obj?.version
|
||||||
|
}
|
||||||
|
|
||||||
|
async readLibrary(lib: string) {
|
||||||
|
return this.db.get('libs', lib)
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeLibrary(lib: string, version: string, files: VfsFile[]) {
|
||||||
|
await this.db.put('libs', { name: lib, version, files })
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLib(lib: string) {
|
||||||
|
await this.db.delete('libs', lib)
|
||||||
|
}
|
||||||
|
}
|
124
src/lib/vfs/system.ts
Normal file
124
src/lib/vfs/system.ts
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
// roughly based on https://github.com/microsoft/TypeScript-Website/blob/v2/packages/typescript-vfs/src/index.ts
|
||||||
|
|
||||||
|
import type { CompilerHost, CompilerOptions, SourceFile, System } from 'typescript'
|
||||||
|
|
||||||
|
import type { VfsStorage } from './storage'
|
||||||
|
|
||||||
|
import { iter, utf8 } from '@fuman/utils'
|
||||||
|
|
||||||
|
function notImplemented(methodName: string): any {
|
||||||
|
throw new Error(`Method '${methodName}' is not implemented.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "/DOM.d.ts" => "/lib.dom.d.ts"
|
||||||
|
const libize = (path: string) => path.replace('/', '/lib.').toLowerCase()
|
||||||
|
|
||||||
|
export function createSystem(files: Map<string, Uint8Array>): System {
|
||||||
|
return {
|
||||||
|
args: [],
|
||||||
|
createDirectory: () => notImplemented('createDirectory'),
|
||||||
|
// TODO: could make a real file tree
|
||||||
|
directoryExists: (directory) => {
|
||||||
|
return Array.from(files.keys()).some(path => path.startsWith(directory))
|
||||||
|
},
|
||||||
|
exit: () => notImplemented('exit'),
|
||||||
|
fileExists: fileName => files.has(fileName) || files.has(libize(fileName)),
|
||||||
|
getCurrentDirectory: () => '/',
|
||||||
|
getDirectories: () => [],
|
||||||
|
getExecutingFilePath: () => notImplemented('getExecutingFilePath'),
|
||||||
|
readDirectory: (directory) => {
|
||||||
|
return (directory === '/' ? Array.from(files.keys()) : [])
|
||||||
|
},
|
||||||
|
readFile: (fileName) => {
|
||||||
|
const bytes = files.get(fileName) ?? files.get(libize(fileName))
|
||||||
|
if (!bytes) return undefined
|
||||||
|
|
||||||
|
return utf8.decoder.decode(bytes)
|
||||||
|
},
|
||||||
|
resolvePath: path => path,
|
||||||
|
newLine: '\n',
|
||||||
|
useCaseSensitiveFileNames: true,
|
||||||
|
write: () => notImplemented('write'),
|
||||||
|
writeFile: (fileName, contents) => {
|
||||||
|
files.set(fileName, utf8.encoder.encode(contents))
|
||||||
|
},
|
||||||
|
deleteFile: (fileName) => {
|
||||||
|
files.delete(fileName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVirtualCompilerHost(
|
||||||
|
sys: System,
|
||||||
|
compilerOptions: CompilerOptions,
|
||||||
|
ts: typeof import('typescript'),
|
||||||
|
) {
|
||||||
|
const sourceFiles = new Map<string, SourceFile>()
|
||||||
|
const save = (sourceFile: SourceFile) => {
|
||||||
|
sourceFiles.set(sourceFile.fileName, sourceFile)
|
||||||
|
return sourceFile
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Return {
|
||||||
|
compilerHost: CompilerHost
|
||||||
|
updateFile: (sourceFile: SourceFile) => boolean
|
||||||
|
deleteFile: (sourceFile: SourceFile) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const vHost: Return = {
|
||||||
|
compilerHost: {
|
||||||
|
...sys,
|
||||||
|
getCanonicalFileName: fileName => fileName,
|
||||||
|
getDefaultLibFileName: () => '/lib.d.ts',
|
||||||
|
// getDefaultLibLocation: () => '/',
|
||||||
|
getNewLine: () => sys.newLine,
|
||||||
|
getSourceFile: (fileName, languageVersionOrOptions) => {
|
||||||
|
const existing = sourceFiles.get(fileName)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const content = sys.readFile(fileName)
|
||||||
|
if (!content) return undefined
|
||||||
|
|
||||||
|
return save(
|
||||||
|
ts.createSourceFile(
|
||||||
|
fileName,
|
||||||
|
content,
|
||||||
|
languageVersionOrOptions ?? compilerOptions.target ?? ts.ScriptTarget.ESNext,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames,
|
||||||
|
},
|
||||||
|
updateFile: (sourceFile) => {
|
||||||
|
const alreadyExists = sourceFiles.has(sourceFile.fileName)
|
||||||
|
sys.writeFile(sourceFile.fileName, sourceFile.text)
|
||||||
|
sourceFiles.set(sourceFile.fileName, sourceFile)
|
||||||
|
return alreadyExists
|
||||||
|
},
|
||||||
|
deleteFile: (sourceFile) => {
|
||||||
|
const alreadyExists = sourceFiles.has(sourceFile.fileName)
|
||||||
|
sourceFiles.delete(sourceFile.fileName)
|
||||||
|
sys.deleteFile!(sourceFile.fileName)
|
||||||
|
return alreadyExists
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return vHost
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFilesMapFromVfs(vfs: VfsStorage, libs: string[]): Promise<Map<string, Uint8Array>> {
|
||||||
|
const files = await Promise.all(libs.map(lib => vfs.readLibrary(lib)))
|
||||||
|
const fileMap = new Map<string, Uint8Array>()
|
||||||
|
|
||||||
|
for (const [idx, list] of iter.enumerate(files)) {
|
||||||
|
const lib = libs[idx].split(':')[1]
|
||||||
|
const prefix = `/node_modules/${lib}`
|
||||||
|
|
||||||
|
for (const file of list) {
|
||||||
|
const fullPath = prefix + (file.path[0] === '/' ? file.path : `/${file.path}`)
|
||||||
|
fileMap.set(fullPath, file.contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileMap
|
||||||
|
}
|
359
src/lib/vfs/tar.ts
Normal file
359
src/lib/vfs/tar.ts
Normal file
|
@ -0,0 +1,359 @@
|
||||||
|
import type { IReadable } from '@fuman/io'
|
||||||
|
import type { UnsafeMutable, Values } from '@fuman/utils'
|
||||||
|
import { read } from '@fuman/io'
|
||||||
|
import { typed, utf8 } from '@fuman/utils'
|
||||||
|
|
||||||
|
const BLOCK_SIZE = 512
|
||||||
|
|
||||||
|
const USTAR_MAGIC = /* #__PURE__ */ utf8.encoder.encode('ustar\x00')
|
||||||
|
const GNU_MAGIC = /* #__PURE__ */ utf8.encoder.encode('ustar\x20')
|
||||||
|
const GNU_VER = /* #__PURE__ */ utf8.encoder.encode('\x20\x00')
|
||||||
|
|
||||||
|
const TAR_ENTRY_TYPES = {
|
||||||
|
0: 'file',
|
||||||
|
1: 'link',
|
||||||
|
2: 'symlink',
|
||||||
|
3: 'characterDevice',
|
||||||
|
4: 'blockDevice',
|
||||||
|
5: 'directory',
|
||||||
|
6: 'fifo',
|
||||||
|
7: 'contiguousFile',
|
||||||
|
} as const
|
||||||
|
type TarEntryType = Values<typeof TAR_ENTRY_TYPES> | 'unknown'
|
||||||
|
|
||||||
|
function readString(buf: Uint8Array): string {
|
||||||
|
const zeroIdx = buf.indexOf(0)
|
||||||
|
if (zeroIdx !== -1) buf = buf.subarray(0, zeroIdx)
|
||||||
|
|
||||||
|
return utf8.decoder.decode(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOctalInteger(buf: Uint8Array): number {
|
||||||
|
if (buf[0] & 0x80) {
|
||||||
|
// If prefixed with 0x80 then parse as a base-256 integer
|
||||||
|
// currently mostly copy-pasted from node-tar, but we should check if we can simplify this
|
||||||
|
|
||||||
|
// first byte MUST be either 80 or FF
|
||||||
|
// 80 for positive, FF for 2's comp
|
||||||
|
let positive
|
||||||
|
if (buf[0] === 0x80) {
|
||||||
|
positive = true
|
||||||
|
} else if (buf[0] === 0xFF) {
|
||||||
|
positive = false
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// build up a base-256 tuple from the least sig to the highest
|
||||||
|
let zero = false
|
||||||
|
const tuple: number[] = []
|
||||||
|
for (let i = buf.length - 1; i > 0; i--) {
|
||||||
|
const byte = buf[i]
|
||||||
|
if (positive) {
|
||||||
|
tuple.push(byte)
|
||||||
|
} else if (zero && byte === 0) {
|
||||||
|
tuple.push(0)
|
||||||
|
} else if (zero) {
|
||||||
|
zero = false
|
||||||
|
tuple.push(0x100 - byte)
|
||||||
|
} else {
|
||||||
|
tuple.push(0xFF - byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = 0
|
||||||
|
const l = tuple.length
|
||||||
|
for (let i = 0; i < l; i++) {
|
||||||
|
sum += tuple[i] * 256 ** i
|
||||||
|
}
|
||||||
|
|
||||||
|
return positive ? sum : -1 * sum
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = 0
|
||||||
|
let prefix = true
|
||||||
|
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const byte = buf[i]
|
||||||
|
if (prefix) {
|
||||||
|
// some tar implementations prefix with spaces.
|
||||||
|
// zeroes would work fine with the below, but we can also just skip them
|
||||||
|
if (byte === 0x20 || byte === 0x00) continue
|
||||||
|
else prefix = false
|
||||||
|
} else if (byte === 0 || byte === 0x20) {
|
||||||
|
// some tar implementations also use spaces as terminators
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
res = (res << 3) | (buf[i] - 0x30)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksum(block: Uint8Array): number {
|
||||||
|
let sum = 8 * 32
|
||||||
|
for (let i = 0; i < 148; i++) sum += block[i]
|
||||||
|
for (let j = 156; j < 512; j++) sum += block[j]
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TarHeader {
|
||||||
|
readonly name: string
|
||||||
|
readonly type: TarEntryType
|
||||||
|
readonly typeflag: number
|
||||||
|
readonly linkName: string | null
|
||||||
|
readonly size: number
|
||||||
|
readonly mtime: Date
|
||||||
|
readonly mode: number
|
||||||
|
readonly uid: number
|
||||||
|
readonly gid: number
|
||||||
|
readonly uname: string
|
||||||
|
readonly gname: string
|
||||||
|
readonly devMajor: number
|
||||||
|
readonly devMinor: number
|
||||||
|
readonly pax?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeader(buffer: Uint8Array): TarHeader | null {
|
||||||
|
let typeflag = buffer[156] === 0 ? 0 : buffer[156] - 0x30
|
||||||
|
|
||||||
|
const name = readString(buffer.subarray(0, 100))
|
||||||
|
const mode = readOctalInteger(buffer.subarray(100, 108))
|
||||||
|
const uid = readOctalInteger(buffer.subarray(108, 116))
|
||||||
|
const gid = readOctalInteger(buffer.subarray(116, 124))
|
||||||
|
const size = readOctalInteger(buffer.subarray(124, 136))
|
||||||
|
const mtime = new Date(1000 * readOctalInteger(buffer.subarray(136, 148)))
|
||||||
|
const linkName = buffer[157] === 0 ? null : readString(buffer.subarray(157, 257))
|
||||||
|
const uname = readString(buffer.subarray(265, 297))
|
||||||
|
const gname = readString(buffer.subarray(297, 329))
|
||||||
|
const devMajor = readOctalInteger(buffer.subarray(329, 337))
|
||||||
|
const devMinor = readOctalInteger(buffer.subarray(337, 345))
|
||||||
|
|
||||||
|
const sum = checksum(buffer)
|
||||||
|
|
||||||
|
// checksum is still initial value if header was null.
|
||||||
|
if (sum === 8 * 32) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sum !== readOctalInteger(buffer.subarray(148, 156))) {
|
||||||
|
throw new Error('Invalid tar header. Maybe the tar is corrupted or it needs to be gunzipped?')
|
||||||
|
}
|
||||||
|
|
||||||
|
const magic = buffer.subarray(257, 263)
|
||||||
|
|
||||||
|
if (typed.equal(magic, USTAR_MAGIC)) {
|
||||||
|
// ustar (posix) format. prepend prefix, if present.
|
||||||
|
if (buffer[354] !== 0) {
|
||||||
|
readString(buffer.subarray(345, 354))
|
||||||
|
}
|
||||||
|
} else if (typed.equal(magic, GNU_MAGIC) && typed.equal(buffer.subarray(263, 265), GNU_VER)) {
|
||||||
|
// 'gnu'/'oldgnu' format. Similar to ustar, but has support for incremental and
|
||||||
|
// multi-volume tarballs.
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid tar header: unknown format')
|
||||||
|
}
|
||||||
|
|
||||||
|
// to support old tar versions that use trailing / to indicate dirs
|
||||||
|
if (typeflag === 0 && name[name.length - 1] === '/') {
|
||||||
|
typeflag = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: TAR_ENTRY_TYPES[typeflag as keyof typeof TAR_ENTRY_TYPES] ?? 'unknown',
|
||||||
|
typeflag,
|
||||||
|
linkName,
|
||||||
|
size,
|
||||||
|
mtime,
|
||||||
|
mode,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
uname,
|
||||||
|
gname,
|
||||||
|
devMajor,
|
||||||
|
devMinor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePaxHeader(buffer: Uint8Array): Record<string, string> | null {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
|
||||||
|
while (buffer.length > 0) {
|
||||||
|
let i = 0
|
||||||
|
while (i < buffer.length && buffer[i] !== 32) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = Number.parseInt(utf8.decoder.decode(buffer.subarray(0, i)), 10)
|
||||||
|
|
||||||
|
if (len === 0) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = utf8.decoder.decode(buffer.subarray(i + 1, len - 1))
|
||||||
|
const keyIndex = b.indexOf('=')
|
||||||
|
|
||||||
|
if (keyIndex === -1) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result[b.slice(0, keyIndex)] = b.slice(keyIndex + 1)
|
||||||
|
buffer = buffer.subarray(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TarEntry {
|
||||||
|
readonly header: TarHeader
|
||||||
|
readonly content?: IReadable
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileStream implements IReadable {
|
||||||
|
#inner: IReadable
|
||||||
|
#pos = 0
|
||||||
|
|
||||||
|
constructor(inner: IReadable, readonly size: number) {
|
||||||
|
this.#inner = inner
|
||||||
|
}
|
||||||
|
|
||||||
|
get remaining(): number {
|
||||||
|
return this.size - this.#pos
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(into: Uint8Array): Promise<number> {
|
||||||
|
const remaining = this.size - this.#pos
|
||||||
|
if (remaining === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (into.length > remaining) {
|
||||||
|
into = into.subarray(0, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nread = await this.#inner.read(into)
|
||||||
|
if (nread === 0) return 0
|
||||||
|
this.#pos += nread
|
||||||
|
|
||||||
|
return nread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readNextHeader(readable: IReadable): Promise<TarHeader | null> {
|
||||||
|
let nextLongPath: string | null = null
|
||||||
|
let nextLongLinkPath: string | null = null
|
||||||
|
let paxGlobalHeader: Record<string, string> | null = null
|
||||||
|
let nextPaxHeader: Record<string, string> | null = null
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const block = await read.async.exactly(readable, BLOCK_SIZE, 'truncate')
|
||||||
|
if (block.length < BLOCK_SIZE) return null // eof
|
||||||
|
|
||||||
|
const header = parseHeader(block) as UnsafeMutable<TarHeader>
|
||||||
|
if (header === null) continue
|
||||||
|
|
||||||
|
switch (header.typeflag) {
|
||||||
|
case 28:
|
||||||
|
case 30: {
|
||||||
|
// gnu long path
|
||||||
|
const nextBlock = await read.async.exactly(readable, BLOCK_SIZE)
|
||||||
|
nextLongPath = readString(nextBlock.subarray(0, header.size))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case 27: {
|
||||||
|
// gnu long link path
|
||||||
|
const nextBlock = await read.async.exactly(readable, BLOCK_SIZE)
|
||||||
|
nextLongLinkPath = readString(nextBlock.subarray(0, header.size))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case 72: {
|
||||||
|
// pax header
|
||||||
|
const nextBlock = await read.async.exactly(readable, BLOCK_SIZE)
|
||||||
|
nextPaxHeader = parsePaxHeader(nextBlock.subarray(0, header.size))
|
||||||
|
if (paxGlobalHeader != null) {
|
||||||
|
nextPaxHeader = { ...paxGlobalHeader, ...nextPaxHeader }
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case 55: {
|
||||||
|
// pax global header
|
||||||
|
const nextBlock = await read.async.exactly(readable, BLOCK_SIZE)
|
||||||
|
paxGlobalHeader = parsePaxHeader(nextBlock.subarray(0, header.size))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextLongPath != null) {
|
||||||
|
header.name = nextLongPath
|
||||||
|
nextLongPath = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextLongLinkPath != null) {
|
||||||
|
header.linkName = nextLongLinkPath
|
||||||
|
nextLongLinkPath = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPaxHeader != null) {
|
||||||
|
if (nextPaxHeader.path != null) {
|
||||||
|
header.name = nextPaxHeader.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPaxHeader.linkpath != null) {
|
||||||
|
header.linkName = nextPaxHeader.linkpath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPaxHeader.size != null) {
|
||||||
|
header.size = Number.parseInt(nextPaxHeader.size, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
header.pax = nextPaxHeader
|
||||||
|
nextPaxHeader = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTar(readable: IReadable): AsyncIterableIterator<TarEntry> {
|
||||||
|
let prevContent: FileStream | null = null
|
||||||
|
|
||||||
|
const iterator: AsyncIterableIterator<TarEntry> = {
|
||||||
|
[Symbol.asyncIterator]: () => iterator,
|
||||||
|
next: async () => {
|
||||||
|
if (prevContent != null) {
|
||||||
|
// make sure the previous content is fully read
|
||||||
|
if (prevContent.remaining > 0) {
|
||||||
|
await read.async.exactly(readable, prevContent.remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip padding after the file, if any
|
||||||
|
const paddedSize = prevContent.size % BLOCK_SIZE
|
||||||
|
if (paddedSize > 0) {
|
||||||
|
await read.async.exactly(readable, BLOCK_SIZE - paddedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
prevContent = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = await readNextHeader(readable)
|
||||||
|
if (header === null) return { done: true, value: undefined }
|
||||||
|
|
||||||
|
if (header.size === 0 || header.typeflag === 5) {
|
||||||
|
// directory or empty file
|
||||||
|
return { done: false, value: { header } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = new FileStream(readable, header.size)
|
||||||
|
prevContent = content
|
||||||
|
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: { header, content },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return iterator
|
||||||
|
}
|
49
src/store/accounts.ts
Normal file
49
src/store/accounts.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import * as v from '@badrap/valita'
|
||||||
|
import { persistentAtom } from '@nanostores/persistent'
|
||||||
|
import { computed } from 'nanostores'
|
||||||
|
|
||||||
|
export interface TelegramAccount {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
bot: boolean
|
||||||
|
testMode: boolean
|
||||||
|
telegramId: number
|
||||||
|
dcId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountSchema = v.object({
|
||||||
|
_v: v.literal(1),
|
||||||
|
id: v.string(),
|
||||||
|
telegramId: v.number(),
|
||||||
|
bot: v.boolean(),
|
||||||
|
name: v.string(),
|
||||||
|
testMode: v.boolean(),
|
||||||
|
dcId: v.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const $accounts = persistentAtom<TelegramAccount[]>('repl:accounts', [], {
|
||||||
|
encode: v => JSON.stringify(v.map(a => ({ ...a, _v: 1 }))),
|
||||||
|
decode: (str) => {
|
||||||
|
const arr = JSON.parse(str)
|
||||||
|
const res: TelegramAccount[] = []
|
||||||
|
|
||||||
|
for (const account of arr) {
|
||||||
|
const parsed = AccountSchema.try(account)
|
||||||
|
if (parsed.ok) {
|
||||||
|
res.push(parsed.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const $activeAccountId = persistentAtom<string>('repl:activeAccountId')
|
||||||
|
export const $activeAccount = computed([$accounts, $activeAccountId], (accounts, activeAccountId) => {
|
||||||
|
if (!activeAccountId) return null
|
||||||
|
|
||||||
|
const account = accounts.find(account => account.id === activeAccountId)
|
||||||
|
if (!account) return null
|
||||||
|
|
||||||
|
return account
|
||||||
|
})
|
17
src/store/tabs.ts
Normal file
17
src/store/tabs.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
|
export interface EditorTab {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
main: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const $tabs = atom<EditorTab[]>([
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
fileName: 'main.ts',
|
||||||
|
main: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
export const $activeTab = atom('main')
|
31
src/store/use-store.ts
Normal file
31
src/store/use-store.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
// based on https://github.com/nanostores/solid/blob/master/src/index.ts, but using signals instead of stores because they make no sense here
|
||||||
|
import type { Store, StoreValue } from 'nanostores'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import { createSignal, onCleanup } from 'solid-js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to store changes and gets store’s value.
|
||||||
|
*
|
||||||
|
* @param store Store instance.
|
||||||
|
* @returns Store value.
|
||||||
|
*/
|
||||||
|
export function useStore<SomeStore extends Store, Value extends StoreValue<SomeStore>>(
|
||||||
|
store: SomeStore,
|
||||||
|
): Accessor<Value> {
|
||||||
|
// Activate the store explicitly:
|
||||||
|
// https://github.com/nanostores/solid/issues/19
|
||||||
|
const unbindActivation = store.listen(() => {})
|
||||||
|
|
||||||
|
const [state, setState] = createSignal(store.get())
|
||||||
|
|
||||||
|
const unsubscribe = store.subscribe((newValue) => {
|
||||||
|
setState(newValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => unsubscribe())
|
||||||
|
|
||||||
|
// Remove temporary listener now that there is already a proper subscriber.
|
||||||
|
unbindActivation()
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
46
src/sw/avatar.ts
Normal file
46
src/sw/avatar.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import type { BaseTelegramClient } from '@mtcute/web'
|
||||||
|
import { downloadAsBuffer, getMe } from '@mtcute/web/methods.js'
|
||||||
|
import { createInternalClient } from '../lib/telegram.ts'
|
||||||
|
import { timeout } from '../lib/utils.ts'
|
||||||
|
import { $accounts } from '../store/accounts.ts'
|
||||||
|
import { getCacheStorage } from './cache.ts'
|
||||||
|
|
||||||
|
const clients = new Map<string, BaseTelegramClient>()
|
||||||
|
|
||||||
|
export async function handleAvatarRequest(accountId: string) {
|
||||||
|
const cacheKey = new URL(`/sw/avatar/${accountId}`, location.origin)
|
||||||
|
|
||||||
|
const cache = await getCacheStorage()
|
||||||
|
try {
|
||||||
|
const cachedRes = await timeout(cache.match(cacheKey), 10000)
|
||||||
|
if (cachedRes && cachedRes.ok) {
|
||||||
|
return cachedRes
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
let client = clients.get(accountId)
|
||||||
|
if (!client) {
|
||||||
|
client = createInternalClient(accountId)
|
||||||
|
await client.prepare()
|
||||||
|
clients.set(accountId, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = await getMe(client)
|
||||||
|
|
||||||
|
if (!self.photo) {
|
||||||
|
return new Response('No photo', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await downloadAsBuffer(client, self.photo.big)
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
const res = new Response(buf, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await cache.put(cacheKey, res.clone())
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
35
src/sw/cache.ts
Normal file
35
src/sw/cache.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { timeout } from '../lib/utils.ts'
|
||||||
|
|
||||||
|
let _cacheStorage: Cache | undefined
|
||||||
|
|
||||||
|
const CACHE_STORE_NAME = 'cached'
|
||||||
|
|
||||||
|
export async function getCacheStorage() {
|
||||||
|
if (!_cacheStorage) {
|
||||||
|
const storage = await caches.open(CACHE_STORE_NAME)
|
||||||
|
_cacheStorage = storage
|
||||||
|
}
|
||||||
|
return _cacheStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestCache(event: FetchEvent) {
|
||||||
|
try {
|
||||||
|
// const cache = await ctx.caches.open(CACHE_ASSETS_NAME);
|
||||||
|
const cache = await timeout(getCacheStorage(), 10000)
|
||||||
|
const cachedRes = await timeout(cache.match(event.request), 10000)
|
||||||
|
|
||||||
|
if (cachedRes && cachedRes.ok) {
|
||||||
|
return cachedRes
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: HeadersInit = { Vary: '*' }
|
||||||
|
const response = await fetch(event.request, { headers })
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(event.request, response.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch {
|
||||||
|
return fetch(event.request)
|
||||||
|
}
|
||||||
|
}
|
47
src/sw/client.ts
Normal file
47
src/sw/client.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import type { SwMessage } from './main.ts'
|
||||||
|
import { asNonNull, Deferred } from '@fuman/utils'
|
||||||
|
|
||||||
|
export function getServiceWorker() {
|
||||||
|
return asNonNull(navigator.serviceWorker.controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
let registered = false
|
||||||
|
let nextId = 0
|
||||||
|
const pending = new Map<number, Deferred<any>>()
|
||||||
|
|
||||||
|
function swInvokeMethod(request: SwMessage) {
|
||||||
|
const sw = getServiceWorker()
|
||||||
|
if (!registered) {
|
||||||
|
navigator.serviceWorker.addEventListener('message', (e) => {
|
||||||
|
const { id, result, error } = (e as MessageEvent).data
|
||||||
|
const def = pending.get(id)
|
||||||
|
if (!def) return
|
||||||
|
if (error) {
|
||||||
|
def.reject(new Error(error))
|
||||||
|
} else {
|
||||||
|
def.resolve(result)
|
||||||
|
}
|
||||||
|
pending.delete(id)
|
||||||
|
})
|
||||||
|
registered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const def = new Deferred<any>()
|
||||||
|
const id = nextId++
|
||||||
|
;(request as any).id = id
|
||||||
|
pending.set(id, def)
|
||||||
|
sw.postMessage(request)
|
||||||
|
return def.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swUploadScript(name: string, files: Record<string, string>) {
|
||||||
|
return swInvokeMethod({ event: 'UPLOAD_SCRIPT', name, files })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swForgetScript(name: string) {
|
||||||
|
return swInvokeMethod({ event: 'FORGET_SCRIPT', name })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swClearCache() {
|
||||||
|
return swInvokeMethod({ event: 'CLEAR_CACHE' })
|
||||||
|
}
|
85
src/sw/main.ts
Normal file
85
src/sw/main.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import { unknownToError } from '@fuman/utils'
|
||||||
|
import { IS_SAFARI } from '../lib/env.ts'
|
||||||
|
import { handleAvatarRequest } from './avatar.ts'
|
||||||
|
import { requestCache } from './cache.ts'
|
||||||
|
import { clearCache, forgetScript, handleRuntimeRequest, uploadScript } from './runtime.ts'
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
async function handleSwRequest(req: Request, url: URL): Promise<Response> {
|
||||||
|
if (url.pathname.startsWith('/sw/avatar/')) {
|
||||||
|
const accountId = url.pathname.split('/')[3]
|
||||||
|
return handleAvatarRequest(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/sw/runtime/')) {
|
||||||
|
return handleRuntimeRequest(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFetch(event: FetchEvent) {
|
||||||
|
const req = event.request
|
||||||
|
const url = new URL(req.url)
|
||||||
|
|
||||||
|
if (
|
||||||
|
import.meta.env.PROD
|
||||||
|
&& !IS_SAFARI
|
||||||
|
&& event.request.url.indexOf(`${location.origin}/`) === 0
|
||||||
|
&& event.request.url.match(/\.(js|css|jpe?g|json|wasm|png|mp3|svg|tgs|ico|woff2?|ttf|webmanifest?)(?:\?.*)?$/)
|
||||||
|
) {
|
||||||
|
return event.respondWith(requestCache(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/sw/')) {
|
||||||
|
event.respondWith(
|
||||||
|
handleSwRequest(req, url)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
return new Response(err.message || err.toString(), { status: 500 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
self.onfetch = onFetch
|
||||||
|
}
|
||||||
|
|
||||||
|
register()
|
||||||
|
self.onoffline = self.ononline = () => {
|
||||||
|
register()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SwMessage =
|
||||||
|
| { event: 'UPLOAD_SCRIPT', name: string, files: Record<string, string> }
|
||||||
|
| { event: 'FORGET_SCRIPT', name: string }
|
||||||
|
| { event: 'CLEAR_CACHE' }
|
||||||
|
|
||||||
|
function handleMessage(msg: SwMessage) {
|
||||||
|
switch (msg.event) {
|
||||||
|
case 'UPLOAD_SCRIPT': {
|
||||||
|
uploadScript(msg.name, msg.files)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'FORGET_SCRIPT': {
|
||||||
|
forgetScript(msg.name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'CLEAR_CACHE': {
|
||||||
|
clearCache()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (event) => {
|
||||||
|
const msg = event.data as SwMessage & { id: number }
|
||||||
|
try {
|
||||||
|
const result = await handleMessage(msg)
|
||||||
|
event.source!.postMessage({ id: msg.id, result })
|
||||||
|
} catch (e) {
|
||||||
|
event.source!.postMessage({ id: msg.id, error: unknownToError(e).message })
|
||||||
|
}
|
||||||
|
}
|
39
src/sw/register.ts
Normal file
39
src/sw/register.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import workerUrl from '../../sw.ts?worker&url'
|
||||||
|
|
||||||
|
export function registerServiceWorker() {
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: center; align-items: center; height: 100vh; font-size: 2rem; color: #fff; background-color: #000">
|
||||||
|
<div>Service worker support is required to use this app.</div>
|
||||||
|
<div>Please enable it in your browser settings (or update your browser).</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
throw new Error('Service worker not supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.serviceWorker.register(
|
||||||
|
workerUrl,
|
||||||
|
{ type: 'module', scope: './' },
|
||||||
|
).then((reg) => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const FIX_KEY = 'swfix'
|
||||||
|
const swfix = Number(url.searchParams.get(FIX_KEY) ?? 0)
|
||||||
|
|
||||||
|
if (reg.active && !navigator.serviceWorker.controller) {
|
||||||
|
if (swfix >= 3) {
|
||||||
|
throw new Error('no controller')
|
||||||
|
}
|
||||||
|
|
||||||
|
// sometimes this happens on hard refresh
|
||||||
|
return reg.unregister().then(() => {
|
||||||
|
url.searchParams.set(FIX_KEY, `${swfix + 1}`)
|
||||||
|
location.replace(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swfix) {
|
||||||
|
url.searchParams.delete(FIX_KEY)
|
||||||
|
history.pushState(undefined, '', url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
136
src/sw/runtime.ts
Normal file
136
src/sw/runtime.ts
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
import { utf8 } from '@fuman/utils'
|
||||||
|
import { generateImportMap, generateRunnerHtml } from '../lib/runtime.ts'
|
||||||
|
import { VfsStorage } from '../lib/vfs/storage.ts'
|
||||||
|
|
||||||
|
const libraryCache = new Map<string, Map<string, Uint8Array>>()
|
||||||
|
let importMapCache: Record<string, string> | undefined
|
||||||
|
let vfs: VfsStorage | undefined
|
||||||
|
|
||||||
|
const scriptsStorage = new Map<string, string>()
|
||||||
|
|
||||||
|
async function getVfs() {
|
||||||
|
if (!vfs) {
|
||||||
|
vfs = await VfsStorage.create()
|
||||||
|
}
|
||||||
|
return vfs
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLibrary(name: string) {
|
||||||
|
const vfs = await getVfs()
|
||||||
|
if (libraryCache.has(name)) {
|
||||||
|
return libraryCache.get(name)!
|
||||||
|
}
|
||||||
|
|
||||||
|
const lib = await vfs.readLibrary(name)
|
||||||
|
if (!lib) return null
|
||||||
|
|
||||||
|
const map = new Map<string, Uint8Array>()
|
||||||
|
libraryCache.set(name, map)
|
||||||
|
for (const file of lib.files) {
|
||||||
|
map.set(file.path, file.contents)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadScript(name: string, files: Record<string, string>) {
|
||||||
|
for (const [fileName, contents] of Object.entries(files)) {
|
||||||
|
scriptsStorage.set(`${name}/${fileName}`, contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetScript(name: string) {
|
||||||
|
const folder = `${name}/`
|
||||||
|
for (const path of scriptsStorage.keys()) {
|
||||||
|
if (path.startsWith(folder)) {
|
||||||
|
scriptsStorage.delete(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCache() {
|
||||||
|
libraryCache.clear()
|
||||||
|
scriptsStorage.clear()
|
||||||
|
importMapCache = undefined
|
||||||
|
vfs = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sw/runtime/[library-name]/[...file...]
|
||||||
|
export async function handleRuntimeRequest(url: URL) {
|
||||||
|
const path = url.pathname.slice('/sw/runtime/'.length)
|
||||||
|
|
||||||
|
if (path === '_iframe.html') {
|
||||||
|
// primarily a workaround for chrome bug: https://crbug.com/880768
|
||||||
|
// (tldr: iframes created with blob: do not inherit service worker from the parent page)
|
||||||
|
// but also a nice way to warm up the cache
|
||||||
|
if (!importMapCache) {
|
||||||
|
const vfs = await getVfs()
|
||||||
|
const libNames = (await vfs.getAvailableLibs()).filter(lib => !lib.startsWith('@types/'))
|
||||||
|
const allLibs = await Promise.all(libNames.map(loadLibrary))
|
||||||
|
|
||||||
|
const packageJsons: any[] = []
|
||||||
|
for (const lib of allLibs) {
|
||||||
|
if (!lib) continue
|
||||||
|
const pkgJson = lib.get('package.json')
|
||||||
|
if (!pkgJson) continue
|
||||||
|
packageJsons.push(JSON.parse(utf8.decoder.decode(pkgJson)))
|
||||||
|
}
|
||||||
|
|
||||||
|
importMapCache = await generateImportMap(packageJsons)
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = generateRunnerHtml(importMapCache)
|
||||||
|
return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('script/')) {
|
||||||
|
const scriptId = path.slice('script/'.length)
|
||||||
|
if (!scriptsStorage.has(scriptId)) {
|
||||||
|
return new Response('Not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(scriptsStorage.get(scriptId)!, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/javascript',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let slashIdx = path.indexOf('/')
|
||||||
|
if (slashIdx === -1) {
|
||||||
|
return new Response('Not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path[0] === '@') {
|
||||||
|
// scoped package
|
||||||
|
slashIdx = path.indexOf('/', slashIdx + 1)
|
||||||
|
if (slashIdx === -1) {
|
||||||
|
return new Response('Not found', { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageName = path.slice(0, slashIdx)
|
||||||
|
const filePath = path.slice(slashIdx + 1)
|
||||||
|
|
||||||
|
const files = await loadLibrary(packageName)
|
||||||
|
if (!files) {
|
||||||
|
return new Response('Library not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = files.get(filePath)
|
||||||
|
if (!file) return new Response('Not found', { status: 404 })
|
||||||
|
|
||||||
|
const ext = filePath.split('.').pop()!
|
||||||
|
const mime = {
|
||||||
|
js: 'application/javascript',
|
||||||
|
mjs: 'application/javascript',
|
||||||
|
wasm: 'application/wasm',
|
||||||
|
json: 'application/json',
|
||||||
|
}[ext] ?? 'application/octet-stream'
|
||||||
|
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mime,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
5
src/vite-env.d.ts
vendored
Normal file
5
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '@mtcute/web?external' {
|
||||||
|
export * from '@mtcute/web'
|
||||||
|
}
|
3
sw.ts
Normal file
3
sw.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
// we have to do it this way because otherwise vite will bundle it to
|
||||||
|
// /src/sw/main.js and we wont be able to scope it to /
|
||||||
|
import './src/sw/main.ts'
|
106
tailwind.config.js
Normal file
106
tailwind.config.js
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
/** @type {import("tailwindcss").Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class', '[data-kb-theme="dark"]'],
|
||||||
|
content: ['./src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: {
|
||||||
|
'2xl': '1400px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
DEFAULT: 'hsl(var(--info))',
|
||||||
|
foreground: 'hsl(var(--info-foreground))',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: 'hsl(var(--success))',
|
||||||
|
foreground: 'hsl(var(--success-foreground))',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: 'hsl(var(--warning))',
|
||||||
|
foreground: 'hsl(var(--warning-foreground))',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
DEFAULT: 'hsl(var(--error))',
|
||||||
|
foreground: 'hsl(var(--error-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
xl: 'calc(var(--radius) + 4px)',
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: 'var(--kb-accordion-content-height)' },
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--kb-accordion-content-height)' },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
'content-show': {
|
||||||
|
from: { opacity: 0, transform: 'scale(0.96)' },
|
||||||
|
to: { opacity: 1, transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
'content-hide': {
|
||||||
|
from: { opacity: 1, transform: 'scale(1)' },
|
||||||
|
to: { opacity: 0, transform: 'scale(0.96)' },
|
||||||
|
},
|
||||||
|
'caret-blink': {
|
||||||
|
'0%, 70%, 100%': { opacity: 1 },
|
||||||
|
'20%, 50%': { opacity: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
'2xs': ['0.625rem', { lineHeight: '1rem' }],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
'content-show': 'content-show 0.2s ease-out',
|
||||||
|
'content-hide': 'content-hide 0.2s ease-out',
|
||||||
|
'caret-blink': 'caret-blink 1.2s ease-out infinite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
}
|
31
tsconfig.app.json
Normal file
31
tsconfig.app.json
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "solid-js",
|
||||||
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
|
"WebWorker"
|
||||||
|
],
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"module": "ESNext",
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"files": []
|
||||||
|
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"module": "ESNext",
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
8
ui.config.json
Normal file
8
ui.config.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"tsx": true,
|
||||||
|
"componentDir": "./src/lib/components/ui",
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/app.css"
|
||||||
|
}
|
||||||
|
}
|
1
vendor/.gitignore
vendored
Normal file
1
vendor/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
chobitsu
|
13
vendor/build-patched-chobitsu.sh
vendored
Normal file
13
vendor/build-patched-chobitsu.sh
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# https://github.com/liriliri/chobitsu/issues/18
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
git clone https://github.com/liriliri/chobitsu
|
||||||
|
cd chobitsu
|
||||||
|
git checkout v1.8.4
|
||||||
|
git apply ../chobitsu.patch
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run build
|
74
vendor/chobitsu.patch
vendored
Normal file
74
vendor/chobitsu.patch
vendored
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
diff --git a/src/domains/Runtime.ts b/src/domains/Runtime.ts
|
||||||
|
index b980929..67388f9 100644
|
||||||
|
--- a/src/domains/Runtime.ts
|
||||||
|
+++ b/src/domains/Runtime.ts
|
||||||
|
@@ -64,9 +64,9 @@ export function getProperties(
|
||||||
|
return objManager.getProperties(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
-export function evaluate(
|
||||||
|
+export async function evaluate(
|
||||||
|
params: Runtime.EvaluateRequest
|
||||||
|
-): Runtime.EvaluateResponse {
|
||||||
|
+): Promise<Runtime.EvaluateResponse> {
|
||||||
|
const ret: any = {}
|
||||||
|
|
||||||
|
let result: any
|
||||||
|
@@ -74,7 +74,7 @@ export function evaluate(
|
||||||
|
if (params.throwOnSideEffect && hasSideEffect(params.expression)) {
|
||||||
|
throw EvalError('Possible side-effect in debug-evaluate')
|
||||||
|
}
|
||||||
|
- result = evaluateJs(params.expression)
|
||||||
|
+ result = await evaluateJs(params.expression)
|
||||||
|
setGlobal('$_', result)
|
||||||
|
ret.result = objManager.wrap(result, {
|
||||||
|
generatePreview: true,
|
||||||
|
diff --git a/src/index.ts b/src/index.ts
|
||||||
|
index 1dd6e97..0ea8300 100644
|
||||||
|
--- a/src/index.ts
|
||||||
|
+++ b/src/index.ts
|
||||||
|
@@ -38,7 +38,6 @@ chobitsu.register('Runtime', {
|
||||||
|
discardConsoleEntries: noop,
|
||||||
|
getHeapUsage: noop,
|
||||||
|
getIsolateId: noop,
|
||||||
|
- releaseObject: noop,
|
||||||
|
releaseObjectGroup: noop,
|
||||||
|
runIfWaitingForDebugger: noop,
|
||||||
|
})
|
||||||
|
diff --git a/src/lib/evaluate.ts b/src/lib/evaluate.ts
|
||||||
|
index 0732a13..f0abcce 100644
|
||||||
|
--- a/src/lib/evaluate.ts
|
||||||
|
+++ b/src/lib/evaluate.ts
|
||||||
|
@@ -44,14 +44,14 @@ export function setGlobal(name: string, val: any) {
|
||||||
|
global[name] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
-export default function evaluate(expression: string) {
|
||||||
|
+export default async function evaluate(expression: string) {
|
||||||
|
let ret
|
||||||
|
|
||||||
|
injectGlobal()
|
||||||
|
try {
|
||||||
|
- ret = eval.call(window, `(${expression})`)
|
||||||
|
+ ret = await eval.call(window, `(async() => (${expression}))()`)
|
||||||
|
} catch (e) {
|
||||||
|
- ret = eval.call(window, expression)
|
||||||
|
+ ret = await eval.call(window, `(async () => {${expression}})()`)
|
||||||
|
}
|
||||||
|
clearGlobal()
|
||||||
|
|
||||||
|
diff --git a/src/lib/objManager.ts b/src/lib/objManager.ts
|
||||||
|
index ff6a9e3..d79cdd6 100644
|
||||||
|
--- a/src/lib/objManager.ts
|
||||||
|
+++ b/src/lib/objManager.ts
|
||||||
|
@@ -46,6 +46,10 @@ export function wrap(
|
||||||
|
value: any,
|
||||||
|
{ generatePreview = false, self = value } = {}
|
||||||
|
): any {
|
||||||
|
+ if (typeof value === 'object' && value !== null && typeof value.toJSON === 'function') {
|
||||||
|
+ value = value.toJSON()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
const ret = basic(value)
|
||||||
|
const { type, subtype } = ret
|
||||||
|
|
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import solid from 'vite-plugin-solid'
|
||||||
|
import externalizeDeps from './scripts/vite-plugin-externalize-dependencies.ts'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@mtcute/wasm'],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
solid(),
|
||||||
|
externalizeDeps({
|
||||||
|
externals: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
Loading…
Reference in a new issue