Initial commit: SSH Key Generator
React+Vite app that generates Ed25519 and RSA SSH keys entirely in the browser using Web Crypto API and micro-key-producer. Styled with Tailwind CSS v4 dark theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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?
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SSH Key Generator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3515
package-lock.json
generated
Normal file
3515
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ssh-generator",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"micro-key-producer": "^0.8.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
20
src/App.jsx
Normal file
20
src/App.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import KeyGenerator from './components/KeyGenerator.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white flex flex-col">
|
||||
<header className="pt-12 pb-8 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">SSH Key Generator</h1>
|
||||
<p className="mt-2 text-zinc-400">Generate SSH keys directly in your browser</p>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-4 pb-16">
|
||||
<KeyGenerator />
|
||||
</main>
|
||||
|
||||
<footer className="py-6 text-center text-xs text-zinc-600">
|
||||
Keys are generated client-side using Web Crypto API. No data leaves your browser.
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/components/CommentField.jsx
Normal file
14
src/components/CommentField.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function CommentField({ value, onChange }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-2">Comment</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="user@hostname"
|
||||
className="w-full px-4 py-3 rounded-lg border border-zinc-700 bg-zinc-800/50 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/components/GenerateButton.jsx
Normal file
26
src/components/GenerateButton.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function GenerateButton({ onClick, loading }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="w-full px-6 py-3 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
Generate SSH Key
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
41
src/components/KeyDisplay.jsx
Normal file
41
src/components/KeyDisplay.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
import { copyToClipboard, downloadFile } from '../lib/utils.js'
|
||||
|
||||
export default function KeyDisplay({ label, value, filename }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await copyToClipboard(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadFile(filename, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-zinc-300">{label}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-3 py-1 text-xs rounded border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 transition-colors cursor-pointer"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-1 text-xs rounded border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 transition-colors cursor-pointer"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="w-full p-4 rounded-lg border border-zinc-700 bg-zinc-900/50 text-zinc-300 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all max-h-48">
|
||||
{value}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
src/components/KeyGenerator.jsx
Normal file
59
src/components/KeyGenerator.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import KeyTypeSelector from './KeyTypeSelector.jsx'
|
||||
import KeySizeSelector from './KeySizeSelector.jsx'
|
||||
import CommentField from './CommentField.jsx'
|
||||
import GenerateButton from './GenerateButton.jsx'
|
||||
import KeyOutput from './KeyOutput.jsx'
|
||||
import { generateKeyPair } from '../lib/keygen.js'
|
||||
|
||||
export default function KeyGenerator() {
|
||||
const [keyType, setKeyType] = useState('ed25519')
|
||||
const [keySize, setKeySize] = useState(4096)
|
||||
const [comment, setComment] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [keys, setKeys] = useState(null)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await generateKeyPair({ keyType, keySize, comment })
|
||||
setKeys({ ...result, keyType })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/80 backdrop-blur p-6 sm:p-8 space-y-6">
|
||||
<KeyTypeSelector value={keyType} onChange={setKeyType} />
|
||||
|
||||
{keyType === 'rsa' && (
|
||||
<KeySizeSelector value={keySize} onChange={setKeySize} />
|
||||
)}
|
||||
|
||||
<CommentField value={comment} onChange={setComment} />
|
||||
|
||||
<GenerateButton onClick={handleGenerate} loading={loading} />
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg border border-red-800 bg-red-900/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys && (
|
||||
<KeyOutput
|
||||
publicKey={keys.publicKey}
|
||||
privateKey={keys.privateKey}
|
||||
keyType={keys.keyType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/components/KeyOutput.jsx
Normal file
32
src/components/KeyOutput.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import KeyDisplay from './KeyDisplay.jsx'
|
||||
|
||||
export default function KeyOutput({ publicKey, privateKey, keyType }) {
|
||||
const pubFilename = keyType === 'ed25519' ? 'id_ed25519.pub' : 'id_rsa.pub'
|
||||
const privFilename = keyType === 'ed25519' ? 'id_ed25519' : 'id_rsa'
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-zinc-800" />
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">Generated Keys</span>
|
||||
<div className="h-px flex-1 bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
<KeyDisplay
|
||||
label="Public Key"
|
||||
value={publicKey}
|
||||
filename={pubFilename}
|
||||
/>
|
||||
|
||||
<KeyDisplay
|
||||
label="Private Key"
|
||||
value={privateKey}
|
||||
filename={privFilename}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-zinc-500 text-center">
|
||||
Keys are generated entirely in your browser. Nothing is sent to any server.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/components/KeySizeSelector.jsx
Normal file
25
src/components/KeySizeSelector.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
const SIZES = [2048, 4096]
|
||||
|
||||
export default function KeySizeSelector({ value, onChange }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-2">Key Size (bits)</label>
|
||||
<div className="flex gap-3">
|
||||
{SIZES.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => onChange(size)}
|
||||
className={`flex-1 px-4 py-3 rounded-lg border text-center transition-all cursor-pointer ${
|
||||
value === size
|
||||
? 'border-blue-500 bg-blue-500/10 text-white'
|
||||
: 'border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{size}</div>
|
||||
<div className="text-xs mt-1 opacity-70">{size === 4096 ? 'More secure' : 'Standard'}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/components/KeyTypeSelector.jsx
Normal file
28
src/components/KeyTypeSelector.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
const KEY_TYPES = [
|
||||
{ value: 'ed25519', label: 'Ed25519', desc: 'Modern, fast, recommended' },
|
||||
{ value: 'rsa', label: 'RSA', desc: 'Wide compatibility' },
|
||||
]
|
||||
|
||||
export default function KeyTypeSelector({ value, onChange }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-2">Key Type</label>
|
||||
<div className="flex gap-3">
|
||||
{KEY_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
onClick={() => onChange(type.value)}
|
||||
className={`flex-1 px-4 py-3 rounded-lg border text-left transition-all cursor-pointer ${
|
||||
value === type.value
|
||||
? 'border-blue-500 bg-blue-500/10 text-white'
|
||||
: 'border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{type.label}</div>
|
||||
<div className="text-xs mt-1 opacity-70">{type.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
src/index.css
Normal file
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
11
src/lib/ed25519.js
Normal file
11
src/lib/ed25519.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import getKeys from 'micro-key-producer/ssh.js'
|
||||
|
||||
export async function generateEd25519(comment) {
|
||||
const privKeyRaw = crypto.getRandomValues(new Uint8Array(32))
|
||||
const keys = getKeys(privKeyRaw, comment)
|
||||
|
||||
return {
|
||||
publicKey: keys.publicKey,
|
||||
privateKey: keys.privateKey,
|
||||
}
|
||||
}
|
||||
16
src/lib/keygen.js
Normal file
16
src/lib/keygen.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { generateRSA } from './rsa.js'
|
||||
import { generateEd25519 } from './ed25519.js'
|
||||
|
||||
export async function generateKeyPair({ keyType, keySize, comment }) {
|
||||
const commentStr = comment || 'user@browser'
|
||||
|
||||
if (keyType === 'ed25519') {
|
||||
return generateEd25519(commentStr)
|
||||
}
|
||||
|
||||
if (keyType === 'rsa') {
|
||||
return generateRSA(keySize, commentStr)
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported key type: ${keyType}`)
|
||||
}
|
||||
100
src/lib/openssh-encoder.js
Normal file
100
src/lib/openssh-encoder.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// OpenSSH binary format encoding helpers
|
||||
|
||||
export function encodeUint32(value) {
|
||||
const buf = new Uint8Array(4)
|
||||
buf[0] = (value >>> 24) & 0xff
|
||||
buf[1] = (value >>> 16) & 0xff
|
||||
buf[2] = (value >>> 8) & 0xff
|
||||
buf[3] = value & 0xff
|
||||
return buf
|
||||
}
|
||||
|
||||
export function encodeString(str) {
|
||||
const encoded = new TextEncoder().encode(str)
|
||||
return encodeBytes(encoded)
|
||||
}
|
||||
|
||||
export function encodeBytes(data) {
|
||||
const buf = new Uint8Array(4 + data.length)
|
||||
const len = encodeUint32(data.length)
|
||||
buf.set(len, 0)
|
||||
buf.set(data, 4)
|
||||
return buf
|
||||
}
|
||||
|
||||
export function encodeMpint(bytes) {
|
||||
// If high bit is set, prepend a zero byte
|
||||
if (bytes[0] & 0x80) {
|
||||
const padded = new Uint8Array(bytes.length + 1)
|
||||
padded[0] = 0
|
||||
padded.set(bytes, 1)
|
||||
return encodeBytes(padded)
|
||||
}
|
||||
return encodeBytes(bytes)
|
||||
}
|
||||
|
||||
export function concat(...arrays) {
|
||||
const totalLen = arrays.reduce((sum, a) => sum + a.length, 0)
|
||||
const result = new Uint8Array(totalLen)
|
||||
let offset = 0
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset)
|
||||
offset += arr.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function wrapPrivateKey(keyType, pubkeyBlob, privatePart, comment) {
|
||||
// OpenSSH private key format (openssh-key-v1)
|
||||
const AUTH_MAGIC = new TextEncoder().encode('openssh-key-v1\0')
|
||||
const ciphername = encodeString('none')
|
||||
const kdfname = encodeString('none')
|
||||
const kdfoptions = encodeString('')
|
||||
const nkeys = encodeUint32(1)
|
||||
const pubkeyWrapped = encodeBytes(pubkeyBlob)
|
||||
|
||||
// checkint - random matching uint32 pair
|
||||
const checkBytes = crypto.getRandomValues(new Uint8Array(4))
|
||||
const checkInt = concat(checkBytes, checkBytes)
|
||||
|
||||
const commentBytes = encodeString(comment)
|
||||
const inner = concat(checkInt, privatePart, commentBytes)
|
||||
|
||||
// Pad to block size of 8 (for 'none' cipher)
|
||||
const blockSize = 8
|
||||
const padLen = blockSize - (inner.length % blockSize)
|
||||
const padding = new Uint8Array(padLen)
|
||||
for (let i = 0; i < padLen; i++) padding[i] = i + 1
|
||||
const paddedInner = concat(inner, padding)
|
||||
|
||||
const privateWrapped = encodeBytes(paddedInner)
|
||||
|
||||
const blob = concat(
|
||||
AUTH_MAGIC,
|
||||
ciphername,
|
||||
kdfname,
|
||||
kdfoptions,
|
||||
nkeys,
|
||||
pubkeyWrapped,
|
||||
privateWrapped
|
||||
)
|
||||
|
||||
return blob
|
||||
}
|
||||
|
||||
export function formatPEM(label, data) {
|
||||
const b64 = uint8ToBase64(data)
|
||||
const lines = []
|
||||
for (let i = 0; i < b64.length; i += 70) {
|
||||
lines.push(b64.slice(i, i + 70))
|
||||
}
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
export function uint8ToBase64(bytes) {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
81
src/lib/rsa.js
Normal file
81
src/lib/rsa.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
encodeString,
|
||||
encodeBytes,
|
||||
encodeMpint,
|
||||
concat,
|
||||
wrapPrivateKey,
|
||||
formatPEM,
|
||||
uint8ToBase64,
|
||||
} from './openssh-encoder.js'
|
||||
|
||||
export async function generateRSA(bits, comment) {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: bits,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify']
|
||||
)
|
||||
|
||||
const jwkPriv = await crypto.subtle.exportKey('jwk', keyPair.privateKey)
|
||||
|
||||
const n = b64urlToBytes(jwkPriv.n)
|
||||
const e = b64urlToBytes(jwkPriv.e)
|
||||
const d = b64urlToBytes(jwkPriv.d)
|
||||
const p = b64urlToBytes(jwkPriv.p)
|
||||
const q = b64urlToBytes(jwkPriv.q)
|
||||
const dp = b64urlToBytes(jwkPriv.dp)
|
||||
const dq = b64urlToBytes(jwkPriv.dq)
|
||||
const qi = b64urlToBytes(jwkPriv.qi)
|
||||
|
||||
const publicKey = formatRSAPublicKey(e, n, comment)
|
||||
const privateKey = formatRSAPrivateKey(n, e, d, p, q, dp, dq, qi, comment)
|
||||
|
||||
return { publicKey, privateKey }
|
||||
}
|
||||
|
||||
function b64urlToBytes(b64url) {
|
||||
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const pad = (4 - (b64.length % 4)) % 4
|
||||
const padded = b64 + '='.repeat(pad)
|
||||
const binary = atob(padded)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function formatRSAPublicKey(e, n, comment) {
|
||||
const keyType = encodeString('ssh-rsa')
|
||||
const eEnc = encodeMpint(e)
|
||||
const nEnc = encodeMpint(n)
|
||||
const blob = concat(keyType, eEnc, nEnc)
|
||||
const b64 = uint8ToBase64(blob)
|
||||
return `ssh-rsa ${b64} ${comment}`
|
||||
}
|
||||
|
||||
function formatRSAPrivateKey(n, e, d, p, q, dp, dq, qi, comment) {
|
||||
const keyTypeStr = 'ssh-rsa'
|
||||
const keyType = encodeString(keyTypeStr)
|
||||
const eEnc = encodeMpint(e)
|
||||
const nEnc = encodeMpint(n)
|
||||
const pubkeyBlob = concat(keyType, eEnc, nEnc)
|
||||
|
||||
// OpenSSH RSA private key order: keytype, n, e, d, iqmp (qi), p, q
|
||||
const privatePart = concat(
|
||||
encodeString(keyTypeStr),
|
||||
encodeMpint(n),
|
||||
encodeMpint(e),
|
||||
encodeMpint(d),
|
||||
encodeMpint(qi),
|
||||
encodeMpint(p),
|
||||
encodeMpint(q)
|
||||
)
|
||||
|
||||
const blob = wrapPrivateKey(keyTypeStr, pubkeyBlob, privatePart, comment)
|
||||
return formatPEM('OPENSSH PRIVATE KEY', blob)
|
||||
}
|
||||
21
src/lib/utils.js
Normal file
21
src/lib/utils.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export function toBase64(uint8arr) {
|
||||
let binary = ''
|
||||
for (let i = 0; i < uint8arr.length; i++) {
|
||||
binary += String.fromCharCode(uint8arr[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
export function downloadFile(filename, content) {
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
8
vite.config.js
Normal file
8
vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: '/ssh/',
|
||||
})
|
||||
Reference in New Issue
Block a user