Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac520dfcff | ||
|
|
65a37ad477 | ||
|
|
e8f62c18a6 | ||
|
|
9f2f235c09 | ||
|
|
0634c26138 | ||
|
|
e4630082e0 | ||
|
|
00d2bdd384 | ||
| 1b4cb0382d | |||
|
|
340a1c3e61 | ||
|
|
6281a0d467 | ||
| e23db74eb8 | |||
|
|
3e164fb687 | ||
|
|
1c6e06bd6d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,3 +25,5 @@ pnpm-debug.log*
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
.env
|
||||||
|
|||||||
@@ -4,19 +4,32 @@ import tailwind from '@astrojs/tailwind';
|
|||||||
import react from '@astrojs/react';
|
import react from '@astrojs/react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://siliconpin.cs1.hz.siliconpin.com',
|
site: 'https://sp-dev-h1-astgsbchdykdvtf.siliconpin.com',
|
||||||
|
integrations: [tailwind(), react()],
|
||||||
|
|
||||||
|
// Vite-specific settings (including proxy)
|
||||||
vite: {
|
vite: {
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: ['siliconpin.cs1.hz.siliconpin.com'],
|
allowedHosts: ['sp-dev-h1-astgsbchdykdvtf.siliconpin.com'],
|
||||||
},
|
proxy: {
|
||||||
preview: {
|
'/api': {
|
||||||
allowedHosts: ['siliconpin.cs1.hz.siliconpin.com'],
|
target: 'http://localhost:8080', // Your backend server
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
secure: false, // Only needed if using self-signed HTTPS
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
integrations: [tailwind(), react()],
|
preview: {
|
||||||
|
allowedHosts: ['sp-dev-h1-astgsbchdykdvtf.siliconpin.com'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Astro server settings
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0', // Accessible on all network interfaces
|
||||||
port: 4000,
|
port: 4000,
|
||||||
},
|
},
|
||||||
|
|
||||||
output: 'static',
|
output: 'static',
|
||||||
});
|
});
|
||||||
24
env_sample.txt
Normal file
24
env_sample.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
PUBLIC_USER_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/users/
|
||||||
|
|
||||||
|
PUBLIC_SERVICE_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/services/
|
||||||
|
|
||||||
|
PUBLIC_INVOICE_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/invoice/
|
||||||
|
|
||||||
|
PUBLIC_MINIO_UPLOAD_URL=https://hostapi2.cs1.hz.siliconpin.com/api/storage/upload
|
||||||
|
|
||||||
|
PUBLIC_HOST_API2_USERS=https://hostapi2.cs1.hz.siliconpin.com/api/users
|
||||||
|
|
||||||
|
PUBLIC_TOPIC_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/topics/
|
||||||
|
|
||||||
|
PUBLIC_TICKET_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/ticket/index.php
|
||||||
|
|
||||||
|
PUBLIC_HETZNER_API_KEY=uMSBR9nxdtbuvazsVM8YMMDd0PvuynpgJbmzFIO47HblMlh7tlHT8wV05sQ28Squ
|
||||||
|
|
||||||
|
PUBLIC_UTHO_API_KEY=IoNXhkRJsQPyOEqFMceSfzuKaDLrpxUCATgZjiVdvYlBHbwWmGtn
|
||||||
|
|
||||||
|
PUBLIC_DIGITALOCEAN_API_KEY=dop_v1_b6a075ece5786faf7c58d21761dbf95d47af372da062d68a870ce2a0bae51adf
|
||||||
|
|
||||||
|
PUBLIC_POCKETBASE_URL=https://tst-pb.s38.siliconpin.com
|
||||||
|
|
||||||
|
|
||||||
|
ENV_TYPE=DEV
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"meta": {
|
|
||||||
"expiry": "2025-06-29 11:36:07.506Z",
|
|
||||||
"rawUser": {
|
|
||||||
"email": "suvodipghosh35@gmail.com",
|
|
||||||
"id": "3913660032212288",
|
|
||||||
"name": "Suvodip Ghosh",
|
|
||||||
"picture": {
|
|
||||||
"data": {
|
|
||||||
"height": 201,
|
|
||||||
"is_silhouette": false,
|
|
||||||
"url": "https://platform-lookaside.fbsbx.com/platform/profilepic/?asid=3913660032212288&height=200&width=200&ext=1748604969&hash=AbaH-X9tWREaBN46FPCtAG6g",
|
|
||||||
"width": 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": "3913660032212288",
|
|
||||||
"name": "Suvodip Ghosh",
|
|
||||||
"username": "",
|
|
||||||
"email": "suvodipghosh35@gmail.com",
|
|
||||||
"avatarURL": "https://platform-lookaside.fbsbx.com/platform/profilepic/?asid=3913660032212288&height=200&width=200&ext=1748604969&hash=AbaH-X9tWREaBN46FPCtAG6g",
|
|
||||||
"accessToken": "EAATKn4EGuGABO5BQDFZBCNX6QrVyfsUZBCfJKov5OzHHrQIAEu5wOePaLcvIZBklT9MPkoiZCrZCraJNIwtBzGrKxZCeUMwKJRAbicZAPuK1gesmDl27iL3J7vp0sAjMoue4yACIZAIRDZBzsDU0Yi91ztFJ6FOJ2qWVfLkTgjkKwEudTe95vUYcN1E9oS6bo6smA75KdVB6RgGiK217C4ez9aRDrQcvH3ZBSGZBY2QZBW1KynycojdqhKAQS68xT1Bpezv80kEP5DQfONMMNbaUOwBU9r0WxwS0viCbW0qZAk92mXYZAZBQhny5Ili4aP1jmAZD",
|
|
||||||
"refreshToken": "",
|
|
||||||
"avatarUrl": "https://platform-lookaside.fbsbx.com/platform/profilepic/?asid=3913660032212288&height=200&width=200&ext=1748604969&hash=AbaH-X9tWREaBN46FPCtAG6g"
|
|
||||||
},
|
|
||||||
"record": {
|
|
||||||
"avatar": "colorful_bird_sits_branch_forest_uiu589q5aa.jpg",
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"collectionName": "users",
|
|
||||||
"created": "2025-03-10 13:17:51.909Z",
|
|
||||||
"email": "neha@siliconpin.com",
|
|
||||||
"emailVisibility": true,
|
|
||||||
"id": "7fp08mzhs7qgmg9",
|
|
||||||
"name": "Neha Ghosh",
|
|
||||||
"phone": "9090935312",
|
|
||||||
"provider": "",
|
|
||||||
"type": "admin",
|
|
||||||
"updated": "2025-04-30 09:34:24.970Z",
|
|
||||||
"verified": true
|
|
||||||
},
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NDY2MTc3NzAsImlkIjoiN2ZwMDhtemhzN3FnbWc5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.5Xz_0hoAod9YLgeOTHbWTEKdRxqusZPUDyb2D0SfVKQ"
|
|
||||||
}
|
|
||||||
59
fix-php-tags-and-decode.cjs
Normal file
59
fix-php-tags-and-decode.cjs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const buildDir = path.join(__dirname, 'dist');
|
||||||
|
|
||||||
|
// Basic HTML entity decode
|
||||||
|
function decodeHtmlEntities(str) {
|
||||||
|
return str
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec))
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode both URI and HTML entities inside <?php ... ?>
|
||||||
|
function decodePhpBlocks(content) {
|
||||||
|
return content.replace(/<\?php([\s\S]*?)\?>/g, (match, code) => {
|
||||||
|
try {
|
||||||
|
let decoded = decodeURIComponent(code); // %XX to characters
|
||||||
|
decoded = decodeHtmlEntities(decoded); // ' to '
|
||||||
|
return `<?php${decoded}?>`;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Decode failed for block:', code.trim().slice(0, 50), '...');
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixPhpTagsInFile(filePath) {
|
||||||
|
let content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
// Step 1: Replace escaped tags
|
||||||
|
content = content
|
||||||
|
.replace(/<\?php/g, '<?php')
|
||||||
|
.replace(/\?>/g, '?>');
|
||||||
|
|
||||||
|
// Step 2: Decode content inside <?php ... ?>
|
||||||
|
content = decodePhpBlocks(content);
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8');
|
||||||
|
console.log(`✅ Processed: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkDir(dir) {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walkDir(fullPath);
|
||||||
|
} else if (fullPath.endsWith('.html') || fullPath.endsWith('.php')) {
|
||||||
|
fixPhpTagsInFile(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkDir(buildDir);
|
||||||
|
console.log('🎉 All PHP tag fixes, URL decodes, and HTML entity decodes complete.');
|
||||||
77
github.json
77
github.json
@@ -1,77 +0,0 @@
|
|||||||
{
|
|
||||||
"meta": {
|
|
||||||
"expiry": "",
|
|
||||||
"rawUser": {
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/128118374?v=4",
|
|
||||||
"bio": null,
|
|
||||||
"blog": "",
|
|
||||||
"collaborators": 0,
|
|
||||||
"company": null,
|
|
||||||
"created_at": "2023-03-17T04:49:12Z",
|
|
||||||
"disk_usage": 55453,
|
|
||||||
"email": null,
|
|
||||||
"events_url": "https://api.github.com/users/suvodip35/events{/privacy}",
|
|
||||||
"followers": 0,
|
|
||||||
"followers_url": "https://api.github.com/users/suvodip35/followers",
|
|
||||||
"following": 1,
|
|
||||||
"following_url": "https://api.github.com/users/suvodip35/following{/other_user}",
|
|
||||||
"gists_url": "https://api.github.com/users/suvodip35/gists{/gist_id}",
|
|
||||||
"gravatar_id": "",
|
|
||||||
"hireable": null,
|
|
||||||
"html_url": "https://github.com/suvodip35",
|
|
||||||
"id": 128118374,
|
|
||||||
"location": null,
|
|
||||||
"login": "suvodip35",
|
|
||||||
"name": "Suvodip",
|
|
||||||
"node_id": "U_kgDOB6LuZg",
|
|
||||||
"notification_email": null,
|
|
||||||
"organizations_url": "https://api.github.com/users/suvodip35/orgs",
|
|
||||||
"owned_private_repos": 4,
|
|
||||||
"plan": {
|
|
||||||
"collaborators": 0,
|
|
||||||
"name": "free",
|
|
||||||
"private_repos": 10000,
|
|
||||||
"space": 976562499
|
|
||||||
},
|
|
||||||
"private_gists": 0,
|
|
||||||
"public_gists": 0,
|
|
||||||
"public_repos": 4,
|
|
||||||
"received_events_url": "https://api.github.com/users/suvodip35/received_events",
|
|
||||||
"repos_url": "https://api.github.com/users/suvodip35/repos",
|
|
||||||
"site_admin": false,
|
|
||||||
"starred_url": "https://api.github.com/users/suvodip35/starred{/owner}{/repo}",
|
|
||||||
"subscriptions_url": "https://api.github.com/users/suvodip35/subscriptions",
|
|
||||||
"total_private_repos": 4,
|
|
||||||
"twitter_username": null,
|
|
||||||
"two_factor_authentication": true,
|
|
||||||
"type": "User",
|
|
||||||
"updated_at": "2025-04-30T08:46:03Z",
|
|
||||||
"url": "https://api.github.com/users/suvodip35",
|
|
||||||
"user_view_type": "private"
|
|
||||||
},
|
|
||||||
"id": "128118374",
|
|
||||||
"name": "Suvodip",
|
|
||||||
"username": "suvodip35",
|
|
||||||
"email": "suvodipghosh35@gmail.com",
|
|
||||||
"avatarURL": "https://avatars.githubusercontent.com/u/128118374?v=4",
|
|
||||||
"accessToken": "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",
|
|
||||||
"refreshToken": "",
|
|
||||||
"avatarUrl": "https://avatars.githubusercontent.com/u/128118374?v=4"
|
|
||||||
},
|
|
||||||
"record": {
|
|
||||||
"avatar": "colorful_bird_sits_branch_forest_uiu589q5aa.jpg",
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"collectionName": "users",
|
|
||||||
"created": "2025-03-10 13:17:51.909Z",
|
|
||||||
"email": "neha@siliconpin.com",
|
|
||||||
"emailVisibility": true,
|
|
||||||
"id": "7fp08mzhs7qgmg9",
|
|
||||||
"name": "Neha Ghosh",
|
|
||||||
"phone": "9090935312",
|
|
||||||
"provider": "",
|
|
||||||
"type": "admin",
|
|
||||||
"updated": "2025-04-30 09:34:24.970Z",
|
|
||||||
"verified": true
|
|
||||||
},
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NDY2MTc2NDQsImlkIjoiN2ZwMDhtemhzN3FnbWc5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.XLlZwZ3TrHWlIoxOhkLoKW7AbTPr6VIB49fRpNCTVbY"
|
|
||||||
}
|
|
||||||
38
google.json
38
google.json
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"meta": {
|
|
||||||
"expiry": "2025-04-30 12:35:11.096Z",
|
|
||||||
"rawUser": {
|
|
||||||
"email": "suvoairtel@gmail.com",
|
|
||||||
"family_name": "GHOSH",
|
|
||||||
"given_name": "SUBHODIP",
|
|
||||||
"id": "112026834578335832361",
|
|
||||||
"name": "SUBHODIP GHOSH",
|
|
||||||
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLvTh2_Injp6f23coUiAfTQB_WmLki0vmQinLak3pnY_xnznQ=s96-c",
|
|
||||||
"verified_email": true
|
|
||||||
},
|
|
||||||
"id": "112026834578335832361",
|
|
||||||
"name": "SUBHODIP GHOSH",
|
|
||||||
"username": "",
|
|
||||||
"email": "suvoairtel@gmail.com",
|
|
||||||
"avatarURL": "https://lh3.googleusercontent.com/a/ACg8ocLvTh2_Injp6f23coUiAfTQB_WmLki0vmQinLak3pnY_xnznQ=s96-c",
|
|
||||||
"accessToken": "ya29.a0AZYkNZiV6bkpVlDOQy2ayy6WCULOE5uWYi_QvRSN9vUqJgUxuxcn3xlUzbe6rOHQV7UCjPb_aCmiOojHCMWsCvtky80pUBNCI2rGGhCZYjpSMTispb9qvPyO7CNgsqh5YGxTUUXAkuz37uXm68MFv5YOoofMZ1f39f2OU0sIhisaCgYKAbgSARMSFQHGX2MiEUQ1pMH6u7K92FgWTPp4Uw0178",
|
|
||||||
"refreshToken": "",
|
|
||||||
"avatarUrl": "https://lh3.googleusercontent.com/a/ACg8ocLvTh2_Injp6f23coUiAfTQB_WmLki0vmQinLak3pnY_xnznQ=s96-c"
|
|
||||||
},
|
|
||||||
"record": {
|
|
||||||
"avatar": "colorful_bird_sits_branch_forest_uiu589q5aa.jpg",
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"collectionName": "users",
|
|
||||||
"created": "2025-03-10 13:17:51.909Z",
|
|
||||||
"email": "neha@siliconpin.com",
|
|
||||||
"emailVisibility": true,
|
|
||||||
"id": "7fp08mzhs7qgmg9",
|
|
||||||
"name": "Neha Ghosh",
|
|
||||||
"phone": "9090935312",
|
|
||||||
"provider": "",
|
|
||||||
"type": "admin",
|
|
||||||
"updated": "2025-04-30 09:34:24.970Z",
|
|
||||||
"verified": true
|
|
||||||
},
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NDY2MTc3MTMsImlkIjoiN2ZwMDhtemhzN3FnbWc5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.Wig_LpSxOd03z389n186t2AwUu1Ypxmj3Apf5LdciaQ"
|
|
||||||
}
|
|
||||||
7534
package-lock.json
generated
7534
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview --port 4000",
|
"preview": "astro preview --port 4000",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"push-prod": "rsync -rv --exclude .hta_config/conf.php dist/ u2@siliconpin.com:~/web/siliconpin.com/public_html/"
|
"push-prod": "rsync -rv --exclude .hta_config/conf.php dist/ sp@siliconpin.com:~/web/siliconpin.com/public_html/",
|
||||||
|
"proxy": "http://localhost:8080"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/react": "^4.2.1",
|
"@astrojs/react": "^4.2.1",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
"@types/date-fns": "^2.5.3",
|
"@types/date-fns": "^2.5.3",
|
||||||
"@types/react": "^19.0.12",
|
"@types/react": "^19.0.12",
|
||||||
|
"@uiw/react-markdown-preview": "^5.1.4",
|
||||||
"@uiw/react-md-editor": "^3.25.6",
|
"@uiw/react-md-editor": "^3.25.6",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"astro": "^5.5.2",
|
"astro": "^5.5.2",
|
||||||
@@ -27,7 +29,10 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"emoji-picker-react": "^4.12.3",
|
||||||
|
"framer-motion": "^12.18.1",
|
||||||
"image-resize-compress": "^2.1.1",
|
"image-resize-compress": "^2.1.1",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.484.0",
|
"lucide-react": "^0.484.0",
|
||||||
"marked": "^15.0.8",
|
"marked": "^15.0.8",
|
||||||
"menubar": "^9.5.1",
|
"menubar": "^9.5.1",
|
||||||
@@ -41,6 +46,7 @@
|
|||||||
"react-simplemde-editor": "^5.2.0",
|
"react-simplemde-editor": "^5.2.0",
|
||||||
"react-to-print": "^3.0.5",
|
"react-to-print": "^3.0.5",
|
||||||
"rehype-rewrite": "^4.0.2",
|
"rehype-rewrite": "^4.0.2",
|
||||||
|
"serve": "^14.2.4",
|
||||||
"shadcn": "^2.5.0",
|
"shadcn": "^2.5.0",
|
||||||
"simplemde": "^1.11.2",
|
"simplemde": "^1.11.2",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
|
|||||||
1
public/assets/gitea.svg
Normal file
1
public/assets/gitea.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" aria-label="Gitea" role="img" viewBox="0 0 512 512" width="40px" height="40px" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <rect rx="15%" height="512" width="512" fill="#ffffff"></rect> <path d="M419 150c-98 7-186 2-276-1-27 0-63 19-61 67 3 75 71 82 99 83 3 14 35 62 59 65h104c63-5 109-213 75-214zm-311 67c-3-21 7-42 42-42 3 39 10 61 22 96-32-5-59-15-64-54z" fill="#592"></path> <path d="m293 152v70" stroke="#ffffff" stroke-width="9"></path> <g transform="rotate(25.7 496 -423)" stroke-width="7" fill="#592"> <path d="M561 246h97" stroke="#592"></path> <rect x="561" y="246" width="97" height="97" rx="16" fill="#ffffff"></rect> <path d="M592 245v75" stroke="#592"></path> <path d="M592 273c45 0 38-5 38 48" fill="none" stroke="#592"></path> <circle cx="592" cy="320" r="10"></circle> <circle cx="630" cy="320" r="10"></circle> <circle cx="592" cy="273" r="10"></circle> </g> </g></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
360
src/components/AddBalance.jsx
Normal file
360
src/components/AddBalance.jsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import React, {useState, useEffect} from "react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle} from "./ui/dialog";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||||
|
import { FileX, Copy, CheckCircle2, AlertCircle, X } from "lucide-react";
|
||||||
|
import Loader from "./ui/loader";
|
||||||
|
|
||||||
|
const PUBLIC_USER_API_URL = 'https://siliconpin.com/v1/balance/';
|
||||||
|
// const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
|
||||||
|
export default function AddBalance() {
|
||||||
|
const [orderData, setOrderData] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [showQRModal, setShowQRModal] = useState(false);
|
||||||
|
const [showHelpMessage, setShowHelpMessage] = useState(false);
|
||||||
|
const [upiPaymentLink, setUpiPaymentLink] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [orderId, setOrderId] = useState('');
|
||||||
|
const [transactionId, setTransactionId] = useState('');
|
||||||
|
const [paymentResponse, setPaymentResponse] = useState({
|
||||||
|
message: '',
|
||||||
|
visible: false,
|
||||||
|
isSuccess: false
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const orderId = urlParams.get('orderId');
|
||||||
|
|
||||||
|
if (!orderId) {
|
||||||
|
setError('Order ID is missing from URL');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderId(orderId);
|
||||||
|
fetchOrderData(orderId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchOrderData = async (orderId) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('order_id', orderId);
|
||||||
|
|
||||||
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=get-initiated-add-balance`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type' : 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({order_id:orderId}),
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || 'Failed to fetch payment details');
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderData(data);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Generate UPI payment link
|
||||||
|
if (data.payment_data?.amount) {
|
||||||
|
const upiLink = generateUPILink(data.payment_data.amount, data.txn_id);
|
||||||
|
setUpiPaymentLink(upiLink);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error.message || 'Failed to load payment details. Please try again.');
|
||||||
|
console.error('Error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateUPILink(amount, transactionId) {
|
||||||
|
const merchantUPI = "7001601485@okbizaxis";
|
||||||
|
const merchantName = "SiliconPin";
|
||||||
|
const currency = "INR";
|
||||||
|
const transactionNote = encodeURIComponent(`Balance top-up #${transactionId}`);
|
||||||
|
|
||||||
|
return `upi://pay?pa=${merchantUPI}&pn=${encodeURIComponent(merchantName)}&am=${amount}&cu=${currency}&tn=${transactionNote}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToPayU = () => {
|
||||||
|
if (!orderData?.payment_data || !orderData.payment_url) {
|
||||||
|
console.error('Payment data not loaded yet');
|
||||||
|
alert('Payment information is not ready. Please wait.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a form dynamically
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = orderData.payment_url;
|
||||||
|
form.style.display = 'none';
|
||||||
|
|
||||||
|
Object.entries(orderData.payment_data).forEach(([key, value]) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.name = key;
|
||||||
|
input.value = value;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayPalPayment = () => {
|
||||||
|
const rawInrAmount = orderData.payment_data?.amount || 0;
|
||||||
|
const exchangeRate = 80;
|
||||||
|
const convertedUSD = rawInrAmount / exchangeRate;
|
||||||
|
const usdAmount = Math.max(convertedUSD, 1).toFixed(2);
|
||||||
|
const paypalUrl = `https://www.paypal.com/paypalme/dwdconsultancy/${usdAmount}`;
|
||||||
|
window.open(paypalUrl, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQRPaymentClick = () => {
|
||||||
|
if(orderData.currency !== 'INR'){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!upiPaymentLink) {
|
||||||
|
alert('Payment information is not ready. Please wait.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowQRModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(orderId)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1000);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePayment = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=save-balance-payment`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
orderId: orderId,
|
||||||
|
transactionId: transactionId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setPaymentResponse({
|
||||||
|
message: `Payment of ₹${orderData?.payment_data?.amount} recorded. Your balance will be updated shortly.`,
|
||||||
|
visible: true,
|
||||||
|
isSuccess: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Payment verification failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
setPaymentResponse({
|
||||||
|
message: 'Failed to verify payment. Please contact support.',
|
||||||
|
visible: true,
|
||||||
|
isSuccess: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer;
|
||||||
|
if (showQRModal) {
|
||||||
|
setShowHelpMessage(false);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
setShowHelpMessage(true);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [showQRModal]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orderData) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center flex-grow py-10 text-center px-4 text-gray-600 mt-20">
|
||||||
|
<FileX className="mb-4 text-gray-500" size={100} />
|
||||||
|
<p className="text-lg">No payment request found. <a className="text-[#6d9e37] font-medium hover:underline" href="/profile">Click here</a> to go to your profile.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedBalance = new Intl.NumberFormat('en-IN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: orderData.currency, // fallback if undefined
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(orderData.payment_data?.amount || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen p-4">
|
||||||
|
<Card className="max-w-md w-full bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Balance to Your Account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{orderData && (
|
||||||
|
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="font-bold">Order ID:</span>
|
||||||
|
<span>{orderData.txn_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="font-bold">Amount:</span>
|
||||||
|
<span>{formattedBalance}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-medium">Pay Using:</span>
|
||||||
|
<hr className="border-b border-b-[#6d9e37] mb-2" />
|
||||||
|
|
||||||
|
<Button disabled={orderData.currency !== 'INR'} onClick={handleQRPaymentClick}>UPI QR Code</Button>
|
||||||
|
{
|
||||||
|
orderData.currency !== 'INR' && (
|
||||||
|
<p className="text-center text-xs text-gray-500">UPI QR Code Payment support only for INR currency</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<span className="text-center text-gray-400 text-xs">Banking Service by DWD Consultancy Services (0% Processing Fee)</span>
|
||||||
|
|
||||||
|
<div className="flex items-center my-2">
|
||||||
|
<div className="flex-grow border-t border-gray-500"></div>
|
||||||
|
<span className="mx-2 text-gray-500 font-semibold">OR</span>
|
||||||
|
<div className="flex-grow border-t border-gray-500"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={orderData.currency !== 'INR'}
|
||||||
|
className="text-[#414042] font-bold border-[2px] border-[#00ad7d] hover:bg-[#00ad7d]/80"
|
||||||
|
onClick={redirectToPayU}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Pay with
|
||||||
|
<img className="w-[50px]" src="/assets/payu-logo.svg" alt="PayU" />
|
||||||
|
</Button>
|
||||||
|
{
|
||||||
|
orderData.currency !== 'INR' && (
|
||||||
|
<p className="text-center text-xs text-gray-500">Pay with PayU support only for INR currency</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-2 text-[#414042] font-bold border-[2px] border-b-[#003087] border-r-[#003087] border-t-[#009CDE] border-l-[#009CDE] hover:bg-gradient-to-r hover:from-[#009CDE] hover:to-[#003087]"
|
||||||
|
onClick={handlePayPalPayment}
|
||||||
|
>
|
||||||
|
Pay with
|
||||||
|
<img className="w-[50px]" src="/assets/paypal-logo-white.svg" alt="PayPal" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* QR Code Modal */}
|
||||||
|
<Dialog open={showQRModal} onOpenChange={(open) => {setShowQRModal(open); if (!open) {setShowHelpMessage(false);}}}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Scan QR Code to Add Balance</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="p-4 bg-white rounded-lg border border-gray-200">
|
||||||
|
<QRCode value={upiPaymentLink} size={256} level="H" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 text-center">
|
||||||
|
Scan this QR code with any UPI app to complete your payment
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={handleCopy}>
|
||||||
|
{copied ? 'Copied!' : orderId}
|
||||||
|
{!copied && <Copy size={15} className="ml-2" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showHelpMessage && (
|
||||||
|
<div className="w-full mt-4 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||||
|
<p className="text-sm text-yellow-800 mb-3">
|
||||||
|
If you haven't received payment confirmation, please share your transaction ID with us.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={transactionId}
|
||||||
|
onChange={(e) => setTransactionId(e.target.value)}
|
||||||
|
placeholder="Enter UPI transaction ID"
|
||||||
|
className="w-full mb-2"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSavePayment} className="w-full">
|
||||||
|
Verify Payment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Payment Response Toast */}
|
||||||
|
{paymentResponse.visible && (
|
||||||
|
<div className="fixed bottom-4 left-0 right-0 flex justify-center">
|
||||||
|
<div className={`p-4 rounded-lg shadow-lg max-w-md w-full mx-4 ${
|
||||||
|
paymentResponse.isSuccess
|
||||||
|
? 'bg-green-50 border border-green-200 text-green-800'
|
||||||
|
: 'bg-red-50 border border-red-200 text-red-800'
|
||||||
|
}`}>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{paymentResponse.isSuccess ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 mr-2 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 mr-2 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span>{paymentResponse.message}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentResponse(prev => ({...prev, visible: false}))}
|
||||||
|
className="p-1 rounded-full hover:bg-opacity-20 hover:bg-current"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import { Label } from "./ui/label";
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
const pb = new PocketBase('https://tst-pb.s38.siliconpin.com');
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
const PUBLIC_POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL;
|
||||||
|
const pb = new PocketBase(PUBLIC_POCKETBASE_URL);
|
||||||
|
|
||||||
export function AvatarUpload({ userId }: { userId: string }) {
|
export function AvatarUpload({ userId }: { userId: string }) {
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
@@ -45,7 +46,7 @@ export function AvatarUpload({ userId }: { userId: string }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. Update PHP backend session
|
// 2. Update PHP backend session
|
||||||
const response = await fetch(`${INVOICE_API_URL}?query=login`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include', // Important for sessions to work
|
credentials: 'include', // Important for sessions to work
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from "./ui/button";
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
export default function UserBillingList() {
|
export default function UserBillingList() {
|
||||||
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||||
const [billingData, setBillingData] = useState([]);
|
const [billingData, setBillingData] = useState([]);
|
||||||
const [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
@@ -27,7 +28,6 @@ export default function UserBillingList() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage] = useState(20);
|
const [itemsPerPage] = useState(20);
|
||||||
|
|
||||||
const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
@@ -38,7 +38,7 @@ export default function UserBillingList() {
|
|||||||
|
|
||||||
const fetchBillingData = async () => {
|
const fetchBillingData = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${INVOICE_API_URL}?query=invoice-info`, {
|
const res = await fetch(`${PUBLIC_USER_API_URL}?query=invoice-info`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import { Button } from '../ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
|
||||||
export default function BuyVPN() {
|
export default function BuyVPN() {
|
||||||
// API URL - make sure to set this correctly
|
|
||||||
const USER_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
|
||||||
|
|
||||||
// State for VPN configuration
|
// State for VPN configuration
|
||||||
const [vpnContinent, setVpnContinent] = useState("");
|
const [vpnContinent, setVpnContinent] = useState("");
|
||||||
const [selectedCycle, setSelectedCycle] = useState("");
|
const [selectedCycle, setSelectedCycle] = useState("");
|
||||||
@@ -51,7 +49,7 @@ export default function BuyVPN() {
|
|||||||
formData.append('amount', selectedPrice.toString());
|
formData.append('amount', selectedPrice.toString());
|
||||||
formData.append('vpn_continent', vpnContinent);
|
formData.append('vpn_continent', vpnContinent);
|
||||||
formData.append('service_type', 'vpn');
|
formData.append('service_type', 'vpn');
|
||||||
const response = await fetch(`${USER_API_URL}?query=initiate_payment`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData, // Using FormData instead of JSON
|
body: formData, // Using FormData instead of JSON
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
|
|||||||
22
src/components/BuyServices/KebernetisPayload.jsx
Normal file
22
src/components/BuyServices/KebernetisPayload.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
let endpoint = 'https://api.utho.com/v2/kubernetes/deploy';
|
||||||
|
let kuberPayload = {
|
||||||
|
"dcslug": "inmumbaizone2",
|
||||||
|
"cluster_label": "MyK8S-flZuUYtk-nvn9h",
|
||||||
|
"cluster_version": "1.30.0-utho",
|
||||||
|
"nodepools": [
|
||||||
|
{
|
||||||
|
"label": "pool-GPlb5CRP",
|
||||||
|
"size": "10215",
|
||||||
|
"count": "2",
|
||||||
|
"ebs": [
|
||||||
|
{
|
||||||
|
"disk": "30",
|
||||||
|
"type": "nvme"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vpc": "81b2bd94-61dc-424b-a1ca-ca4c810ed4c4",
|
||||||
|
"network_type": "publicprivate",
|
||||||
|
"cpumodel": "amd"
|
||||||
|
}
|
||||||
470
src/components/BuyServices/Kubernetis.jsx
Normal file
470
src/components/BuyServices/Kubernetis.jsx
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import Loader from "../ui/loader";
|
||||||
|
import { useToast } from "../ui/toast";
|
||||||
|
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||||
|
|
||||||
|
export default function Kubernetes() {
|
||||||
|
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||||
|
const [deployError, setDeployError] = useState(null);
|
||||||
|
const [deployStatus, setDeployStatus] = useState({});
|
||||||
|
const [vpcs, setVpcs] = useState([]);
|
||||||
|
const [nodePools, setNodePools] = useState([{ label: '', size: '', count: 1, ebs: [{ disk: '', type: 'nvme' }] }]);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
dcslug: 'inmumbaizone2',
|
||||||
|
cluster_label: '',
|
||||||
|
cluster_version: '1.30.0-utho',
|
||||||
|
vpc: '',
|
||||||
|
network_type: 'publicprivate',
|
||||||
|
cpumodel: 'amd'
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableSizes = [
|
||||||
|
{ id: '10215', name: '2 vCPU, 4GB RAM' },
|
||||||
|
{ id: '10216', name: '4 vCPU, 8GB RAM' },
|
||||||
|
{ id: '10217', name: '8 vCPU, 16GB RAM' },
|
||||||
|
{ id: '10218', name: '16 vCPU, 32GB RAM' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableVersions = [
|
||||||
|
'1.30.0-utho',
|
||||||
|
'1.29.0-utho',
|
||||||
|
'1.28.0-utho'
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
// Simulate fetching VPCs - replace with actual API call
|
||||||
|
const mockVpcs = [
|
||||||
|
{ id: '81b2bd94-61dc-424b-a1ca-ca4c810ed4c4', name: 'Default VPC (Mumbai)' },
|
||||||
|
{ id: 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976', name: 'Default VPC (Bangalore)' }
|
||||||
|
];
|
||||||
|
setVpcs(mockVpcs);
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load configuration data",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsFetchingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
nodepools: nodePools.map(pool => ({
|
||||||
|
...pool,
|
||||||
|
count: String(pool.count), // Ensure count is string as per API
|
||||||
|
ebs: pool.ebs.map(disk => ({
|
||||||
|
...disk,
|
||||||
|
disk: String(disk.disk) // Ensure disk size is string
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("https://host-api.cs1.hz.siliconpin.com/v1/kubernetis/?query=deploy&source=chanel_1", {
|
||||||
|
credentials: "include",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === "success") {
|
||||||
|
setDeployStatus({
|
||||||
|
status: data.status,
|
||||||
|
clusterId: data.clusterId,
|
||||||
|
endpoint: data.endpoint
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || "Failed to deploy Kubernetes cluster");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setDeployError(error.message);
|
||||||
|
showToast({
|
||||||
|
title: "Deployment Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodePoolChange = (index, field, value) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools[index][field] = value;
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiskChange = (poolIndex, diskIndex, field, value) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools[poolIndex].ebs[diskIndex][field] = value;
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNodePool = () => {
|
||||||
|
setNodePools([...nodePools, { label: '', size: '', count: 1, ebs: [{ disk: '', type: 'nvme' }] }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNodePool = (index) => {
|
||||||
|
if (nodePools.length > 1) {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools.splice(index, 1);
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDisk = (poolIndex) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools[poolIndex].ebs.push({ disk: '', type: 'nvme' });
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDisk = (poolIndex, diskIndex) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
if (updatedPools[poolIndex].ebs.length > 1) {
|
||||||
|
updatedPools[poolIndex].ebs.splice(diskIndex, 1);
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || (isLoggedIn && isFetchingData)) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployError) return <p>Error: {deployError}</p>;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return <p className="text-center mt-8">
|
||||||
|
You are not Logged in. <a href="/login" className="text-[#6d9e37]">Click Here</a> to login first then you can add this service.
|
||||||
|
</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployStatus.status === 'success') {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Kubernetes Cluster Deployed Successfully</CardTitle>
|
||||||
|
<CardDescription>Your Kubernetes cluster is now being provisioned.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Cluster ID:</Label>
|
||||||
|
<p className="font-mono p-2 bg-gray-100 rounded">{deployStatus.clusterId}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>API Endpoint:</Label>
|
||||||
|
<p className="font-mono p-2 bg-gray-100 rounded">{deployStatus.endpoint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button onClick={() => window.location.href = '/services/kubernetes'}>
|
||||||
|
Back to Kubernetes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create New Kubernetes Cluster</CardTitle>
|
||||||
|
<CardDescription>Configure your Kubernetes cluster with the desired node pools and settings.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cluster_label">Cluster Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="cluster_label"
|
||||||
|
name="cluster_label"
|
||||||
|
value={formData.cluster_label}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="my-cluster"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dcslug">Data Center *</Label>
|
||||||
|
<Select
|
||||||
|
name="dcslug"
|
||||||
|
value={formData.dcslug}
|
||||||
|
onValueChange={(value) => setFormData({...formData, dcslug: value})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select location" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="inmumbaizone2">Mumbai</SelectItem>
|
||||||
|
<SelectItem value="inbangalore">Bangalore</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cluster_version">Kubernetes Version *</Label>
|
||||||
|
<Select
|
||||||
|
name="cluster_version"
|
||||||
|
value={formData.cluster_version}
|
||||||
|
onValueChange={(value) => setFormData({...formData, cluster_version: value})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableVersions.map(version => (
|
||||||
|
<SelectItem key={version} value={version}>
|
||||||
|
{version}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="vpc">VPC *</Label>
|
||||||
|
<Select
|
||||||
|
name="vpc"
|
||||||
|
value={formData.vpc}
|
||||||
|
onValueChange={(value) => setFormData({...formData, vpc: value})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select VPC" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vpcs.map(vpc => (
|
||||||
|
<SelectItem key={vpc.id} value={vpc.id}>
|
||||||
|
{vpc.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Network Type *</Label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="network_type"
|
||||||
|
value="publicprivate"
|
||||||
|
checked={formData.network_type === 'publicprivate'}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-[#6d9e37]"
|
||||||
|
/>
|
||||||
|
<span>Public + Private</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="network_type"
|
||||||
|
value="private"
|
||||||
|
checked={formData.network_type === 'private'}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-[#6d9e37]"
|
||||||
|
/>
|
||||||
|
<span>Private Only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CPU Model *</Label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cpumodel"
|
||||||
|
value="amd"
|
||||||
|
checked={formData.cpumodel === 'amd'}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-[#6d9e37]"
|
||||||
|
/>
|
||||||
|
<span>AMD</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cpumodel"
|
||||||
|
value="intel"
|
||||||
|
checked={formData.cpumodel === 'intel'}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-[#6d9e37]"
|
||||||
|
/>
|
||||||
|
<span>Intel</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-lg">Node Pools</Label>
|
||||||
|
<Button type="button" onClick={addNodePool} variant="outline">
|
||||||
|
Add Node Pool
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodePools.map((pool, poolIndex) => (
|
||||||
|
<div key={poolIndex} className="p-4 border rounded-lg space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h4 className="font-medium">Node Pool #{poolIndex + 1}</h4>
|
||||||
|
{nodePools.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeNodePool(poolIndex)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pool Label</Label>
|
||||||
|
<Input
|
||||||
|
value={pool.label}
|
||||||
|
onChange={(e) => handleNodePoolChange(poolIndex, 'label', e.target.value)}
|
||||||
|
placeholder="worker-pool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Node Size *</Label>
|
||||||
|
<Select
|
||||||
|
value={pool.size}
|
||||||
|
onValueChange={(value) => handleNodePoolChange(poolIndex, 'size', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select size" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableSizes.map(size => (
|
||||||
|
<SelectItem key={size.id} value={size.id}>
|
||||||
|
{size.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Node Count *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={pool.count}
|
||||||
|
onChange={(e) => handleNodePoolChange(poolIndex, 'count', parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-md">EBS Volumes</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addDisk(poolIndex)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Add Disk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pool.ebs.map((disk, diskIndex) => (
|
||||||
|
<div key={diskIndex} className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Disk Size (GB) *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={disk.disk}
|
||||||
|
onChange={(e) => handleDiskChange(poolIndex, diskIndex, 'disk', e.target.value)}
|
||||||
|
placeholder="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Disk Type *</Label>
|
||||||
|
<Select
|
||||||
|
value={disk.type}
|
||||||
|
onValueChange={(value) => handleDiskChange(poolIndex, diskIndex, 'type', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="nvme">NVMe SSD</SelectItem>
|
||||||
|
<SelectItem value="ssd">Standard SSD</SelectItem>
|
||||||
|
<SelectItem value="hdd">HDD</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{pool.ebs.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeDisk(poolIndex, diskIndex)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||||
|
{isLoading ? "Deploying Cluster..." : "Deploy Kubernetes Cluster"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Loader2 } from "lucide-react";
|
|||||||
import { useToast } from "../ui/toast";
|
import { useToast } from "../ui/toast";
|
||||||
|
|
||||||
export default function NewDroplet() {
|
export default function NewDroplet() {
|
||||||
|
const PUBLIC_DIGITALOCEAN_API_KEY = import.meta.env.PUBLIC_DIGITALOCEAN_API_KEY;
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -63,7 +64,7 @@ export default function NewDroplet() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": "Bearer dop_v1_b6a075ece5786faf7c58d21761dbf95d47af372da062d68a870ce2a0bae51adf"
|
"Authorization": `Bearer ${PUBLIC_DIGITALOCEAN_API_KEY}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...formData,
|
...formData,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function NewHetznerInstance() {
|
|||||||
const [images, setImages] = useState([]);
|
const [images, setImages] = useState([]);
|
||||||
const [sshKeys, setSshKeys] = useState([]);
|
const [sshKeys, setSshKeys] = useState([]);
|
||||||
const [showAddSshKey, setShowAddSshKey] = useState(false);
|
const [showAddSshKey, setShowAddSshKey] = useState(false);
|
||||||
const HETZNER_API_KEY = "uMSBR9nxdtbuvazsVM8YMMDd0PvuynpgJbmzFIO47HblMlh7tlHT8wV05sQ28Squ"; // Replace with your actual API key
|
const PUBLIC_HETZNER_API_KEY = import.meta.env.PUBLIC_HETZNER_API_KEY;
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -38,16 +38,16 @@ export default function NewHetznerInstance() {
|
|||||||
// Fetch all required data in parallel
|
// Fetch all required data in parallel
|
||||||
const [serverTypesResponse, locationsResponse, imagesResponse, sshKeysResponse] = await Promise.all([
|
const [serverTypesResponse, locationsResponse, imagesResponse, sshKeysResponse] = await Promise.all([
|
||||||
fetch("https://api.hetzner.cloud/v1/server_types", {
|
fetch("https://api.hetzner.cloud/v1/server_types", {
|
||||||
headers: { "Authorization": `Bearer ${HETZNER_API_KEY}` }
|
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||||
}),
|
}),
|
||||||
fetch("https://api.hetzner.cloud/v1/locations", {
|
fetch("https://api.hetzner.cloud/v1/locations", {
|
||||||
headers: { "Authorization": `Bearer ${HETZNER_API_KEY}` }
|
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||||
}),
|
}),
|
||||||
fetch("https://api.hetzner.cloud/v1/images", {
|
fetch("https://api.hetzner.cloud/v1/images", {
|
||||||
headers: { "Authorization": `Bearer ${HETZNER_API_KEY}` }
|
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||||
}),
|
}),
|
||||||
fetch("https://api.hetzner.cloud/v1/ssh_keys", {
|
fetch("https://api.hetzner.cloud/v1/ssh_keys", {
|
||||||
headers: { "Authorization": `Bearer ${HETZNER_API_KEY}` }
|
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ export default function NewHetznerInstance() {
|
|||||||
const response = await fetch("https://api.hetzner.cloud/v1/servers", {
|
const response = await fetch("https://api.hetzner.cloud/v1/servers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${HETZNER_API_KEY}`,
|
"Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}`,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -149,7 +149,7 @@ export default function NewHetznerInstance() {
|
|||||||
const response = await fetch('https://api.hetzner.cloud/v1/ssh_keys', {
|
const response = await fetch('https://api.hetzner.cloud/v1/ssh_keys', {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${HETZNER_API_KEY}`,
|
"Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}`,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -163,7 +163,7 @@ export default function NewHetznerInstance() {
|
|||||||
if (data.ssh_key) {
|
if (data.ssh_key) {
|
||||||
// Refresh SSH keys list
|
// Refresh SSH keys list
|
||||||
const sshResponse = await fetch("https://api.hetzner.cloud/v1/ssh_keys", {
|
const sshResponse = await fetch("https://api.hetzner.cloud/v1/ssh_keys", {
|
||||||
headers: { "Authorization": `Bearer ${HETZNER_API_KEY}` }
|
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||||
});
|
});
|
||||||
const sshData = await sshResponse.json();
|
const sshData = await sshResponse.json();
|
||||||
setSshKeys(sshData.ssh_keys || []);
|
setSshKeys(sshData.ssh_keys || []);
|
||||||
|
|||||||
487
src/components/BuyServices/NewKubernetisUtho copy.jsx
Normal file
487
src/components/BuyServices/NewKubernetisUtho copy.jsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import Loader from "../ui/loader";
|
||||||
|
import { useToast } from "../ui/toast";
|
||||||
|
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||||
|
|
||||||
|
export default function Kubernetes() {
|
||||||
|
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||||
|
const [deployError, setDeployError] = useState(null);
|
||||||
|
const [deployStatus, setDeployStatus] = useState({});
|
||||||
|
const [vpcs, setVpcs] = useState([]);
|
||||||
|
const [nodePools, setNodePools] = useState([{ label: `${getRandomString()}`, size: '10215', count: 1, ebs: [{ disk: 5, type: 'nvme' }] }]);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({ dcslug: 'inmumbaizone2', cluster_label: `${getRandomString()}`, cluster_version: '1.30.0-utho', network_type: 'publicprivate', });
|
||||||
|
function getRandomString( length = 10, { includeNumbers = true, includeLowercase = true, includeUppercase = true, } = {} ) {
|
||||||
|
let chars = '';
|
||||||
|
const numbers = '0123456789';
|
||||||
|
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
|
||||||
|
if (includeNumbers) chars += numbers;
|
||||||
|
if (includeLowercase) chars += lowercase;
|
||||||
|
if (includeUppercase) chars += uppercase;
|
||||||
|
|
||||||
|
if (!chars) {
|
||||||
|
throw new Error('At least one character type must be included');
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const availableSizes = [
|
||||||
|
{ id: '10215', name: '2 vCPU, 4GB RAM' },
|
||||||
|
{ id: '10216', name: '4 vCPU, 8GB RAM' },
|
||||||
|
{ id: '10217', name: '8 vCPU, 16GB RAM' },
|
||||||
|
{ id: '10218', name: '16 vCPU, 32GB RAM' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableVersions = [
|
||||||
|
'1.30.0-utho',
|
||||||
|
'1.29.0-utho',
|
||||||
|
'1.28.0-utho'
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
// Simulate fetching VPCs - replace with actual API call
|
||||||
|
const mockVpcs = [
|
||||||
|
{ id: '81b2bd94-61dc-424b-a1ca-ca4c810ed4c4', name: 'Default VPC (Mumbai)' },
|
||||||
|
{ id: 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976', name: 'Default VPC (Bangalore)' }
|
||||||
|
];
|
||||||
|
setVpcs(mockVpcs);
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load configuration data",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsFetchingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
nodepools: nodePools.map(pool => ({
|
||||||
|
...pool,
|
||||||
|
count: String(pool.count), // Ensure count is string as per API
|
||||||
|
ebs: pool.ebs.map(disk => ({
|
||||||
|
...disk,
|
||||||
|
disk: String(disk.disk) // Ensure disk size is string
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("https://host-api.cs1.hz.siliconpin.com/v1/kubernetis/?query=deploy&source=chanel_1", {
|
||||||
|
credentials: "include",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === "success") {
|
||||||
|
setDeployStatus({
|
||||||
|
status: data.status,
|
||||||
|
clusterId: data.clusterId,
|
||||||
|
endpoint: data.endpoint
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || "Failed to deploy Kubernetes cluster");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setDeployError(error.message);
|
||||||
|
showToast({
|
||||||
|
title: "Deployment Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodePoolChange = (index, field, value) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools[index][field] = value;
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiskChange = (poolIndex, diskIndex, field, value) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools[poolIndex].ebs[diskIndex][field] = value;
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNodePool = () => {
|
||||||
|
setNodePools([...nodePools, { label: '', size: '', count: 1, ebs: [{ disk: '', type: 'nvme' }] }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNodePool = (index) => {
|
||||||
|
if (nodePools.length > 1) {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools.splice(index, 1);
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDisk = (poolIndex) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools[poolIndex].ebs.push({ disk: '', type: 'nvme' });
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDisk = (poolIndex, diskIndex) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
if (updatedPools[poolIndex].ebs.length > 1) {
|
||||||
|
updatedPools[poolIndex].ebs.splice(diskIndex, 1);
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || (isLoggedIn && isFetchingData)) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployError) return <p>Error: {deployError}</p>;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return <p className="text-center mt-8">
|
||||||
|
You are not Logged in. <a href="/login" className="text-[#6d9e37]">Click Here</a> to login first then you can add this service.
|
||||||
|
</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployStatus.status === 'success') {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Kubernetes Cluster Deployed Successfully</CardTitle>
|
||||||
|
<CardDescription>Your Kubernetes cluster is now being provisioned.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Cluster ID:</Label>
|
||||||
|
<p className="font-mono p-2 bg-gray-100 rounded">{deployStatus.id}</p>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
{/* <div>
|
||||||
|
<Label>API Endpoint:</Label>
|
||||||
|
<p className="font-mono p-2 bg-gray-100 rounded">{deployStatus.endpoint}</p>
|
||||||
|
</div> */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button onClick={() => window.location.href = '/services/kubernetis'}>
|
||||||
|
Back to Kubernetes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create New Kubernetes Cluster</CardTitle>
|
||||||
|
<CardDescription>Configure your Kubernetes cluster with the desired node pools and settings.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cluster_label">Cluster Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="cluster_label"
|
||||||
|
name="cluster_label"
|
||||||
|
value={formData.cluster_label}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="my-cluster"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="space-y-2">
|
||||||
|
<Label htmlFor="dcslug">Data Center *</Label>
|
||||||
|
<Select
|
||||||
|
name="dcslug"
|
||||||
|
value={formData.dcslug}
|
||||||
|
onValueChange={(value) => setFormData({...formData, dcslug: value})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select location" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem selected value="inmumbaizone2">Mumbai</SelectItem>
|
||||||
|
<SelectItem value="inbangalore">Bangalore</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cluster_version">Kubernetes Version *</Label>
|
||||||
|
<Select
|
||||||
|
name="cluster_version"
|
||||||
|
value={formData.cluster_version}
|
||||||
|
onValueChange={(value) => setFormData({...formData, cluster_version: value})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableVersions.map(version => (
|
||||||
|
<SelectItem key={version} value={version}>
|
||||||
|
{version}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="space-y-2">
|
||||||
|
<Label htmlFor="vpc">VPC *</Label>
|
||||||
|
<Select
|
||||||
|
name="vpc"
|
||||||
|
value={formData.vpc}
|
||||||
|
onValueChange={(value) => setFormData({...formData, vpc: value})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select VPC" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vpcs.map(vpc => (
|
||||||
|
<SelectItem key={vpc.id} value={vpc.id}>
|
||||||
|
{vpc.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Network Type *</Label>
|
||||||
|
<div className="flex gap-4 border border-neutral-600 rounded-md p-2">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="network_type"
|
||||||
|
value="publicprivate"
|
||||||
|
checked={formData.network_type === 'publicprivate'}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-[#6d9e37] accent-[#6d9e37]"
|
||||||
|
/>
|
||||||
|
<span>Public + Private</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="network_type"
|
||||||
|
value="private"
|
||||||
|
checked={formData.network_type === 'private'}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-[#6d9e37] accent-[#6d9e37]"
|
||||||
|
/>
|
||||||
|
<span>Private Only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* <div className="space-y-2">
|
||||||
|
<Label>CPU Model *</Label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cpumodel"
|
||||||
|
value="amd"
|
||||||
|
checked={formData.cpumodel === 'amd'}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-[#6d9e37]"
|
||||||
|
/>
|
||||||
|
<span>AMD</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cpumodel"
|
||||||
|
value="intel"
|
||||||
|
checked={formData.cpumodel === 'intel'}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-[#6d9e37]"
|
||||||
|
/>
|
||||||
|
<span>Intel</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-lg">Node Pools</Label>
|
||||||
|
<Button type="button" onClick={addNodePool} variant="outline">
|
||||||
|
Add Node Pool
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodePools.map((pool, poolIndex) => (
|
||||||
|
<div key={poolIndex} className="p-4 border rounded-lg space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h4 className="font-medium">Node Pool #{poolIndex + 1}</h4>
|
||||||
|
{nodePools.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeNodePool(poolIndex)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pool Label</Label>
|
||||||
|
<Input
|
||||||
|
value={pool.label}
|
||||||
|
onChange={(e) => handleNodePoolChange(poolIndex, 'label', e.target.value)}
|
||||||
|
placeholder="worker-pool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Node Size *</Label>
|
||||||
|
<Select
|
||||||
|
value={pool.size}
|
||||||
|
onValueChange={(value) => handleNodePoolChange(poolIndex, 'size', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select size" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableSizes.map((size, index) => (
|
||||||
|
<SelectItem selected={index === 0} key={size.id} value={size.id}>
|
||||||
|
{size.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Node Count *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={pool.count}
|
||||||
|
onChange={(e) => handleNodePoolChange(poolIndex, 'count', parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-md">EBS Volumes</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addDisk(poolIndex)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Add Disk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pool.ebs.map((disk, diskIndex) => (
|
||||||
|
<div key={diskIndex} className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Disk Size (GB) *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={disk.disk}
|
||||||
|
onChange={(e) => handleDiskChange(poolIndex, diskIndex, 'disk', e.target.value)}
|
||||||
|
placeholder="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Disk Type *</Label>
|
||||||
|
<Select
|
||||||
|
value={disk.type}
|
||||||
|
onValueChange={(value) => handleDiskChange(poolIndex, diskIndex, 'type', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="nvme">NVMe SSD</SelectItem>
|
||||||
|
<SelectItem value="ssd">Standard SSD</SelectItem>
|
||||||
|
<SelectItem value="hdd">HDD</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{pool.ebs.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeDisk(poolIndex, diskIndex)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||||
|
{isLoading ? "Deploying Cluster..." : "Deploy Kubernetes Cluster"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,251 +1,241 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "../ui/label";
|
||||||
import Loader from "../ui/loader";
|
|
||||||
import { useToast } from "../ui/toast";
|
import { useToast } from "../ui/toast";
|
||||||
import { Switch } from "../ui/switch";
|
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||||
|
import Loader from "../ui/loader";
|
||||||
export default function NewKubernetesService() {
|
export default function Kubernetes() {
|
||||||
|
// Environment variables and hooks
|
||||||
|
const PUBLIC_DEPLOYMENT_CHANEL_1_URL = import.meta.env.PUBLIC_DEPLOYMENT_CHANEL_1_URL;
|
||||||
|
const { loading, isLoggedIn, balance } = useIsLoggedIn();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
// State management
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isFetchingData, setIsFetchingData] = useState(true);
|
const [deployError, setDeployError] = useState(null);
|
||||||
const [plans, setPlans] = useState([]);
|
const [deployStatus, setDeployStatus] = useState({});
|
||||||
const [vpcs, setVpcs] = useState([]);
|
const [productAmount] = useState(100);
|
||||||
const [subnets, setSubnets] = useState([]);
|
const [copied, setCopied] = useState(false);
|
||||||
const UTHO_API_KEY = "Bearer IoNXhkRJsQPyOEqFMceSfzuKaDLrpxUCATgZjiVdvYlBHbwWmGtn";
|
const [downloadClusterId, setDownloadClusterId] = useState('');
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [nodePools, setNodePools] = useState([{
|
||||||
|
label: `${getRandomString()}`,
|
||||||
|
size: '10215',
|
||||||
|
count: 1,
|
||||||
|
ebs: [{ disk: 30, type: 'nvme' }]
|
||||||
|
}]);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
dcslug: "innoida",
|
dcslug: 'inmumbaizone2',
|
||||||
cluster_version: "1.24",
|
cluster_label: `${getRandomString()}`,
|
||||||
cluster_label: "",
|
|
||||||
nodepools: [{
|
|
||||||
label: "default-pool",
|
|
||||||
size: "",
|
|
||||||
count: 1,
|
|
||||||
maxCount: 1
|
|
||||||
}],
|
|
||||||
firewall: "",
|
|
||||||
vpc: "",
|
|
||||||
subnet: "",
|
|
||||||
network_type: "public",
|
|
||||||
cpumodel: "intel"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// Helper functions
|
||||||
const fetchInitialData = async () => {
|
function getRandomString(length = 8) {
|
||||||
setIsFetchingData(true);
|
return Math.random().toString(36).substring(2, length+2);
|
||||||
try {
|
|
||||||
const [plansResponse, vpcsResponse] = await Promise.all([
|
|
||||||
fetch("https://api.utho.com/v2/plans", {
|
|
||||||
headers: {
|
|
||||||
"Authorization": UTHO_API_KEY,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
fetch("https://api.utho.com/v2/vpc", {
|
|
||||||
headers: {
|
|
||||||
"Authorization": UTHO_API_KEY,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
const plansData = await plansResponse.json();
|
|
||||||
const vpcsData = await vpcsResponse.json();
|
|
||||||
|
|
||||||
setPlans(plansData.plans || []);
|
|
||||||
setVpcs(vpcsData.vpc || []);
|
|
||||||
|
|
||||||
// Set default VPC if available
|
|
||||||
if (vpcsData.vpc?.length > 0) {
|
|
||||||
const firstVpc = vpcsData.vpc[0];
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
vpc: firstVpc.id
|
|
||||||
}));
|
|
||||||
// Fetch subnets for the default VPC
|
|
||||||
await fetchSubnets(firstVpc.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Initial data fetch error:", error);
|
|
||||||
showToast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load initial configuration data",
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsFetchingData(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchInitialData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchSubnets = async (vpcId) => {
|
|
||||||
try {
|
|
||||||
// First try to get subnets from the VPC data
|
|
||||||
const selectedVpc = vpcs.find(vpc => vpc.id === vpcId);
|
|
||||||
if (selectedVpc?.subnet?.length > 0) {
|
|
||||||
setSubnets(selectedVpc.subnet);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
subnet: selectedVpc.subnet[0].id
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no subnets in VPC data, try the subnets endpoint
|
const availableSizes = [
|
||||||
const response = await fetch(`https://api.utho.com/v2/vpc/${vpcId}/subnets`, {
|
{ id: '10215', name: '2 vCPU, 4GB RAM' },
|
||||||
headers: {
|
{ id: '10216', name: '4 vCPU, 8GB RAM' },
|
||||||
"Authorization": UTHO_API_KEY,
|
{ id: '10217', name: '8 vCPU, 16GB RAM' },
|
||||||
"Content-Type": "application/json"
|
{ id: '10218', name: '16 vCPU, 32GB RAM' }
|
||||||
}
|
];
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setSubnets(data.subnets || []);
|
|
||||||
if (data.subnets?.length > 0) {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
subnet: data.subnets[0].id
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching subnets:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVpcChange = async (vpcId) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
vpc: vpcId,
|
|
||||||
subnet: ""
|
|
||||||
}));
|
|
||||||
await fetchSubnets(vpcId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
|
if (balance < productAmount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setDeployError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate required fields including subnet
|
|
||||||
if (!formData.cluster_label || !formData.nodepools[0].size || !formData.vpc || !formData.subnet) {
|
|
||||||
throw new Error("Please fill all required fields including VPC and Subnet");
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
dcslug: formData.dcslug,
|
...formData,
|
||||||
cluster_version: formData.cluster_version,
|
cluster_version: '1.30.0-utho',
|
||||||
cluster_label: formData.cluster_label,
|
nodepools: nodePools.map(pool => ({
|
||||||
nodepools: formData.nodepools.map(pool => ({
|
...pool,
|
||||||
label: pool.label,
|
count: String(pool.count),
|
||||||
size: pool.size,
|
ebs: pool.ebs.map(disk => ({
|
||||||
count: pool.count.toString(),
|
...disk,
|
||||||
maxCount: pool.maxCount.toString()
|
disk: String(disk.disk)
|
||||||
|
}))
|
||||||
})),
|
})),
|
||||||
firewall: formData.firewall || undefined,
|
vpc: '81b2bd94-61dc-424b-a1ca-ca4c810ed4c4',
|
||||||
vpc: formData.vpc,
|
network_type: 'publicprivate',
|
||||||
subnet: formData.subnet, // Now required based on API behavior
|
cpumodel: 'amd'
|
||||||
network_type: formData.network_type,
|
|
||||||
cpumodel: formData.cpumodel
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Deployment payload:", payload);
|
const response = await fetch(
|
||||||
|
`${PUBLIC_DEPLOYMENT_CHANEL_1_URL}kubernetis/?query=deploy&source=chanel_1`,
|
||||||
const response = await fetch("https://api.utho.com/v2/kubernetes/deploy", {
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
credentials: "include",
|
||||||
"Authorization": UTHO_API_KEY,
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Deployment request failed");
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.message || "Failed to deploy Kubernetes cluster");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.status === "success") {
|
if (data.status === "success") {
|
||||||
showToast({
|
setDeployStatus({
|
||||||
title: "Success",
|
status: "success",
|
||||||
description: `Cluster ${data.cluster_id || data.id} is being deployed`,
|
clusterId: data.id
|
||||||
variant: "success"
|
});
|
||||||
|
showToast({
|
||||||
|
title: "Deployment Successful",
|
||||||
|
description: "Your cluster is being provisioned.",
|
||||||
|
variant: "default"
|
||||||
});
|
});
|
||||||
// Reset form
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
cluster_label: "",
|
|
||||||
nodepools: [{
|
|
||||||
label: "default-pool",
|
|
||||||
size: "",
|
|
||||||
count: 1,
|
|
||||||
maxCount: 1
|
|
||||||
}],
|
|
||||||
vpc: vpcs[0]?.id || "",
|
|
||||||
subnet: subnets[0]?.id || ""
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.message || "Failed to deploy Kubernetes cluster");
|
throw new Error(data.message || "Cluster deployment failed");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setDeployError(error.message);
|
||||||
showToast({
|
showToast({
|
||||||
title: "Deployment Failed",
|
title: "Deployment Failed",
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
console.error("Deployment error:", error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadKubeConfig = (clusterId) => {
|
||||||
|
if (!clusterId) {
|
||||||
|
showToast({
|
||||||
|
title: "Cluster ID Required",
|
||||||
|
description: "Please enter a valid Cluster ID",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(
|
||||||
|
`${PUBLIC_DEPLOYMENT_CHANEL_1_URL}kubernetis/?query=download&clusterId=${clusterId}&source=chanel_1`,
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (text) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Form field handlers
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value, type, checked } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
...prev,
|
|
||||||
[name]: type === "checkbox" ? checked : value
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNodePoolChange = (index, field, value) => {
|
const handleNodePoolChange = (index, field, value) => {
|
||||||
const updatedNodePools = [...formData.nodepools];
|
const updatedPools = [...nodePools];
|
||||||
updatedNodePools[index][field] = value;
|
updatedPools[index][field] = value;
|
||||||
setFormData(prev => ({
|
setNodePools(updatedPools);
|
||||||
...prev,
|
|
||||||
nodepools: updatedNodePools
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFetchingData) {
|
const handleDiskChange = (poolIndex, diskIndex, field, value) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools[poolIndex].ebs[diskIndex][field] = value;
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNodePool = () => {
|
||||||
|
setNodePools([...nodePools, {
|
||||||
|
label: `${getRandomString()}`,
|
||||||
|
size: '10215',
|
||||||
|
count: 1,
|
||||||
|
ebs: [{ disk: 30, type: 'nvme' }]
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNodePool = (index) => {
|
||||||
|
if (nodePools.length > 1) {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools.splice(index, 1);
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDisk = (poolIndex) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
updatedPools[poolIndex].ebs.push({ disk: 30, type: 'nvme' });
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDisk = (poolIndex, diskIndex) => {
|
||||||
|
const updatedPools = [...nodePools];
|
||||||
|
if (updatedPools[poolIndex].ebs.length > 1) {
|
||||||
|
updatedPools[poolIndex].ebs.splice(diskIndex, 1);
|
||||||
|
setNodePools(updatedPools);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (loading) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
if (!isLoggedIn) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<p className="text-center mt-8">
|
||||||
<Loader />
|
You must be logged in to deploy clusters. <a href="/login" className="text-[#6d9e37]">Login here</a>.
|
||||||
<span className="ml-2">Loading configuration...</span>
|
</p>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (balance < productAmount) {
|
||||||
|
return (
|
||||||
|
<p className="text-center mt-8">
|
||||||
|
You have insufficient balance to deploy this service. Please <a href="/profile" className="text-[#6d9e37]">click here</a> to add balance.
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
<div className="space-y-6 max-w-3xl mx-auto mt-8">
|
||||||
|
{/* Deployment Card */}
|
||||||
|
{deployStatus.status === 'success' ? (
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Deploy New Kubernetes Cluster</CardTitle>
|
<CardTitle>Kubernetes Cluster Deployment</CardTitle>
|
||||||
|
<CardDescription>Save your Cluster ID in a safe place to download configuration file</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex">
|
||||||
|
<Input
|
||||||
|
className="rounded-l-md rounded-r-none text-xl font-bold border-[2px] border-[#6d9e37]"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={deployStatus.clusterId}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="rounded-l-none rounded-r-md"
|
||||||
|
onClick={() => handleCopy(deployStatus.clusterId)}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Kubernetes Cluster</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure your Kubernetes cluster with the required parameters
|
Configure your Kubernetes cluster with the desired specifications
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Cluster Label */}
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cluster_label">Cluster Label *</Label>
|
<Label htmlFor="cluster_label">Cluster Name *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cluster_label"
|
id="cluster_label"
|
||||||
name="cluster_label"
|
name="cluster_label"
|
||||||
@@ -256,117 +246,58 @@ export default function NewKubernetesService() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data Center and Version */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="dcslug">Data Center *</Label>
|
|
||||||
<Select
|
|
||||||
name="dcslug"
|
|
||||||
value={formData.dcslug}
|
|
||||||
onValueChange={(value) => setFormData({...formData, dcslug: value})}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select location" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="innoida">Noida</SelectItem>
|
|
||||||
<SelectItem value="inmumbaizone2">Mumbai Zone 2</SelectItem>
|
|
||||||
<SelectItem value="indelhi">Delhi</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cluster_version">Kubernetes Version *</Label>
|
|
||||||
<Select
|
|
||||||
name="cluster_version"
|
|
||||||
value={formData.cluster_version}
|
|
||||||
onValueChange={(value) => setFormData({...formData, cluster_version: value})}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select version" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1.24">1.24</SelectItem>
|
|
||||||
<SelectItem value="1.25">1.25</SelectItem>
|
|
||||||
<SelectItem value="1.26">1.26</SelectItem>
|
|
||||||
<SelectItem value="1.27">1.27</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Network Type and CPU Model */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="network_type">Network Type *</Label>
|
|
||||||
<Select
|
|
||||||
name="network_type"
|
|
||||||
value={formData.network_type}
|
|
||||||
onValueChange={(value) => setFormData({...formData, network_type: value})}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select network type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="public">Public</SelectItem>
|
|
||||||
<SelectItem value="private">Private</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cpumodel">CPU Model *</Label>
|
|
||||||
<Select
|
|
||||||
name="cpumodel"
|
|
||||||
value={formData.cpumodel}
|
|
||||||
onValueChange={(value) => setFormData({...formData, cpumodel: value})}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select CPU model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="intel">Intel</SelectItem>
|
|
||||||
<SelectItem value="amd">AMD</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Node Pool Configuration */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium">Node Pool Configuration</h3>
|
<div className="flex justify-between items-center">
|
||||||
{formData.nodepools.map((pool, index) => (
|
<Label className="text-lg">Node Pools</Label>
|
||||||
<div key={index} className="p-4 border rounded-lg space-y-4">
|
<Button
|
||||||
<div className="space-y-2">
|
type="button"
|
||||||
<Label htmlFor={`pool-label-${index}`}>Pool Label *</Label>
|
onClick={addNodePool}
|
||||||
<Input
|
variant="outline"
|
||||||
id={`pool-label-${index}`}
|
size="sm"
|
||||||
value={pool.label}
|
>
|
||||||
onChange={(e) => handleNodePoolChange(index, 'label', e.target.value)}
|
Add Node Pool
|
||||||
required
|
</Button>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
{nodePools.map((pool, poolIndex) => (
|
||||||
|
<div key={poolIndex} className="p-4 border rounded-lg space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h4 className="font-medium">Node Pool #{poolIndex + 1}</h4>
|
||||||
|
{nodePools.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeNodePool(poolIndex)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`pool-size-${index}`}>Node Size *</Label>
|
<Label>Pool Label</Label>
|
||||||
|
<Input
|
||||||
|
value={pool.label}
|
||||||
|
onChange={(e) => handleNodePoolChange(poolIndex, 'label', e.target.value)}
|
||||||
|
placeholder="worker-pool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Node Size *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={pool.size}
|
value={pool.size}
|
||||||
onValueChange={(value) => handleNodePoolChange(index, 'size', value)}
|
onValueChange={(value) => handleNodePoolChange(poolIndex, 'size', value)}
|
||||||
required
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select node size" />
|
<SelectValue placeholder="Select size" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{plans.map(plan => (
|
{availableSizes.map(size => (
|
||||||
<SelectItem key={plan.id} value={plan.id}>
|
<SelectItem key={size.id} value={size.id}>
|
||||||
{`${plan.cpu} vCPU, ${Math.floor(parseInt(plan.ram)/1024)}GB RAM`}
|
{size.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -374,113 +305,117 @@ export default function NewKubernetesService() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`pool-count-${index}`}>Node Count *</Label>
|
<Label>Node Count *</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`pool-count-${index}`}
|
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={pool.count}
|
value={pool.count}
|
||||||
onChange={(e) => handleNodePoolChange(index, 'count', parseInt(e.target.value))}
|
onChange={(e) => handleNodePoolChange(poolIndex, 'count', parseInt(e.target.value) || 1)}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-md">Storage Volumes</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addDisk(poolIndex)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Add Volume
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pool.ebs.map((disk, diskIndex) => (
|
||||||
|
<div key={diskIndex} className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`pool-maxCount-${index}`}>Max Nodes</Label>
|
<Label>Size (GB) *</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`pool-maxCount-${index}`}
|
|
||||||
type="number"
|
type="number"
|
||||||
min={pool.count}
|
min="1"
|
||||||
value={pool.maxCount}
|
value={disk.disk}
|
||||||
onChange={(e) => handleNodePoolChange(index, 'maxCount', parseInt(e.target.value))}
|
onChange={(e) => handleDiskChange(poolIndex, diskIndex, 'disk', e.target.value)}
|
||||||
|
placeholder="30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VPC and Subnet */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="vpc">VPC *</Label>
|
<Label>Type *</Label>
|
||||||
<Select
|
<Select
|
||||||
name="vpc"
|
value={disk.type}
|
||||||
value={formData.vpc}
|
onValueChange={(value) => handleDiskChange(poolIndex, diskIndex, 'type', value)}
|
||||||
onValueChange={handleVpcChange}
|
|
||||||
required
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select VPC" />
|
<SelectValue placeholder="Select type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{vpcs.map(vpc => (
|
<SelectItem selected value="nvme">NVMe SSD</SelectItem>
|
||||||
<SelectItem key={vpc.id} value={vpc.id}>
|
|
||||||
{vpc.name || vpc.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex justify-end">
|
||||||
<Label htmlFor="subnet">Subnet *</Label>
|
{pool.ebs.length > 1 && (
|
||||||
<Select
|
<Button
|
||||||
name="subnet"
|
type="button"
|
||||||
value={formData.subnet}
|
variant="destructive"
|
||||||
onValueChange={(value) => setFormData({...formData, subnet: value})}
|
size="sm"
|
||||||
required
|
onClick={() => removeDisk(poolIndex, diskIndex)}
|
||||||
disabled={!formData.vpc || subnets.length === 0}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
Remove
|
||||||
<SelectValue placeholder={subnets.length === 0 ? "No subnets available" : "Select subnet"}>
|
</Button>
|
||||||
{formData.subnet || (subnets.length === 0 ? "No subnets available" : "Select subnet")}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{subnets.map(subnet => (
|
|
||||||
<SelectItem key={subnet.id} value={subnet.id}>
|
|
||||||
{subnet.name || subnet.id} ({subnet.network}/{subnet.size})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{subnets.length === 0 && formData.vpc && (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
No subnets found in this VPC. Please create a subnet first.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
{/* Firewall (Optional) */}
|
</div>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<Label htmlFor="firewall">Firewall ID (Optional)</Label>
|
))}
|
||||||
<Input
|
</div>
|
||||||
id="firewall"
|
|
||||||
name="firewall"
|
|
||||||
value={formData.firewall}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Firewall ID"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !formData.cluster_label || !formData.nodepools[0].size || !formData.vpc || !formData.subnet}
|
disabled={isLoading}
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? "Deploying..." : "Deploy Cluster"}
|
||||||
<>
|
|
||||||
<Loader className="mr-2 h-4 w-4" />
|
|
||||||
Deploying...
|
|
||||||
</>
|
|
||||||
) : "Deploy Kubernetes Cluster"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download Config Card - Always visible */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Download Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your Cluster ID to download the kubeconfig file <br /> <span className="text-xs">(Try to download config file after 5 minutes from Deployment!)</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="downloadClusterId">Cluster ID</Label>
|
||||||
|
<Input
|
||||||
|
id="downloadClusterId"
|
||||||
|
value={deployStatus.clusterId || downloadClusterId}
|
||||||
|
onChange={(e) => setDownloadClusterId(e.target.value)}
|
||||||
|
placeholder="Enter your Cluster ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => downloadKubeConfig(deployStatus.clusterId || downloadClusterId)}
|
||||||
|
>
|
||||||
|
Download Config
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,48 +9,295 @@ import { useToast } from "../ui/toast";
|
|||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from "../ui/textarea";
|
||||||
import { Switch } from "../ui/switch";
|
import { Switch } from "../ui/switch";
|
||||||
|
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||||
|
|
||||||
export default function NewCloudInstance() {
|
export default function NewCloudInstance() {
|
||||||
|
const PUBLIC_UTHO_API_KEY = import.meta.env.PUBLIC_UTHO_API_KEY;
|
||||||
|
const { isLoggedIn, loading, error, sessionData, balance } = useIsLoggedIn();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isFetchingData, setIsFetchingData] = useState(true);
|
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||||
const [plans, setPlans] = useState([]);
|
const [deployError, setDeployError] = useState()
|
||||||
const [sshKeys, setSshKeys] = useState([]);
|
const [deployStatus, setDeployStatus] = useState({});
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [productAmount, setProductAmount] = useState(100);
|
||||||
|
const [plans, setPlans] = useState([
|
||||||
|
{
|
||||||
|
"id": "10027",
|
||||||
|
"type": "cloud",
|
||||||
|
"slug": "dedicated-cpu",
|
||||||
|
"disk": "80",
|
||||||
|
"ram": "4096",
|
||||||
|
"cpu": "2",
|
||||||
|
"bandwidth": "1000",
|
||||||
|
"dedicated_vcore": "1",
|
||||||
|
"is_available": "YES",
|
||||||
|
"price": 26.83,
|
||||||
|
"price_currency": 26.83,
|
||||||
|
"price_cur": "$26.8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10028",
|
||||||
|
"type": "cloud",
|
||||||
|
"slug": "dedicated-cpu",
|
||||||
|
"disk": "160",
|
||||||
|
"ram": "8192",
|
||||||
|
"cpu": "4",
|
||||||
|
"bandwidth": "2000",
|
||||||
|
"dedicated_vcore": "1",
|
||||||
|
"is_available": "YES",
|
||||||
|
"price": 53.72,
|
||||||
|
"price_currency": 53.72,
|
||||||
|
"price_cur": "$53.7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10029",
|
||||||
|
"type": "cloud",
|
||||||
|
"slug": "dedicated-cpu",
|
||||||
|
"disk": "480",
|
||||||
|
"ram": "32768",
|
||||||
|
"cpu": "8",
|
||||||
|
"bandwidth": "3000",
|
||||||
|
"dedicated_vcore": "1",
|
||||||
|
"is_available": "YES",
|
||||||
|
"price": 151.91,
|
||||||
|
"price_currency": 151.91,
|
||||||
|
"price_cur": "$151.9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10030",
|
||||||
|
"type": "cloud",
|
||||||
|
"slug": "dedicated-cpu",
|
||||||
|
"disk": "960",
|
||||||
|
"ram": "65536",
|
||||||
|
"cpu": "16",
|
||||||
|
"bandwidth": "4000",
|
||||||
|
"dedicated_vcore": "1",
|
||||||
|
"is_available": "YES",
|
||||||
|
"price": 292.19,
|
||||||
|
"price_currency": 292.19,
|
||||||
|
"price_cur": "$292.2"
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// const [sshKeys, setSshKeys] = useState([]);
|
||||||
const [showAddSshKey, setShowAddSshKey] = useState(false);
|
const [showAddSshKey, setShowAddSshKey] = useState(false);
|
||||||
const UTHO_API_KEY = "Bearer IoNXhkRJsQPyOEqFMceSfzuKaDLrpxUCATgZjiVdvYlBHbwWmGtn";
|
const [vpcs, setVpcs] = useState();
|
||||||
|
const [images, setImages] = useState([
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Alma Linux",
|
||||||
|
"version": "9.2 x86_64",
|
||||||
|
"image": "almalinux-9.2-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Alma Linux",
|
||||||
|
"version": "8.4 x86_64",
|
||||||
|
"image": "AlmaLinux_8.4_x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Centos",
|
||||||
|
"version": "7.3 x86_64",
|
||||||
|
"image": "centos-7.3-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Centos",
|
||||||
|
"version": "8 x86_64",
|
||||||
|
"image": "centos-8-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Centos",
|
||||||
|
"version": "7.9 x86_64",
|
||||||
|
"image": "centos-7.9-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Centos",
|
||||||
|
"version": "Ameyo x86_64",
|
||||||
|
"image": "AmeyoCentos7",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "9.4 x86_64",
|
||||||
|
"image": "debian-9.4-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "9.13 x86_64",
|
||||||
|
"image": "debian-9.13-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "10 x86_64",
|
||||||
|
"image": "debian-10-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "11 x86_64",
|
||||||
|
"image": "debian-11-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "12 x86_64",
|
||||||
|
"image": "debian-12-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Fedora",
|
||||||
|
"version": "34 x86_64",
|
||||||
|
"image": "fedora-34-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Fedora",
|
||||||
|
"version": "32 x86_64",
|
||||||
|
"image": "fedora-32-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Fedora",
|
||||||
|
"version": "33 x86_64",
|
||||||
|
"image": "fedora-33-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "FortiOS",
|
||||||
|
"version": "7.6.2 ",
|
||||||
|
"image": "fortios-762",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Rocky Linux",
|
||||||
|
"version": "8.7 x86_64",
|
||||||
|
"image": "rocky-8.7-86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Rocky Linux",
|
||||||
|
"version": "8.8 x86_64",
|
||||||
|
"image": "rocky-8.8-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Rocky Linux",
|
||||||
|
"version": "9.1 x86_64",
|
||||||
|
"image": "rocky-9.1-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Rocky Linux",
|
||||||
|
"version": "9.2 x86_64",
|
||||||
|
"image": "rocky-9.2-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Ubuntu",
|
||||||
|
"version": "20.04 x86_64",
|
||||||
|
"image": "ubuntu-20.04-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Ubuntu",
|
||||||
|
"version": "18.04 x86_64",
|
||||||
|
"image": "ubuntu-18.10-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Ubuntu",
|
||||||
|
"version": "22.04 x86_64",
|
||||||
|
"image": "ubuntu-22.04-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Windows",
|
||||||
|
"version": "Server 2012R2 x86_64",
|
||||||
|
"image": "windows-server2012r2-x86_64",
|
||||||
|
"cost": 1200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Windows",
|
||||||
|
"version": "Server 2016 x86_64",
|
||||||
|
"image": "windows-2016",
|
||||||
|
"cost": 1200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Windows",
|
||||||
|
"version": "Server 2019 x86_64",
|
||||||
|
"image": "windows2019-10nov2020",
|
||||||
|
"cost": 1200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Windows",
|
||||||
|
"version": "Server 2022 x86_64",
|
||||||
|
"image": "windows-2022",
|
||||||
|
"cost": 1200
|
||||||
|
}
|
||||||
|
])
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
dcslug: "inmumbaizone2",
|
dcslug: '',
|
||||||
planid: "",
|
planid: '',
|
||||||
hostname: "",
|
billingcycle: "hourly",
|
||||||
image: "debian-12-x86_64",
|
auth: "option2",
|
||||||
auth: "ssh_key",
|
enable_publicip: '',
|
||||||
sshkeys: "",
|
subnetRequired: '',
|
||||||
password: "",
|
firewall: "23434645",
|
||||||
enable_publicip: true,
|
cpumodel: "intel",
|
||||||
enablebackup: false,
|
enablebackup: '',
|
||||||
vpc: "bcb68d3d-60f5-495f-9513-6b6a4e53a470",
|
root_password: '',
|
||||||
billingcycle: "hourly"
|
support: "unmanaged",
|
||||||
|
vpc: '',
|
||||||
|
cloud: [
|
||||||
|
{
|
||||||
|
hostname: ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
image: '',
|
||||||
|
sshkeys: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch plans and SSH keys in parallel
|
const vpcResponse = await fetch('https://api.utho.com/v2/vpc', {
|
||||||
const [plansResponse, sshResponse] = await Promise.all([
|
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||||
fetch("https://api.utho.com/v2/plans", {
|
});
|
||||||
headers: { "Authorization": UTHO_API_KEY }
|
|
||||||
}),
|
|
||||||
fetch("https://api.utho.com/v2/key", {
|
|
||||||
headers: { "Authorization": UTHO_API_KEY }
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
const plansData = await plansResponse.json();
|
const vpcsData = await vpcResponse.json();
|
||||||
const sshData = await sshResponse.json();
|
|
||||||
|
|
||||||
setPlans(plansData.plans || []);
|
setVpcs(vpcsData.vpc || []);
|
||||||
setSshKeys(sshData.key || []);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({
|
showToast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -65,7 +312,11 @@ export default function NewCloudInstance() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
|
if(balance < productAmount){
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -73,26 +324,37 @@ export default function NewCloudInstance() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
dcslug: formData.dcslug,
|
dcslug: formData.dcslug,
|
||||||
planid: formData.planid,
|
planid: formData.planid,
|
||||||
hostname: formData.hostname,
|
billingcycle: "hourly",
|
||||||
|
auth: "option2",
|
||||||
|
enable_publicip: formData.enable_publicip ? true : false,
|
||||||
|
subnetRequired: false,
|
||||||
|
firewall: "23434645",
|
||||||
|
cpumodel: "intel",
|
||||||
|
enablebackup: formData.enablebackup ? true : false,
|
||||||
|
root_password: formData.rootPassword,
|
||||||
|
support: "unmanaged",
|
||||||
|
vpc: formData.dcslug === 'inmumbaizone2' ? '68c36d65-bda8-43cc-94b5-ae28bb2c3306' : formData.dcslug === 'inbangalore' ? 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976' : '',
|
||||||
|
cloud: [
|
||||||
|
{
|
||||||
|
hostname: formData.hostname
|
||||||
|
}
|
||||||
|
],
|
||||||
image: formData.image,
|
image: formData.image,
|
||||||
enable_publicip: formData.enable_publicip ? "true" : "false",
|
sshkeys: formData.sshkeys
|
||||||
enablebackup: formData.enablebackup ? "true" : "false",
|
|
||||||
cloud: [{ hostname: formData.hostname }],
|
|
||||||
vpc: "bcb68d3d-60f5-495f-9513-6b6a4e53a470",
|
|
||||||
billingcycle: "hourly"
|
|
||||||
};
|
};
|
||||||
|
// inbangalore = c17032c9-3cfd-4028-8f2a-f3f5aa8c2976
|
||||||
|
// inmumbai = 68c36d65-bda8-43cc-94b5-ae28bb2c3306
|
||||||
// Add authentication based on selected method
|
// Add authentication based on selected method
|
||||||
if (formData.auth === "ssh_key") {
|
if (formData.auth === "ssh_key") {
|
||||||
payload.sshkeys = formData.sshkeys;
|
payload.sshkeys = formData.sshkeys;
|
||||||
} else {
|
} else {
|
||||||
payload.password = formData.password;
|
payload.password = formData.password;
|
||||||
}
|
}
|
||||||
|
console.log('payload', payload)
|
||||||
const response = await fetch("https://api.utho.com/v2/cloud/deploy", {
|
const response = await fetch("https://host-api.cs1.hz.siliconpin.com/v1/vps/?query=deploy&source=chanel_1", {
|
||||||
|
credentials: "include",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": UTHO_API_KEY,
|
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -101,11 +363,12 @@ export default function NewCloudInstance() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === "success") {
|
if (data.status === "success") {
|
||||||
showToast({
|
setDeployStatus({status : data.status, ip : data.ipv4})
|
||||||
title: "Deployment Started",
|
// showToast({
|
||||||
description: `Server ${data.cloudid} is being deployed with IP ${data.ipv4}`,
|
// title: "Deployment Started",
|
||||||
variant: "success"
|
// description: `Server ${data.cloudid} is being deployed with IP ${data.ipv4}`,
|
||||||
});
|
// variant: "success"
|
||||||
|
// });
|
||||||
// Reset form after successful deployment
|
// Reset form after successful deployment
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -116,11 +379,7 @@ export default function NewCloudInstance() {
|
|||||||
throw new Error(data.message || "Failed to deploy instance");
|
throw new Error(data.message || "Failed to deploy instance");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({
|
setDeployError(error.message)
|
||||||
title: "Deployment Failed",
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -139,7 +398,7 @@ export default function NewCloudInstance() {
|
|||||||
const response = await fetch('https://api.utho.com/v2/key/import', {
|
const response = await fetch('https://api.utho.com/v2/key/import', {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": UTHO_API_KEY,
|
"Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}`,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -153,7 +412,7 @@ export default function NewCloudInstance() {
|
|||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
// Refresh SSH keys list
|
// Refresh SSH keys list
|
||||||
const sshResponse = await fetch("https://api.utho.com/v2/key", {
|
const sshResponse = await fetch("https://api.utho.com/v2/key", {
|
||||||
headers: { "Authorization": UTHO_API_KEY }
|
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||||
});
|
});
|
||||||
const sshData = await sshResponse.json();
|
const sshData = await sshResponse.json();
|
||||||
setSshKeys(sshData.key || []);
|
setSshKeys(sshData.key || []);
|
||||||
@@ -175,17 +434,67 @@ export default function NewCloudInstance() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFetchingData) {
|
const handleCopy = (text) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1000); // revert after 1 second
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (loading || (isLoggedIn && isFetchingData)) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (balance < productAmount) {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
You have insufficient balance to deploy this service. Please <a href="/profile" className="text-[#6d9e37]"> click here </a> to go to your profile and add balance, then try again.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployError) return <p>Error: {deployError?.message}</p>;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return <p className="text-center mt-8">
|
||||||
|
You are not Logged in. <a href="/login" className="text-[#6d9e37]">Click Here</a> to login first then you can add this service.
|
||||||
|
</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if(deployStatus.status === 'success'){
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create New Vertual Private Server (VPS)</CardTitle>
|
||||||
|
<CardDescription>Provide the root password then you can access vps using the password. <br /> later on you can setup ssh key</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>Your New VPS</p>
|
||||||
|
<div className="flex flex-col my-2">
|
||||||
|
<Label>IP Address:</Label>
|
||||||
|
<div className="flex">
|
||||||
|
<Input className="rounded-l-md rounded-r-none text-xl font-bold border-[2px] border-[#6d9e37]" type="text" readOnly value={deployStatus.ip} />
|
||||||
|
<Button className="rounded-l-none rounded-r-md" onClick={() => handleCopy(deployStatus.ip)}>{copied ? 'Copied!' : 'Copy' }</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center place-items-center">
|
||||||
|
<Button className="" onClick={() => window.location.href = '/services/cloud-instance'}>Back</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Deploy New Cloud Instance</CardTitle>
|
<CardTitle>Create New Vertual Private Server (VPS)</CardTitle>
|
||||||
<CardDescription>Configure your cloud server with essential parameters</CardDescription>
|
<CardDescription>Provide the root password then you can access vps using the password. <br /> later on you can setup ssh key</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="hostname">Hostname *</Label>
|
<Label htmlFor="hostname">Hostname *</Label>
|
||||||
@@ -211,9 +520,8 @@ export default function NewCloudInstance() {
|
|||||||
<SelectValue placeholder="Select location" />
|
<SelectValue placeholder="Select location" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="inmumbaizone2">Mumbai Zone 2</SelectItem>
|
<SelectItem value="inmumbaizone2">Mumbai</SelectItem>
|
||||||
<SelectItem value="innoida">Noida</SelectItem>
|
<SelectItem value="inbangalore">Bangalore</SelectItem>
|
||||||
<SelectItem value="indelhi">Delhi</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,38 +538,53 @@ export default function NewCloudInstance() {
|
|||||||
<SelectValue placeholder="Select a plan" />
|
<SelectValue placeholder="Select a plan" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* {plans.map(plan => (
|
{plans.map(plan => (
|
||||||
<SelectItem key={plan.id} value={plan.id}>
|
<SelectItem key={plan.id} value={plan.id}>
|
||||||
{`${plan.cpu} vCPU, ${plan.ram}MB RAM - ${plan.price_cur}/mo`}
|
{`${plan.cpu} vCPU, ${plan.ram}MB RAM`} {/* {`${plan.cpu} vCPU, ${plan.ram}MB RAM - ${plan.price_cur}/mo`} */}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))} */}
|
))}
|
||||||
<SelectItem key={plans[0].id} value={plans[0].id}>
|
{/* <SelectItem key={plans[0].id} value={plans[0].id}>
|
||||||
{`${plans[0].cpu} vCPU, ${plans[0].ram}MB RAM - ${plans[0].price_cur}/mo`}
|
{`${plans[0].cpu} vCPU, ${plans[0].ram}MB RAM - ${plans[0].price_cur}/mo`}
|
||||||
</SelectItem>
|
</SelectItem> */}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="image">Operating System *</Label>
|
<Label htmlFor="image">Operating System *</Label>
|
||||||
<Select
|
<Select
|
||||||
name="image"
|
name="image"
|
||||||
value={formData.image}
|
value={formData.image}
|
||||||
onValueChange={(value) => setFormData({...formData, image: value})}
|
onValueChange={(value) => setFormData({...formData, image: value})}>
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select OS" />
|
<SelectValue placeholder="Select OS" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="debian-12-x86_64">Debian 12</SelectItem>
|
{
|
||||||
<SelectItem value="ubuntu-22.04">Ubuntu 22.04</SelectItem>
|
images.map((image, i) => (
|
||||||
<SelectItem value="centos-7.4-x86_64">CentOS 7.4</SelectItem>
|
<SelectItem key={i} value={image.image}>{image.image.split('-x86_64')[0]}</SelectItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Root Password *</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Enter root password"
|
||||||
|
required={formData.auth === "password"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium">Authentication</h3>
|
<h3 className="text-lg font-medium">Authentication</h3>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={formData.auth}
|
value={formData.auth}
|
||||||
@@ -349,7 +672,7 @@ export default function NewCloudInstance() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium">Options</h3>
|
<h3 className="text-lg font-medium">Options</h3>
|
||||||
|
|||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import Loader from "../ui/loader";
|
||||||
|
import { useToast } from "../ui/toast";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import { Switch } from "../ui/switch";
|
||||||
|
|
||||||
|
export default function NewCloudInstance() {
|
||||||
|
const PUBLIC_UTHO_API_KEY = import.meta.env.PUBLIC_UTHO_API_KEY;
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||||
|
const [plans, setPlans] = useState([]);
|
||||||
|
const [sshKeys, setSshKeys] = useState([]);
|
||||||
|
const [showAddSshKey, setShowAddSshKey] = useState(false);
|
||||||
|
const [vpcs, setVpcs] = useState();
|
||||||
|
const [images, setImages] = useState([
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Alma Linux",
|
||||||
|
"version": "9.2 x86_64",
|
||||||
|
"image": "almalinux-9.2-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Alma Linux",
|
||||||
|
"version": "8.4 x86_64",
|
||||||
|
"image": "AlmaLinux_8.4_x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Centos",
|
||||||
|
"version": "7.3 x86_64",
|
||||||
|
"image": "centos-7.3-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Centos",
|
||||||
|
"version": "8 x86_64",
|
||||||
|
"image": "centos-8-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Centos",
|
||||||
|
"version": "7.9 x86_64",
|
||||||
|
"image": "centos-7.9-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Centos",
|
||||||
|
"version": "Ameyo x86_64",
|
||||||
|
"image": "AmeyoCentos7",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "9.4 x86_64",
|
||||||
|
"image": "debian-9.4-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "9.13 x86_64",
|
||||||
|
"image": "debian-9.13-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "10 x86_64",
|
||||||
|
"image": "debian-10-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "11 x86_64",
|
||||||
|
"image": "debian-11-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Debian",
|
||||||
|
"version": "12 x86_64",
|
||||||
|
"image": "debian-12-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Fedora",
|
||||||
|
"version": "34 x86_64",
|
||||||
|
"image": "fedora-34-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Fedora",
|
||||||
|
"version": "32 x86_64",
|
||||||
|
"image": "fedora-32-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Fedora",
|
||||||
|
"version": "33 x86_64",
|
||||||
|
"image": "fedora-33-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "FortiOS",
|
||||||
|
"version": "7.6.2 ",
|
||||||
|
"image": "fortios-762",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Rocky Linux",
|
||||||
|
"version": "8.7 x86_64",
|
||||||
|
"image": "rocky-8.7-86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Rocky Linux",
|
||||||
|
"version": "8.8 x86_64",
|
||||||
|
"image": "rocky-8.8-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Rocky Linux",
|
||||||
|
"version": "9.1 x86_64",
|
||||||
|
"image": "rocky-9.1-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Rocky Linux",
|
||||||
|
"version": "9.2 x86_64",
|
||||||
|
"image": "rocky-9.2-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Ubuntu",
|
||||||
|
"version": "20.04 x86_64",
|
||||||
|
"image": "ubuntu-20.04-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Ubuntu",
|
||||||
|
"version": "18.04 x86_64",
|
||||||
|
"image": "ubuntu-18.10-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Ubuntu",
|
||||||
|
"version": "22.04 x86_64",
|
||||||
|
"image": "ubuntu-22.04-x86_64",
|
||||||
|
"cost": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Windows",
|
||||||
|
"version": "Server 2012R2 x86_64",
|
||||||
|
"image": "windows-server2012r2-x86_64",
|
||||||
|
"cost": 1200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Windows",
|
||||||
|
"version": "Server 2016 x86_64",
|
||||||
|
"image": "windows-2016",
|
||||||
|
"cost": 1200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Windows",
|
||||||
|
"version": "Server 2019 x86_64",
|
||||||
|
"image": "windows2019-10nov2020",
|
||||||
|
"cost": 1200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distro": null,
|
||||||
|
"distribution": "Windows",
|
||||||
|
"version": "Server 2022 x86_64",
|
||||||
|
"image": "windows-2022",
|
||||||
|
"cost": 1200
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
dcslug: '',
|
||||||
|
planid: '',
|
||||||
|
billingcycle: "hourly",
|
||||||
|
auth: "option2",
|
||||||
|
enable_publicip: '',
|
||||||
|
subnetRequired: '',
|
||||||
|
firewall: "23434645",
|
||||||
|
cpumodel: "intel",
|
||||||
|
enablebackup: '',
|
||||||
|
root_password: '',
|
||||||
|
support: "unmanaged",
|
||||||
|
vpc: '',
|
||||||
|
cloud: [
|
||||||
|
{
|
||||||
|
hostname: ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
image: '',
|
||||||
|
sshkeys: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch plans and SSH keys in parallel
|
||||||
|
const [plansResponse, sshResponse, vpcLists] = await Promise.all([
|
||||||
|
fetch("https://api.utho.com/v2/plans", {
|
||||||
|
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||||
|
}),
|
||||||
|
fetch("https://api.utho.com/v2/key", {
|
||||||
|
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||||
|
}),
|
||||||
|
fetch('https://api.utho.com/v2/vpc', {
|
||||||
|
headers: {"Authorization" : `Bearer ${PUBLIC_UTHO_API_KEY}`}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const plansData = await plansResponse.json();
|
||||||
|
const sshData = await sshResponse.json();
|
||||||
|
const vpcsData = await vpcLists.json();
|
||||||
|
|
||||||
|
setPlans(plansData.plans || []);
|
||||||
|
setSshKeys(sshData.key || []);
|
||||||
|
setVpcs(vpcsData.vpc || []);
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load configuration data",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsFetchingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
dcslug: formData.dcslug,
|
||||||
|
planid: formData.planid,
|
||||||
|
billingcycle: "hourly",
|
||||||
|
auth: "option2",
|
||||||
|
enable_publicip: formData.enable_publicip ? true : false,
|
||||||
|
subnetRequired: false,
|
||||||
|
firewall: "23434645",
|
||||||
|
cpumodel: "intel",
|
||||||
|
enablebackup: formData.enablebackup ? true : false,
|
||||||
|
root_password: formData.rootPassword,
|
||||||
|
support: "unmanaged",
|
||||||
|
vpc: 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976',
|
||||||
|
cloud: [
|
||||||
|
{
|
||||||
|
hostname: formData.hostname
|
||||||
|
}
|
||||||
|
],
|
||||||
|
image: formData.image,
|
||||||
|
sshkeys: formData.sshkeys
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add authentication based on selected method
|
||||||
|
if (formData.auth === "ssh_key") {
|
||||||
|
payload.sshkeys = formData.sshkeys;
|
||||||
|
} else {
|
||||||
|
payload.password = formData.password;
|
||||||
|
}
|
||||||
|
console.log('payload', payload)
|
||||||
|
const response = await fetch("https://host-api.cs1.hz.siliconpin.com/v1/vps/?query=deploy&source=chanel_1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === "success") {
|
||||||
|
showToast({
|
||||||
|
title: "Deployment Started",
|
||||||
|
description: `Server ${data.cloudid} is being deployed with IP ${data.ipv4}`,
|
||||||
|
variant: "success"
|
||||||
|
});
|
||||||
|
// Reset form after successful deployment
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
hostname: "",
|
||||||
|
planid: ""
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || "Failed to deploy instance");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
title: "Deployment Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === "checkbox" ? checked : value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSshKey = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.utho.com/v2/key/import', {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.newSshKeyName,
|
||||||
|
sshkey: formData.newSshKeyValue
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
// Refresh SSH keys list
|
||||||
|
const sshResponse = await fetch("https://api.utho.com/v2/key", {
|
||||||
|
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||||
|
});
|
||||||
|
const sshData = await sshResponse.json();
|
||||||
|
setSshKeys(sshData.key || []);
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
title: "SSH Key Added",
|
||||||
|
description: "Your SSH key has been successfully added",
|
||||||
|
variant: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowAddSshKey(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to add SSH key",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFetchingData) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Deploy New Cloud Instance</CardTitle>
|
||||||
|
<CardDescription>Configure your cloud server with essential parameters</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="hostname">Hostname *</Label>
|
||||||
|
<Input
|
||||||
|
id="hostname"
|
||||||
|
name="hostname"
|
||||||
|
value={formData.hostname}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="server1.example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dcslug">Data Center *</Label>
|
||||||
|
<Select
|
||||||
|
name="dcslug"
|
||||||
|
value={formData.dcslug}
|
||||||
|
onValueChange={(value) => setFormData({...formData, dcslug: value})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select location" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="inmumbaizone2">Mumbai</SelectItem>
|
||||||
|
<SelectItem value="inbangalore">Bangalore</SelectItem>
|
||||||
|
<SelectItem value="innoida">Delhi (Noida)</SelectItem>
|
||||||
|
<SelectItem value="defra1">Germany</SelectItem>
|
||||||
|
<SelectItem value="uslosangeles">Los Angeles</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="planid">Plan *</Label>
|
||||||
|
<Select
|
||||||
|
name="planid"
|
||||||
|
value={formData.planid}
|
||||||
|
onValueChange={(value) => setFormData({...formData, planid: value})}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a plan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{plans.map(plan => (
|
||||||
|
<SelectItem key={plan.id} value={plan.id}>
|
||||||
|
{`${plan.cpu} vCPU, ${plan.ram}MB RAM - ${plan.price_cur}/mo`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{/* <SelectItem key={plans[0].id} value={plans[0].id}>
|
||||||
|
{`${plans[0].cpu} vCPU, ${plans[0].ram}MB RAM - ${plans[0].price_cur}/mo`}
|
||||||
|
</SelectItem> */}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="image">Operating System *</Label>
|
||||||
|
<Select
|
||||||
|
name="image"
|
||||||
|
value={formData.image}
|
||||||
|
onValueChange={(value) => setFormData({...formData, image: value})}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select OS" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{
|
||||||
|
images.map((image, i) => (
|
||||||
|
<SelectItem key={i} value={image.image}>{image.image.split('-x86_64')[0]}</SelectItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="vpc">VPC *</Label>
|
||||||
|
<Select
|
||||||
|
name="vpc"
|
||||||
|
value={formData.vpc}
|
||||||
|
onValueChange={(value) => setFormData({...formData, vpc: value})}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select VPC" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{
|
||||||
|
vpcs.map((vpc, n) => (
|
||||||
|
<SelectItem key={n} value={`${vpc.id}`}>{vpc.dclocation.location}</SelectItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">Authentication</h3>
|
||||||
|
<Tabs
|
||||||
|
value={formData.auth}
|
||||||
|
onValueChange={(value) => setFormData({...formData, auth: value})}
|
||||||
|
className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 gap-4">
|
||||||
|
<TabsTrigger className={`p-2 rounded-md ${formData.auth === 'ssh_key' ? 'bg-[#6d9e37] text-[#FFF]' : 'border border-[#6d9e37]'}`} value="ssh_key">SSH Key</TabsTrigger>
|
||||||
|
<TabsTrigger className={`p-2 rounded-md ${formData.auth === 'password' ? 'bg-[#6d9e37] text-[#FFF]' : 'border border-[#6d9e37]'}`} value="password">Password</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="ssh_key" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Select SSH Key</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
value={formData.sshkeys}
|
||||||
|
onValueChange={(value) => setFormData({...formData, sshkeys: value})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select SSH key" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sshKeys.map(key => (
|
||||||
|
<SelectItem key={key.id} value={key.id}>{key.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
onClick={() => setShowAddSshKey(!showAddSshKey)}
|
||||||
|
>
|
||||||
|
{showAddSshKey ? "Cancel" : "Add New"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddSshKey && (
|
||||||
|
<div className="space-y-4 p-4 border rounded-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newSshKeyName">Key Name</Label>
|
||||||
|
<Input
|
||||||
|
id="newSshKeyName"
|
||||||
|
name="newSshKeyName"
|
||||||
|
value={formData.newSshKeyName}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="My Laptop Key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newSshKeyValue">Public Key</Label>
|
||||||
|
<Textarea
|
||||||
|
id="newSshKeyValue"
|
||||||
|
name="newSshKeyValue"
|
||||||
|
value={formData.newSshKeyValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddSshKey}
|
||||||
|
disabled={!formData.newSshKeyName || !formData.newSshKeyValue}
|
||||||
|
>
|
||||||
|
Add SSH Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="password" className="space-y-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Root Password *</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Enter root password"
|
||||||
|
required={formData.auth === "password"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">Options</h3>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="enable_publicip"
|
||||||
|
checked={formData.enable_publicip}
|
||||||
|
onCheckedChange={(checked) => setFormData({...formData, enable_publicip: checked})}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enable_publicip">Enable Public IP</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="enablebackup"
|
||||||
|
checked={formData.enablebackup}
|
||||||
|
onCheckedChange={(checked) => setFormData({...formData, enablebackup: checked})}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enablebackup">Enable Weekly Backups (+20%)</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||||
|
{isLoading ? "Deploying..." : "Deploy Instance"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ const PRICE_CONFIG = [
|
|||||||
{purchaseType: 'bulk', price: 1000, minute: 10000}
|
{purchaseType: 'bulk', price: 1000, minute: 10000}
|
||||||
];
|
];
|
||||||
export default function STTStreaming(){
|
export default function STTStreaming(){
|
||||||
const USER_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
const [purchaseType, setPurchaseType] = useState();
|
const [purchaseType, setPurchaseType] = useState();
|
||||||
const [amount, setAmount] = useState();
|
const [amount, setAmount] = useState();
|
||||||
const [dataError, setDataError] = useState();
|
const [dataError, setDataError] = useState();
|
||||||
@@ -38,7 +38,7 @@ export default function STTStreaming(){
|
|||||||
formData.append('cycle', 'monthly');
|
formData.append('cycle', 'monthly');
|
||||||
formData.append('amount', amount.toString());
|
formData.append('amount', amount.toString());
|
||||||
formData.append('service_type', 'streaming_api');
|
formData.append('service_type', 'streaming_api');
|
||||||
const response = await fetch(`${USER_API_URL}?query=initiate_payment`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData, // Using FormData instead of JSON
|
body: formData, // Using FormData instead of JSON
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
|
|||||||
43
src/components/BuyServices/uthoPayload.jsx
Normal file
43
src/components/BuyServices/uthoPayload.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const payload = {
|
||||||
|
"dcslug": "inbangalore",
|
||||||
|
"planid": "10045",
|
||||||
|
"billingcycle": "hourly",
|
||||||
|
"auth": "option2",
|
||||||
|
"enable_publicip": "true",
|
||||||
|
"subnetRequired": "false",
|
||||||
|
"firewall": "23434645",
|
||||||
|
"cpumodel": "intel",
|
||||||
|
"enablebackup": "false",
|
||||||
|
"root_password": "5qT381g@K@pDsq",
|
||||||
|
"support": "unmanaged",
|
||||||
|
"vpc": "c17032c9-3cfd-4028-8f2a-f3f5aa8c2976",
|
||||||
|
"cloud": [
|
||||||
|
{
|
||||||
|
"hostname": "cloudserver-ep0yNFPI.mhc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"image": "debian-9.4-x86_64",
|
||||||
|
"sshkeys": "74131237"
|
||||||
|
};
|
||||||
|
|
||||||
|
// const payload = {
|
||||||
|
// dcslug: formData.dcslug,
|
||||||
|
// planid: formData.planid,
|
||||||
|
// billingcycle: "hourly",
|
||||||
|
// auth: "option2",
|
||||||
|
// enable_publicip: formData.enable_publicip ? true : false,
|
||||||
|
// subnetRequired: false,
|
||||||
|
// firewall: "23434645",
|
||||||
|
// cpumodel: "intel",
|
||||||
|
// enablebackup: formData.enablebackup ? true : false,
|
||||||
|
// root_password: formData.rootPassword,
|
||||||
|
// support: "unmanaged",
|
||||||
|
// vpc: formData.vpc,
|
||||||
|
// cloud: [
|
||||||
|
// {
|
||||||
|
// hostname: formData.hostname
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// image: "debian-9.4-x86_64",
|
||||||
|
// sshkeys: formData.sshkeys
|
||||||
|
// };
|
||||||
200
src/components/Comment copy.jsx
Normal file
200
src/components/Comment copy.jsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card } from "./ui/card";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Textarea } from "./ui/textarea";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||||
|
|
||||||
|
const COMMENTS_API_URL = 'https://host-api-sxashuasysagibx.siliconpin.com/v1/comments/';
|
||||||
|
|
||||||
|
export default function Comment(props) {
|
||||||
|
const [comments, setComments] = useState([]);
|
||||||
|
const [newComment, setNewComment] = useState({ comment: '' });
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const [isLoadingComments, setIsLoadingComments] = useState(true);
|
||||||
|
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||||
|
|
||||||
|
// Load comments when component mounts or when topicId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.topicId) {
|
||||||
|
fetchComments(props.topicId);
|
||||||
|
}
|
||||||
|
}, [props.topicId]);
|
||||||
|
|
||||||
|
const fetchComments = async (topicId) => {
|
||||||
|
setIsLoadingComments(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${COMMENTS_API_URL}?topicId=${topicId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Failed to fetch comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success && data.comments) {
|
||||||
|
setComments(data.comments);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching comments:', error);
|
||||||
|
setSubmitError('Failed to load comments');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingComments(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setNewComment(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitComment = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${COMMENTS_API_URL}?query=new-comment`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...newComment,
|
||||||
|
topicId: props.topicId
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setNewComment({ comment: '' });
|
||||||
|
setSubmitSuccess(true);
|
||||||
|
setTimeout(() => setSubmitSuccess(false), 3000);
|
||||||
|
|
||||||
|
// Refresh comments after successful submission
|
||||||
|
await fetchComments(props.topicId);
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setSubmitError(errorData.message || 'Failed to submit comment');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserInitials = (name) => {
|
||||||
|
if (!name) return 'U';
|
||||||
|
const words = name.trim().split(' ');
|
||||||
|
return words
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(word => word[0].toUpperCase())
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Comments List */}
|
||||||
|
<div className="space-y-6 border-t">
|
||||||
|
<h2 className="text-2xl font-bold text-[#6d9e37]">Comments ({comments.length})</h2>
|
||||||
|
|
||||||
|
{isLoadingComments ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
{/* <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-[#6d9e37]"></div> */}
|
||||||
|
</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No comments yet. Be the first to comment!</p>
|
||||||
|
) : (
|
||||||
|
comments.map(comment => (
|
||||||
|
<div key={comment.id} className="border-b pb-6 last:border-b-0">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 font-bold">
|
||||||
|
{getUserInitials(comment.userName)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-[#6d9e37]">
|
||||||
|
{comment.userName || 'User'}
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(comment.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="">{comment.comment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Section */}
|
||||||
|
<Card className="mt-16 border-t">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="px-6 pb-6 rounded-lg">
|
||||||
|
<h3 className="text-lg font-medium mb-4 pt-8">Leave a Comment</h3>
|
||||||
|
{submitSuccess && (
|
||||||
|
<div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
|
||||||
|
Thank you for your comment!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{submitError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmitComment}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Label htmlFor="comment">Comment *</Label>
|
||||||
|
<Textarea
|
||||||
|
className="mt-2"
|
||||||
|
id="comment"
|
||||||
|
name="comment"
|
||||||
|
rows="4"
|
||||||
|
value={newComment.comment}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
disabled={!isLoggedIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !isLoggedIn}>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Post Comment'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-full absolute bg-black inset-0 opacity-70 backdrop-blur-2xl rounded-lg" />
|
||||||
|
<div className="w-10 h-10 rounded-full border-2 border-dotted border-[#6d9e37] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" role="status">
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : !isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-full absolute bg-black inset-0 opacity-70 backdrop-blur-2xl rounded-lg" />
|
||||||
|
<p className="text-gray-100 bg-gray-700 p-2 rounded-md shadow-xl italic absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||||
|
Join the conversation! Log in or sign up to post a comment
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
src/components/CommentSystem/CommentSystem.jsx
Normal file
260
src/components/CommentSystem/CommentSystem.jsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import EmojiPicker from 'emoji-picker-react';
|
||||||
|
import { Card } from '../ui/card';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Textarea } from '../ui/textarea';
|
||||||
|
import CommentThread from './CommentThread';
|
||||||
|
import { token, user_name, pb_id, siliconId, isLogin } from '../../lib/CookieValues';
|
||||||
|
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||||
|
|
||||||
|
const CommentSystem = ({ topicId, pbId }) => {
|
||||||
|
const isLoginCoockie = JSON.parse(isLogin);
|
||||||
|
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||||
|
const [comments, setComments] = useState([]);
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||||
|
const [commentsError, setCommentsError] = useState('');
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
|
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
const emojiPickerRef = useRef(null);
|
||||||
|
|
||||||
|
// 📌 Insert emoji at cursor position
|
||||||
|
const insertEmojiAtCursor = (emoji) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = newComment;
|
||||||
|
|
||||||
|
const updatedText = text.slice(0, start) + emoji + text.slice(end);
|
||||||
|
setNewComment(updatedText);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 📌 Outside click to close emoji picker
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (
|
||||||
|
emojiPickerRef.current &&
|
||||||
|
!emojiPickerRef.current.contains(e.target) &&
|
||||||
|
!e.target.closest('#emoji-toggle-btn')
|
||||||
|
) {
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 📌 Comment nesting logic
|
||||||
|
const nestComments = (flatComments) => {
|
||||||
|
const commentMap = {};
|
||||||
|
const rootComments = [];
|
||||||
|
|
||||||
|
flatComments?.forEach(comment => {
|
||||||
|
comment.replies = [];
|
||||||
|
commentMap[comment.comment_id] = comment;
|
||||||
|
});
|
||||||
|
|
||||||
|
flatComments?.forEach(comment => {
|
||||||
|
if (comment.parent_comment_id?.Valid) {
|
||||||
|
const parent = commentMap[comment.parent_comment_id.String];
|
||||||
|
if (parent) parent.replies.push(comment);
|
||||||
|
} else {
|
||||||
|
rootComments.push(comment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rootComments.sort((a, b) =>
|
||||||
|
new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchComments = async () => {
|
||||||
|
try {
|
||||||
|
setCommentsLoading(true);
|
||||||
|
setCommentsError('');
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ topic_id: String(topicId) });
|
||||||
|
|
||||||
|
if (siliconId && siliconId !== 'null') {
|
||||||
|
params.append('silicon_id', siliconId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pb_id && pb_id !== 'null') {
|
||||||
|
params.append('pb_id', pb_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`https://sp-comments-prod-pocndhvgmcnbacgb.siliconpin.com/comments?${params.toString()}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatComments = await response.json();
|
||||||
|
setComments(nestComments(flatComments || []));
|
||||||
|
} catch (err) {
|
||||||
|
setCommentsError(err.message);
|
||||||
|
} finally {
|
||||||
|
setCommentsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const postComment = async (text, parentId = null) => {
|
||||||
|
try {
|
||||||
|
setCommentsLoading(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
topic_id: String(topicId),
|
||||||
|
silicon_id: siliconId,
|
||||||
|
pb_id: pbId,
|
||||||
|
user_id: siliconId,
|
||||||
|
user_name: user_name,
|
||||||
|
comment_text: text,
|
||||||
|
is_approved: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
payload.parent_comment_id = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = parentId ? '/comments/reply' : '/comments';
|
||||||
|
|
||||||
|
const response = await fetch(`https://sp-comments-prod-pocndhvgmcnbacgb.siliconpin.com${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to post comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchComments();
|
||||||
|
} catch (err) {
|
||||||
|
setCommentsError(err.message);
|
||||||
|
} finally {
|
||||||
|
setCommentsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchComments();
|
||||||
|
}, [topicId, siliconId, pbId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
<h2 className="text-2xl font-bold text-[#6d9e37] mb-6">Comments</h2>
|
||||||
|
|
||||||
|
{commentsError && (
|
||||||
|
<div className="bg-red-50 text-red-700 p-3 rounded-md mb-4">
|
||||||
|
{commentsError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{commentsLoading && comments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-[#6d9e37]">Loading comments...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{comments.length > 0 ? (
|
||||||
|
comments.map(comment => (
|
||||||
|
<CommentThread key={comment.comment_id} comment={comment} onReply={postComment} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-center py-8 text-gray-500 italic">
|
||||||
|
No comments yet. Be the first to comment!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{isLoginCoockie !== true ? (
|
||||||
|
<div className="absolute bg-black bg-opacity-70 w-full h-full rounded-lg">
|
||||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<p className="italic bg-[#2a2e35] px-2 py-1.5 rounded-md shadow-xl">
|
||||||
|
To join the conversation, please log in first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card className="mt-8 p-6 shadow-md border border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium mb-4">Add a Comment</h3>
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newComment.trim()) return;
|
||||||
|
postComment(newComment);
|
||||||
|
setNewComment('');
|
||||||
|
}}>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
placeholder="Share your thoughts..."
|
||||||
|
className="min-h-[100px] pr-12 border rounded-md"
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Emoji Button */}
|
||||||
|
<button
|
||||||
|
id="emoji-toggle-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
|
className="absolute bottom-3 right-3 text-xl text-gray-500 hover:text-yellow-500 transition-colors"
|
||||||
|
title="Add Emoji"
|
||||||
|
>
|
||||||
|
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M8.5 11C9.32843 11 10 10.3284 10 9.5C10 8.67157 9.32843 8 8.5 8C7.67157 8 7 8.67157 7 9.5C7 10.3284 7.67157 11 8.5 11Z" fill="#6d9e37"></path> <path d="M17 9.5C17 10.3284 16.3284 11 15.5 11C14.6716 11 14 10.3284 14 9.5C14 8.67157 14.6716 8 15.5 8C16.3284 8 17 8.67157 17 9.5Z" fill="#6d9e37"></path> <path d="M8.88875 13.5414C8.63822 13.0559 8.0431 12.8607 7.55301 13.1058C7.05903 13.3528 6.8588 13.9535 7.10579 14.4474C7.18825 14.6118 7.29326 14.7659 7.40334 14.9127C7.58615 15.1565 7.8621 15.4704 8.25052 15.7811C9.04005 16.4127 10.2573 17.0002 12.0002 17.0002C13.7431 17.0002 14.9604 16.4127 15.7499 15.7811C16.1383 15.4704 16.4143 15.1565 16.5971 14.9127C16.7076 14.7654 16.8081 14.6113 16.8941 14.4485C17.1387 13.961 16.9352 13.3497 16.4474 13.1058C15.9573 12.8607 15.3622 13.0559 15.1117 13.5414C15.0979 13.5663 14.9097 13.892 14.5005 14.2194C14.0401 14.5877 13.2573 15.0002 12.0002 15.0002C10.7431 15.0002 9.96038 14.5877 9.49991 14.2194C9.09071 13.892 8.90255 13.5663 8.88875 13.5414Z" fill="#6d9e37"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 20.9932C7.03321 20.9932 3.00683 16.9668 3.00683 12C3.00683 7.03321 7.03321 3.00683 12 3.00683C16.9668 3.00683 20.9932 7.03321 20.9932 12C20.9932 16.9668 16.9668 20.9932 12 20.9932Z" fill="#6d9e37"></path> </g></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Emoji Picker */}
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div
|
||||||
|
ref={emojiPickerRef}
|
||||||
|
className="absolute bottom-16 right-0 z-50 rounded-lg shadow-lg"
|
||||||
|
>
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiClick={(emojiData) => insertEmojiAtCursor(emojiData.emoji)}
|
||||||
|
height={400}
|
||||||
|
width={300}
|
||||||
|
theme="dark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={commentsLoading || !newComment.trim()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{commentsLoading ? (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Posting...
|
||||||
|
</span>
|
||||||
|
) : 'Post Comment'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentSystem;
|
||||||
157
src/components/CommentSystem/CommentThread.jsx
Normal file
157
src/components/CommentSystem/CommentThread.jsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import EmojiPicker from 'emoji-picker-react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Textarea } from '../ui/textarea';
|
||||||
|
import { token, user_name, pb_id, siliconId, isLogin } from '../../lib/CookieValues';
|
||||||
|
|
||||||
|
const CommentThread = ({ comment, depth = 0, onReply }) => {
|
||||||
|
const isLoginCoockie = JSON.parse(isLogin);
|
||||||
|
const [isReplying, setIsReplying] = useState(false);
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
|
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
const emojiPickerRef = useRef(null);
|
||||||
|
|
||||||
|
// 🧠 Emoji insert at cursor
|
||||||
|
const insertEmojiAtCursor = (emoji) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = replyText;
|
||||||
|
|
||||||
|
const updatedText = text.slice(0, start) + emoji + text.slice(end);
|
||||||
|
setReplyText(updatedText);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🧠 Close emoji picker on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (
|
||||||
|
emojiPickerRef.current &&
|
||||||
|
!emojiPickerRef.current.contains(e.target) &&
|
||||||
|
!e.target.closest(`#reply-emoji-btn-${comment.comment_id}`)
|
||||||
|
) {
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [comment.comment_id]);
|
||||||
|
|
||||||
|
const handleReplySubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!replyText.trim()) return;
|
||||||
|
onReply(replyText, comment.comment_id);
|
||||||
|
setReplyText('');
|
||||||
|
setIsReplying(false);
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mb-4 pl-${depth * 4} border-l-2 ${depth > 0 ? 'border-[#6d9e37]' : 'border-transparent'}`}
|
||||||
|
style={{ marginLeft: `${depth * 0.5}rem` }}
|
||||||
|
>
|
||||||
|
<div className="bg-[#25272950] rounded-lg shadow-sm px-4 py-3 mb-2">
|
||||||
|
<div className="flex gap-3 items-center justify-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" className="rounded-full bg-[#6d9e37] p-1"><path d="M20 20C24.1421 20 27.5 16.6421 27.5 12.5C27.5 8.35786 24.1421 5 20 5C15.8579 5 12.5 8.35786 12.5 12.5C12.5 16.6421 15.8579 20 20 20ZM20 23.75C14.2675 23.75 5 26.4825 5 32.1875V35H35V32.1875C35 26.4825 25.7325 23.75 20 23.75Z" fill="white"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className="font-medium text-[#6d9e37]">{comment.user_name}</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(comment.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-100 mb-2 whitespace-pre-wrap">{comment.comment_text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoginCoockie === true && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-none text-[#6d9e37] hover:text-[#8bc34a] hover:bg-transparent px-0 py-0 h-auto"
|
||||||
|
onClick={() => setIsReplying(!isReplying)}
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReplying && (
|
||||||
|
<form onSubmit={handleReplySubmit} className="mt-3 pl-11 relative">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Write your reply..."
|
||||||
|
className="mb-2 min-h-[70px] pr-10"
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Emoji Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={`reply-emoji-btn-${comment.comment_id}`}
|
||||||
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
|
className="absolute bottom-4 right-4 text-xl text-gray-400 hover:text-yellow-500 transition"
|
||||||
|
title="Add Emoji"
|
||||||
|
>
|
||||||
|
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M8.5 11C9.32843 11 10 10.3284 10 9.5C10 8.67157 9.32843 8 8.5 8C7.67157 8 7 8.67157 7 9.5C7 10.3284 7.67157 11 8.5 11Z" fill="#6d9e37"></path> <path d="M17 9.5C17 10.3284 16.3284 11 15.5 11C14.6716 11 14 10.3284 14 9.5C14 8.67157 14.6716 8 15.5 8C16.3284 8 17 8.67157 17 9.5Z" fill="#6d9e37"></path> <path d="M8.88875 13.5414C8.63822 13.0559 8.0431 12.8607 7.55301 13.1058C7.05903 13.3528 6.8588 13.9535 7.10579 14.4474C7.18825 14.6118 7.29326 14.7659 7.40334 14.9127C7.58615 15.1565 7.8621 15.4704 8.25052 15.7811C9.04005 16.4127 10.2573 17.0002 12.0002 17.0002C13.7431 17.0002 14.9604 16.4127 15.7499 15.7811C16.1383 15.4704 16.4143 15.1565 16.5971 14.9127C16.7076 14.7654 16.8081 14.6113 16.8941 14.4485C17.1387 13.961 16.9352 13.3497 16.4474 13.1058C15.9573 12.8607 15.3622 13.0559 15.1117 13.5414C15.0979 13.5663 14.9097 13.892 14.5005 14.2194C14.0401 14.5877 13.2573 15.0002 12.0002 15.0002C10.7431 15.0002 9.96038 14.5877 9.49991 14.2194C9.09071 13.892 8.90255 13.5663 8.88875 13.5414Z" fill="#6d9e37"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 20.9932C7.03321 20.9932 3.00683 16.9668 3.00683 12C3.00683 7.03321 7.03321 3.00683 12 3.00683C16.9668 3.00683 20.9932 7.03321 20.9932 12C20.9932 16.9668 16.9668 20.9932 12 20.9932Z" fill="#6d9e37"></path> </g></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Emoji Picker */}
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div
|
||||||
|
ref={emojiPickerRef}
|
||||||
|
className="absolute bottom-20 right-0 z-50 rounded-lg shadow-lg"
|
||||||
|
>
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiClick={(emojiData) => insertEmojiAtCursor(emojiData.emoji)}
|
||||||
|
height={400}
|
||||||
|
width={300}
|
||||||
|
theme="dark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button type="submit" size="sm" className="bg-[#6d9e37] hover:bg-[#8bc34a]">
|
||||||
|
Post Reply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-gray-600 text-gray-300 hover:border-[#6d9e37] hover:text-[#8bc34a]"
|
||||||
|
onClick={() => {
|
||||||
|
setIsReplying(false);
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{comment.replies?.map((reply) => (
|
||||||
|
<CommentThread key={reply.comment_id} comment={reply} depth={depth + 1} onReply={onReply} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentThread;
|
||||||
98
src/components/CommentSystem/LikeSystem.jsx
Normal file
98
src/components/CommentSystem/LikeSystem.jsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Heart, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function LikeSystem({ postId, siliconId, token }) {
|
||||||
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [likeCount, setLikeCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLikeStatusAndCount = async () => {
|
||||||
|
try {
|
||||||
|
const countRes = await fetch(`https://sp-likes-pocndhvgmcnbacgb.siliconpin.com/posts/${postId}/likes`);
|
||||||
|
const countData = await countRes.json();
|
||||||
|
setLikeCount(countData.likes);
|
||||||
|
|
||||||
|
const likedRes = await fetch(
|
||||||
|
`https://sp-likes-pocndhvgmcnbacgb.siliconpin.com/is-liked?post_id=${postId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'X-SiliconId': siliconId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const likedData = await likedRes.json();
|
||||||
|
setLiked(likedData.liked);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching like data", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLikeStatusAndCount();
|
||||||
|
}, [postId, token, siliconId]);
|
||||||
|
|
||||||
|
const handleLikeToggle = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://sp-likes-pocndhvgmcnbacgb.siliconpin.com/like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'X-SiliconId': siliconId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ post_id: postId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setLiked(data.liked);
|
||||||
|
setLikeCount(prev => data.liked ? prev + 1 : prev - 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Network error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleLikeToggle}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={22} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Heart
|
||||||
|
size={28}
|
||||||
|
fill={liked ? '#ff4d4f' : 'none'}
|
||||||
|
color={liked ? '#ff4d4f' : '#999'}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
style={{ transition: '0.2s ease-in-out' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: liked ? '#ff4d4f' : '#555'
|
||||||
|
}}>
|
||||||
|
{likeCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/CommentSystem/index.js
Normal file
5
src/components/CommentSystem/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import CommentSystem from './CommentSystem';
|
||||||
|
import CommentThread from './CommentThread';
|
||||||
|
|
||||||
|
export default CommentSystem;
|
||||||
|
export { CommentThread };
|
||||||
371
src/components/CommentWithMD.jsx
Normal file
371
src/components/CommentWithMD.jsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||||
|
import { Card } from "./ui/card";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||||
|
|
||||||
|
const COMMENTS_API_URL = 'https://host-api-sxashuasysagibx.siliconpin.com/v1/comments/';
|
||||||
|
const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
|
||||||
|
|
||||||
|
export default function Comment(props) {
|
||||||
|
const [comments, setComments] = useState([]);
|
||||||
|
const [newComment, setNewComment] = useState({ comment: '' });
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const [isLoadingComments, setIsLoadingComments] = useState(true);
|
||||||
|
const [editorMode, setEditorMode] = useState('edit');
|
||||||
|
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||||
|
const [imageUrlInput, setImageUrlInput] = useState('');
|
||||||
|
const [imageUploadFile, setImageUploadFile] = useState(null);
|
||||||
|
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||||
|
|
||||||
|
// Custom image command for MDEditor
|
||||||
|
const customImageCommand = {
|
||||||
|
name: 'image',
|
||||||
|
keyCommand: 'image',
|
||||||
|
buttonProps: { 'aria-label': 'Insert image' },
|
||||||
|
icon: (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 20 20">
|
||||||
|
<path fill="currentColor" d="M15 9c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4-7H1c-.55 0-1 .45-1 1v14c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V3c0-.55-.45-1-1-1zm-1 13l-6-5-2 2-4-5-4 8V4h16v11z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
execute: () => {
|
||||||
|
setImageDialogOpen(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all default commands and replace the image command
|
||||||
|
const allCommands = commands.getCommands().map(cmd => {
|
||||||
|
if (cmd.name === 'image') {
|
||||||
|
return customImageCommand;
|
||||||
|
}
|
||||||
|
return cmd;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load comments when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.topicId) {
|
||||||
|
fetchComments(props.topicId);
|
||||||
|
}
|
||||||
|
}, [props.topicId]);
|
||||||
|
|
||||||
|
const fetchComments = async (topicId) => {
|
||||||
|
setIsLoadingComments(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${COMMENTS_API_URL}?topicId=${topicId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Failed to fetch comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success && data.comments) {
|
||||||
|
setComments(data.comments);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching comments:', error);
|
||||||
|
setSubmitError('Failed to load comments');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingComments(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload file to MinIO
|
||||||
|
const uploadToMinIO = async (file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('api_key', 'wweifwehfwfhwhtuyegbvijvbfvegfreyf');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.publicUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle image URL insertion
|
||||||
|
const handleInsertImageUrl = () => {
|
||||||
|
if (imageUrlInput) {
|
||||||
|
const imgMarkdown = ``;
|
||||||
|
setNewComment(prev => ({
|
||||||
|
...prev,
|
||||||
|
comment: prev.comment ? `${prev.comment}\n${imgMarkdown}` : imgMarkdown
|
||||||
|
}));
|
||||||
|
setImageDialogOpen(false);
|
||||||
|
setImageUrlInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle image file selection
|
||||||
|
const handleImageFileSelect = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
setImageUploadFile(file);
|
||||||
|
|
||||||
|
// Create preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setImageUploadPreview(reader.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload image file to MinIO and insert into editor
|
||||||
|
const handleImageUpload = async () => {
|
||||||
|
if (!imageUploadFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
|
const uploadedUrl = await uploadToMinIO(imageUploadFile);
|
||||||
|
|
||||||
|
// Insert markdown for the uploaded image
|
||||||
|
const imgMarkdown = ``;
|
||||||
|
setNewComment(prev => ({
|
||||||
|
...prev,
|
||||||
|
comment: prev.comment ? `${prev.comment}\n${imgMarkdown}` : imgMarkdown
|
||||||
|
}));
|
||||||
|
|
||||||
|
setImageDialogOpen(false);
|
||||||
|
setImageUploadFile(null);
|
||||||
|
setImageUploadPreview('');
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError('Failed to upload image: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitComment = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${COMMENTS_API_URL}?query=new-comment`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...newComment,
|
||||||
|
topicId: props.topicId
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setNewComment({ comment: '' });
|
||||||
|
setSubmitSuccess(true);
|
||||||
|
setTimeout(() => setSubmitSuccess(false), 3000);
|
||||||
|
|
||||||
|
// Refresh comments after successful submission
|
||||||
|
await fetchComments(props.topicId);
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setSubmitError(errorData.message || 'Failed to submit comment');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserInitials = (name) => {
|
||||||
|
if (!name) return 'U';
|
||||||
|
const words = name.trim().split(' ');
|
||||||
|
return words
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(word => word[0].toUpperCase())
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Comments List */}
|
||||||
|
<div className="space-y-6 border-t">
|
||||||
|
<h2 className="text-2xl font-bold text-[#6d9e37]">Comments ({comments.length})</h2>
|
||||||
|
|
||||||
|
{isLoadingComments ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-[#6d9e37]"></div>
|
||||||
|
</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No comments yet. Be the first to comment!</p>
|
||||||
|
) : (
|
||||||
|
comments.map(comment => (
|
||||||
|
<div key={comment.id} className="border-b pb-6 last:border-b-0">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 font-bold">
|
||||||
|
{getUserInitials(comment.userName)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-[#6d9e37]">
|
||||||
|
{comment.userName || 'User'}
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(comment.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div data-color-mode="light" className="markdown-body">
|
||||||
|
<MDEditor.Markdown source={comment.comment} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Section with MDEditor */}
|
||||||
|
<Card className="mt-16 border-t">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="px-6 pb-6 rounded-lg">
|
||||||
|
<h3 className="text-lg font-medium mb-4 pt-8">Leave a Comment</h3>
|
||||||
|
{submitSuccess && (
|
||||||
|
<div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
|
||||||
|
Thank you for your comment!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{submitError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmitComment}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Label htmlFor="comment">Comment *</Label>
|
||||||
|
<div data-color-mode="light">
|
||||||
|
<MDEditor
|
||||||
|
placeholder="Write your comment (markdown supported)"
|
||||||
|
value={newComment.comment}
|
||||||
|
onChange={(value) => setNewComment(prev => ({ ...prev, comment: value || '' }))}
|
||||||
|
height={300}
|
||||||
|
preview={editorMode}
|
||||||
|
commands={allCommands}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditorMode(editorMode === 'edit' ? 'preview' : 'edit')}
|
||||||
|
className={`text-sm ${editorMode !== 'edit' ? 'bg-[#6d9e37] text-white' : 'text-[#6d9e37]'} px-2 py-1 rounded-md border border-[#6d9e37]`}
|
||||||
|
>
|
||||||
|
{editorMode === 'edit' ? 'Preview' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !isLoggedIn}>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Post Comment'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Upload Dialog */}
|
||||||
|
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Insert Image</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="image-url">From URL</Label>
|
||||||
|
<Input
|
||||||
|
id="image-url"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter image URL"
|
||||||
|
value={imageUrlInput}
|
||||||
|
onChange={(e) => setImageUrlInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInsertImageUrl}
|
||||||
|
disabled={!imageUrlInput}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Insert Image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="image-upload">Upload Image</Label>
|
||||||
|
<Input
|
||||||
|
id="image-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageFileSelect}
|
||||||
|
/>
|
||||||
|
{imageUploadPreview && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={imageUploadPreview}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-h-40 rounded-md border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleImageUpload}
|
||||||
|
disabled={!imageUploadFile || isSubmitting}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Uploading...' : 'Upload & Insert'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-full absolute bg-black inset-0 opacity-70 backdrop-blur-2xl rounded-lg" />
|
||||||
|
<div className="w-10 h-10 rounded-full border-2 border-dotted border-[#6d9e37] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" role="status">
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : !isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-full absolute bg-black inset-0 opacity-70 backdrop-blur-2xl rounded-lg" />
|
||||||
|
<p className="text-gray-100 bg-gray-700 p-2 rounded-md shadow-xl italic absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||||
|
Join the conversation! Log in or sign up to post a comment
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import { Button } from './ui/button';
|
|||||||
export function ContactForm() {
|
export function ContactForm() {
|
||||||
const [formState, setFormState] = useState({ name: '', email: '', company: '', service: '', message: '' });
|
const [formState, setFormState] = useState({ name: '', email: '', company: '', service: '', message: '' });
|
||||||
const [formStatus, setFormStatus] = useState('idle');
|
const [formStatus, setFormStatus] = useState('idle');
|
||||||
const USER_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
|
||||||
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
@@ -32,7 +33,7 @@ export function ContactForm() {
|
|||||||
message: formState.message
|
message: formState.message
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${USER_API_URL}?query=contact-form`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=contact-form`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { Button } from './ui/button';
|
|||||||
|
|
||||||
export const DomainSetupForm = ({ defaultSubdomain }) => {
|
export const DomainSetupForm = ({ defaultSubdomain }) => {
|
||||||
// API URLs
|
// API URLs
|
||||||
const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
|
||||||
const SERVICES_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/services/';
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
const PUBLIC_SERVICE_API_URL = import.meta.env.PUBLIC_SERVICE_API_URL;
|
||||||
|
|
||||||
// State for deployment options
|
// State for deployment options
|
||||||
const [deploymentType, setDeploymentType] = useState('app');
|
const [deploymentType, setDeploymentType] = useState('app');
|
||||||
@@ -113,7 +114,7 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
|
|||||||
formData.append('cycle', selectedCycle);
|
formData.append('cycle', selectedCycle);
|
||||||
formData.append('amount', selectedPrice.toString());
|
formData.append('amount', selectedPrice.toString());
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}?query=initiate_payment`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@@ -166,7 +167,7 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
|
|||||||
setShowDnsConfig(false);
|
setShowDnsConfig(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${SERVICES_API_URL}?query=validate-domain`, {
|
const response = await fetch(`${PUBLIC_SERVICE_API_URL}?query=validate-domain`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ domain })
|
body: JSON.stringify({ domain })
|
||||||
@@ -208,7 +209,7 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
|
|||||||
const checkSubDomainCname = useCallback(() => {
|
const checkSubDomainCname = useCallback(() => {
|
||||||
const domainToCheck = customDomain || customSubdomain;
|
const domainToCheck = customDomain || customSubdomain;
|
||||||
|
|
||||||
fetch(`${SERVICES_API_URL}?query=check-c-name`, {
|
fetch(`${PUBLIC_SERVICE_API_URL}?query=check-c-name`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: `domain=${encodeURIComponent(domainToCheck)}`
|
body: `domain=${encodeURIComponent(domainToCheck)}`
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
|
|||||||
|
|
||||||
|
|
||||||
const [vpnContinent, setVpnContinent] = useState('');
|
const [vpnContinent, setVpnContinent] = useState('');
|
||||||
const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
const SERVICES_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/services/';
|
const PUBLIC_SERVICE_API_URL = import.meta.env.PUBLIC_SERVICE_API_URL;
|
||||||
// const BILLING_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/index.php';
|
|
||||||
|
|
||||||
const [selectedcycle, setSelectedcycle] = useState('');
|
const [selectedcycle, setSelectedcycle] = useState('');
|
||||||
const [selectedPrice, setSelectedPrice] = useState(0);
|
const [selectedPrice, setSelectedPrice] = useState(0);
|
||||||
@@ -70,7 +69,7 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
|
|||||||
formData.append('cycle', selectedcycle);
|
formData.append('cycle', selectedcycle);
|
||||||
formData.append('amount', selectedPrice); //selectedPrice
|
formData.append('amount', selectedPrice); //selectedPrice
|
||||||
|
|
||||||
fetch(`${API_URL}?query=initiate_payment`, {
|
fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@@ -105,7 +104,7 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
|
|||||||
formData.append('serviceId', 'vpnservices');
|
formData.append('serviceId', 'vpnservices');
|
||||||
formData.append('cycle', selectedcycle);
|
formData.append('cycle', selectedcycle);
|
||||||
formData.append('amount', selectedPrice);
|
formData.append('amount', selectedPrice);
|
||||||
fetch(`${API_URL}?query=initiate_payment`, {
|
fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@@ -295,7 +294,7 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Make API call to validate domain
|
// Make API call to validate domain
|
||||||
const response = await fetch(`${SERVICES_API_URL}?query=validate-domain`, {
|
const response = await fetch(`${PUBLIC_SERVICE_API_URL}?query=validate-domain`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -331,7 +330,7 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
|
|||||||
const checkSubDomainCname = () => {
|
const checkSubDomainCname = () => {
|
||||||
const domainToCheck = customDomain || customSubdomain;
|
const domainToCheck = customDomain || customSubdomain;
|
||||||
|
|
||||||
fetch(`${SERVICES_API_URL}?query=check-c-name`, {
|
fetch(`${PUBLIC_SERVICE_API_URL}?query=check-c-name`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
|||||||
56
src/components/ImageSlider/ImageSlider.css
Normal file
56
src/components/ImageSlider/ImageSlider.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.slider-container {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider img {
|
||||||
|
width: 100%;
|
||||||
|
height: 600px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-arrow, .right-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #fff;
|
||||||
|
z-index: 10;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-arrow {
|
||||||
|
left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-arrow {
|
||||||
|
right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
margin: 0 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.active {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
174
src/components/ImageSlider/ImageSlider.jsx
Normal file
174
src/components/ImageSlider/ImageSlider.jsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
function ImageSlider({ images }) {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [startX, setStartX] = useState(0);
|
||||||
|
const [autoplay, setAutoplay] = useState(true);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const trackRef = useRef(null);
|
||||||
|
const autoplayRef = useRef(null);
|
||||||
|
// Autoplay
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoplay) {
|
||||||
|
autoplayRef.current = setInterval(() => {
|
||||||
|
setCurrentIndex(prev => (prev + 1) % images.length);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(autoplayRef.current);
|
||||||
|
}, [autoplay, images.length]);
|
||||||
|
|
||||||
|
// ESC key for fullscreen exit
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (e) => {
|
||||||
|
if (e.key === 'Escape') setIsFullscreen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEsc);
|
||||||
|
return () => window.removeEventListener('keydown', handleEsc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Drag Start
|
||||||
|
const handleDragStart = (e) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
setAutoplay(false);
|
||||||
|
setStartX(e.type.includes('mouse') ? e.clientX : e.touches[0].clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag Move
|
||||||
|
const handleDragMove = (e) => {
|
||||||
|
if (!isDragging || !trackRef.current) return;
|
||||||
|
const currentX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
|
||||||
|
const diff = currentX - startX;
|
||||||
|
trackRef.current.style.transform = `translateX(calc(${-currentIndex * 100}% + ${diff}px))`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag End
|
||||||
|
const handleDragEnd = (e) => {
|
||||||
|
if (!isDragging || !trackRef.current) return;
|
||||||
|
setIsDragging(false);
|
||||||
|
const endX = e.type.includes('mouse') ? e.clientX : e.changedTouches[0].clientX;
|
||||||
|
const diff = endX - startX;
|
||||||
|
const threshold = 50;
|
||||||
|
|
||||||
|
if (diff > threshold) {
|
||||||
|
setCurrentIndex(prev => (prev > 0 ? prev - 1 : images.length - 1));
|
||||||
|
} else if (diff < -threshold) {
|
||||||
|
setCurrentIndex(prev => (prev + 1) % images.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoplay(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToImage = (index) => {
|
||||||
|
setCurrentIndex(index);
|
||||||
|
setAutoplay(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPrevious = () => {
|
||||||
|
setCurrentIndex(prev => (prev === 0 ? images.length - 1 : prev - 1));
|
||||||
|
setAutoplay(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNext = () => {
|
||||||
|
setCurrentIndex(prev => (prev + 1) % images.length);
|
||||||
|
setAutoplay(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full max-w-5xl mx-auto select-none group">
|
||||||
|
{/* Main Slider */}
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden h-auto rounded-2xl shadow-2xl"
|
||||||
|
onMouseDown={handleDragStart}
|
||||||
|
onMouseMove={handleDragMove}
|
||||||
|
onMouseUp={handleDragEnd}
|
||||||
|
onMouseLeave={handleDragEnd}
|
||||||
|
onTouchStart={handleDragStart}
|
||||||
|
onTouchMove={handleDragMove}
|
||||||
|
onTouchEnd={handleDragEnd}
|
||||||
|
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="flex transition-transform duration-500 ease-in-out"
|
||||||
|
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
images.length > 0 && (
|
||||||
|
images.map((img, idx) => (
|
||||||
|
<div key={idx} className="w-full flex-shrink-0">
|
||||||
|
<img src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${img.image}`} alt={`Slide ${idx}`} className="relative w-full h-full" draggable={false} />
|
||||||
|
<p className='absolute bottom-0 bg-black/70 lg:text-center text-[#FFF] z-40 w-full px-2 py-1 lg:py-2'>{img.desc}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen Button */}
|
||||||
|
<div className="absolute bottom-4 left-4 z-30">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(true)}
|
||||||
|
className="bg-black/50 hover:bg-black/70 text-white p-2 rounded-full text-lg shadow-lg transition-all border-2 border-[#fff}"
|
||||||
|
title="Fullscreen"
|
||||||
|
>
|
||||||
|
<svg width="18px" height="18px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="#ffffff"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M21.7092 2.29502C21.8041 2.3904 21.8757 2.50014 21.9241 2.61722C21.9727 2.73425 21.9996 2.8625 22 2.997L22 3V9C22 9.55228 21.5523 10 21 10C20.4477 10 20 9.55228 20 9V5.41421L14.7071 10.7071C14.3166 11.0976 13.6834 11.0976 13.2929 10.7071C12.9024 10.3166 12.9024 9.68342 13.2929 9.29289L18.5858 4H15C14.4477 4 14 3.55228 14 3C14 2.44772 14.4477 2 15 2H20.9998C21.2749 2 21.5242 2.11106 21.705 2.29078L21.7092 2.29502Z" fill="#ffffff"></path> <path d="M10.7071 14.7071L5.41421 20H9C9.55228 20 10 20.4477 10 21C10 21.5523 9.55228 22 9 22H3.00069L2.997 22C2.74301 21.9992 2.48924 21.9023 2.29502 21.7092L2.29078 21.705C2.19595 21.6096 2.12432 21.4999 2.07588 21.3828C2.02699 21.2649 2 21.1356 2 21V15C2 14.4477 2.44772 14 3 14C3.55228 14 4 14.4477 4 15V18.5858L9.29289 13.2929C9.68342 12.9024 10.3166 12.9024 10.7071 13.2929C11.0976 13.6834 11.0976 14.3166 10.7071 14.7071Z" fill="#ffffff"></path> </g></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Arrows */}
|
||||||
|
<button
|
||||||
|
onClick={goToPrevious}
|
||||||
|
className="absolute top-1/2 left-3 -translate-y-1/2 z-20 bg-black/40 hover:bg-black/70 text-white rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
<svg width="32px" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M11.7071 16.7071C12.0976 16.3166 12.0976 15.6834 11.7071 15.2929L9.41421 13H17C17.5523 13 18 12.5523 18 12C18 11.4477 17.5523 11 17 11H9.41421L11.7071 8.70711C12.0976 8.31658 12.0976 7.68342 11.7071 7.29289C11.3166 6.90237 10.6834 6.90237 10.2929 7.29289L6.29289 11.2929C5.90237 11.6834 5.90237 12.3166 6.29289 12.7071L10.2929 16.7071C10.6834 17.0976 11.3166 17.0976 11.7071 16.7071Z" fill="#ffffff"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" fill="#ffffff"></path> </g></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={goToNext}
|
||||||
|
className="absolute top-1/2 right-3 -translate-y-1/2 z-20 bg-black/40 hover:bg-black/70 text-white rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
<svg width="32px" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M12.2929 16.7071C11.9024 16.3166 11.9024 15.6834 12.2929 15.2929L14.5858 13H7C6.44772 13 6 12.5523 6 12C6 11.4477 6.44772 11 7 11H14.5858L12.2929 8.70711C11.9024 8.31658 11.9024 7.68342 12.2929 7.29289C12.6834 6.90237 13.3166 6.90237 13.7071 7.29289L17.7071 11.2929C18.0976 11.6834 18.0976 12.3166 17.7071 12.7071L13.7071 16.7071C13.3166 17.0976 12.6834 17.0976 12.2929 16.7071Z" fill="#ffffff"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23ZM3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12Z" fill="#ffffff"></path> </g></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Thumbnail Navigation */}
|
||||||
|
<div className="mt-4 flex overflow-x-auto gap-2">
|
||||||
|
{
|
||||||
|
images.length > 0 && (
|
||||||
|
images.map((img, idx) => (
|
||||||
|
<img
|
||||||
|
key={idx}
|
||||||
|
src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${img.image}`}
|
||||||
|
alt={`Thumb ${idx}`}
|
||||||
|
onClick={() => goToImage(idx)}
|
||||||
|
className={`w-20 h-14 object-cover rounded-md cursor-pointer border-2 transition ${
|
||||||
|
idx === currentIndex ? 'border-white' : 'border-transparent'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen Overlay */}
|
||||||
|
{images.length > 0 && isFullscreen && (
|
||||||
|
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
||||||
|
<img src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${images[currentIndex].image}`} alt="Fullscreen" className="relative max-w-full max-h-[80vh] object-contain transition duration-300" />
|
||||||
|
<p className='absolute bottom-0 bg-black/70 lg:text-center text-[#FFF] z-100 w-full px-2 py-1 lg:py-2'>{images[currentIndex].desc}</p>
|
||||||
|
<button onClick={() => setIsFullscreen(false)} className="absolute top-4 left-4 text-white text-2xl bg-white/20 hover:bg-white/40 rounded-full" title="Close" >
|
||||||
|
<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <title>cross-circle</title> <desc>Created with Sketch Beta.</desc> <defs> </defs> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"> <g id="Icon-Set" sketch:type="MSLayerGroup" transform="translate(-568.000000, -1087.000000)" fill="#ffffff"> <path d="M584,1117 C576.268,1117 570,1110.73 570,1103 C570,1095.27 576.268,1089 584,1089 C591.732,1089 598,1095.27 598,1103 C598,1110.73 591.732,1117 584,1117 L584,1117 Z M584,1087 C575.163,1087 568,1094.16 568,1103 C568,1111.84 575.163,1119 584,1119 C592.837,1119 600,1111.84 600,1103 C600,1094.16 592.837,1087 584,1087 L584,1087 Z M589.717,1097.28 C589.323,1096.89 588.686,1096.89 588.292,1097.28 L583.994,1101.58 L579.758,1097.34 C579.367,1096.95 578.733,1096.95 578.344,1097.34 C577.953,1097.73 577.953,1098.37 578.344,1098.76 L582.58,1102.99 L578.314,1107.26 C577.921,1107.65 577.921,1108.29 578.314,1108.69 C578.708,1109.08 579.346,1109.08 579.74,1108.69 L584.006,1104.42 L588.242,1108.66 C588.633,1109.05 589.267,1109.05 589.657,1108.66 C590.048,1108.27 590.048,1107.63 589.657,1107.24 L585.42,1103.01 L589.717,1098.71 C590.11,1098.31 590.11,1097.68 589.717,1097.28 L589.717,1097.28 Z" id="cross-circle" sketch:type="MSShapeGroup"> </path> </g> </g> </g></svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={goToPrevious} className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-3xl bg-white/10 hover:bg-white/30 rounded-full">
|
||||||
|
<svg width="32px" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M11.7071 16.7071C12.0976 16.3166 12.0976 15.6834 11.7071 15.2929L9.41421 13H17C17.5523 13 18 12.5523 18 12C18 11.4477 17.5523 11 17 11H9.41421L11.7071 8.70711C12.0976 8.31658 12.0976 7.68342 11.7071 7.29289C11.3166 6.90237 10.6834 6.90237 10.2929 7.29289L6.29289 11.2929C5.90237 11.6834 5.90237 12.3166 6.29289 12.7071L10.2929 16.7071C10.6834 17.0976 11.3166 17.0976 11.7071 16.7071Z" fill="#ffffff"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" fill="#ffffff"></path> </g></svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={goToNext} className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-3xl bg-white/10 hover:bg-white/30 rounded-full">
|
||||||
|
<svg width="32px" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M12.2929 16.7071C11.9024 16.3166 11.9024 15.6834 12.2929 15.2929L14.5858 13H7C6.44772 13 6 12.5523 6 12C6 11.4477 6.44772 11 7 11H14.5858L12.2929 8.70711C11.9024 8.31658 11.9024 7.68342 12.2929 7.29289C12.6834 6.90237 13.3166 6.90237 13.7071 7.29289L17.7071 11.2929C18.0976 11.6834 18.0976 12.3166 17.7071 12.7071L13.7071 16.7071C13.3166 17.0976 12.6834 17.0976 12.2929 16.7071Z" fill="#ffffff"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23ZM3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12Z" fill="#ffffff"></path> </g></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageSlider;
|
||||||
@@ -34,7 +34,11 @@ const LoginPage = () => {
|
|||||||
const [status, setStatus] = useState<AuthStatus>({ message: '', isError: false });
|
const [status, setStatus] = useState<AuthStatus>({ message: '', isError: false });
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const pb = new PocketBase("https://tst-pb.s38.siliconpin.com");
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
const PUBLIC_HOST_API2_USERS = import.meta.env.PUBLIC_HOST_API2_USERS;
|
||||||
|
const PUBLIC_POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL;
|
||||||
|
|
||||||
|
const pb = new PocketBase(PUBLIC_POCKETBASE_URL);
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -48,6 +52,14 @@ const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set Coockie Function
|
||||||
|
function setCookie(name : string, value : string, daysToExpire : number) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + (daysToExpire * 24 * 60 * 60 * 1000));
|
||||||
|
const expires = "expires=" + date.toUTCString();
|
||||||
|
document.cookie = `${name}=${value}; ${expires}; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -92,7 +104,7 @@ const LoginPage = () => {
|
|||||||
setStatus({ message: '', isError: false });
|
setStatus({ message: '', isError: false });
|
||||||
|
|
||||||
const authData = await pb.collection('users').authWithOAuth2({ provider });
|
const authData = await pb.collection('users').authWithOAuth2({ provider });
|
||||||
|
console.log('Authdata', authData);
|
||||||
if (!authData?.record) {
|
if (!authData?.record) {
|
||||||
throw new Error("No user record found");
|
throw new Error("No user record found");
|
||||||
}
|
}
|
||||||
@@ -123,16 +135,9 @@ const LoginPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set Coockie Function
|
|
||||||
function setCookie(name : string, value : string, daysToExpire : number) {
|
|
||||||
const date = new Date();
|
|
||||||
date.setTime(date.getTime() + (daysToExpire * 24 * 60 * 60 * 1000));
|
|
||||||
const expires = "expires=" + date.toUTCString();
|
|
||||||
document.cookie = `${name}=${value}; ${expires}; path=/`;
|
|
||||||
}
|
|
||||||
const handleCreateUserInMongo = async (siliconId: string) => {
|
const handleCreateUserInMongo = async (siliconId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://hostapi2.cs1.hz.siliconpin.com/api/users`, {
|
const response = await fetch(`${PUBLIC_HOST_API2_USERS}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -144,22 +149,27 @@ const LoginPage = () => {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const data = await response.json();
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (response.status === 200 && data.message) {
|
||||||
|
console.log('User exists:', data.message);
|
||||||
|
return data.user; // return the existing user
|
||||||
|
}
|
||||||
|
console.log('New user created:', data);
|
||||||
|
return data; // return the new user
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
throw new Error(data.error || `HTTP error! status: ${response.status}`);
|
||||||
console.log('MongoDB Response:', data);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('MongoDB Creation Error:', error);
|
console.error('MongoDB Creation Error:', error);
|
||||||
throw error; // Return Error
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const syncSessionWithBackend = async (authData: AuthResponse, avatarUrl: string) => {
|
const syncSessionWithBackend = async (authData: AuthResponse, avatarUrl: string) => {
|
||||||
try {
|
try {
|
||||||
// Step 1: Sync with SiliconPin backend
|
// Step 1: Sync with SiliconPin backend
|
||||||
const response = await fetch('https://host-api.cs1.hz.siliconpin.com/v1/users/?query=login', {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -191,7 +201,14 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
// Step 3: Redirect to profile if all are ok
|
// Step 3: Redirect to profile if all are ok
|
||||||
window.location.href = '/profile';
|
window.location.href = '/profile';
|
||||||
|
console.log('data', data)
|
||||||
setCookie('token', data.userData.accessToken, 7);
|
setCookie('token', data.userData.accessToken, 7);
|
||||||
|
setCookie('user_name', authData.record.name, 7);
|
||||||
|
setCookie('pb_id', authData.record.id, 7);
|
||||||
|
setCookie('siliconId', data.userData.userId, 7);
|
||||||
|
setCookie('isLogin', JSON.stringify(true), 7);
|
||||||
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Sync Error:', error);
|
console.error('Sync Error:', error);
|
||||||
@@ -327,6 +344,14 @@ const LoginPage = () => {
|
|||||||
>
|
>
|
||||||
<img src="/assets/github.svg" alt="GitHub" className="h-6 w-6" />
|
<img src="/assets/github.svg" alt="GitHub" className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* <Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => loginWithOAuth2('gitea')}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<img src="/assets/gitea.svg" alt="GitHub" className="h-6 w-6" />
|
||||||
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { Toast } from './Toast';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card";
|
||||||
import { FileX, Copy, CheckCircle2, AlertCircle, X } from "lucide-react";
|
import { FileX, Copy, CheckCircle2, AlertCircle, X } from "lucide-react";
|
||||||
import Loader from "./ui/loader";
|
import Loader from "./ui/loader";
|
||||||
const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
|
||||||
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
|
||||||
export default function MakePayment(){
|
export default function MakePayment(){
|
||||||
const [initialOrderData, setInitialOrderData] = useState(null);
|
const [initialOrderData, setInitialOrderData] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -36,7 +38,7 @@ export default function MakePayment(){
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('order_id', orderId);
|
formData.append('order_id', orderId);
|
||||||
|
|
||||||
fetch(`${API_URL}?query=get-initiated_payment`, {
|
fetch(`${PUBLIC_USER_API_URL}?query=get-initiated_payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@@ -154,7 +156,7 @@ export default function MakePayment(){
|
|||||||
|
|
||||||
const handleSaveUPIPayment = async () => {
|
const handleSaveUPIPayment = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}?query=save-upi-payment`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=save-upi-payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChevronUp, ChevronDown, Eye, Pencil, Trash2, Download, CircleArrowRight
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
export default function AllCustomerList() {
|
export default function AllCustomerList() {
|
||||||
const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||||
const [usersData, setUsersData] = useState([]);
|
const [usersData, setUsersData] = useState([]);
|
||||||
const [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
@@ -28,7 +28,7 @@ export default function AllCustomerList() {
|
|||||||
const fetchUsersData = async () => {
|
const fetchUsersData = async () => {
|
||||||
setDataLoading(true);
|
setDataLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}?query=get-all-users`, {
|
const res = await fetch(`${PUBLIC_USER_API_URL}?query=get-all-users`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -76,7 +76,7 @@ export default function AllCustomerList() {
|
|||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}${currentUser.id}`, {
|
const res = await fetch(`${PUBLIC_USER_API_URL}${currentUser.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -96,7 +96,7 @@ export default function AllCustomerList() {
|
|||||||
|
|
||||||
const saveUserChanges = async (updatedUser) => {
|
const saveUserChanges = async (updatedUser) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}${updatedUser.id}`, {
|
const res = await fetch(`${PUBLIC_USER_API_URL}${updatedUser.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function AllSellingList() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
|
|
||||||
const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn && sessionData?.user_type === 'admin') {
|
if (isLoggedIn && sessionData?.user_type === 'admin') {
|
||||||
@@ -49,7 +49,7 @@ export default function AllSellingList() {
|
|||||||
const fetchBillingData = async () => {
|
const fetchBillingData = async () => {
|
||||||
try {
|
try {
|
||||||
setDataLoading(true);
|
setDataLoading(true);
|
||||||
const res = await fetch(`${INVOICE_API_URL}?query=all-selling-list`, {
|
const res = await fetch(`${PUBLIC_USER_API_URL}?query=all-selling-list`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -69,7 +69,7 @@ export default function AllSellingList() {
|
|||||||
|
|
||||||
const fetchUsersData = async () => {
|
const fetchUsersData = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${INVOICE_API_URL}?query=get-all-users`, {
|
const res = await fetch(`${PUBLIC_USER_API_URL}?query=get-all-users`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -122,7 +122,7 @@ export default function AllSellingList() {
|
|||||||
if (!window.confirm('Are you sure you want to delete this billing record?')) return;
|
if (!window.confirm('Are you sure you want to delete this billing record?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${INVOICE_API_URL}?query=delete-billing&billingId=${billingId}&serviceType=${serviceType}`, {
|
const res = await fetch(`${PUBLIC_USER_API_URL}?query=delete-billing&billingId=${billingId}&serviceType=${serviceType}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -150,7 +150,7 @@ export default function AllSellingList() {
|
|||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const url = selectedItem ? `${INVOICE_API_URL}?query=update-billing&id=${selectedItem.id}` : `${INVOICE_API_URL}?query=create-billing`;
|
const url = selectedItem ? `${PUBLIC_USER_API_URL}?query=update-billing&id=${selectedItem.id}` : `${PUBLIC_USER_API_URL}?query=create-billing`;
|
||||||
|
|
||||||
const method = selectedItem ? 'PUT' : 'POST';
|
const method = selectedItem ? 'PUT' : 'POST';
|
||||||
|
|
||||||
@@ -774,7 +774,7 @@ export default function AllSellingList() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="">
|
<div className="">
|
||||||
<label htmlFor="remarks" className="block text-sm font-medium text-gray-700 mb-1">Remarks</label>
|
<label htmlFor="remarks" className="block text-sm font-medium text-gray-700 mb-1">Remarks</label>
|
||||||
<input type="text" name="remarks" value={formData.remarks} onChange={handleInputChange} className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none text-neutral-950" />
|
<textarea name="remarks" value={formData.remarks} onChange={handleInputChange} className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none text-neutral-950"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ export default function HetznerServicesList() {
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const HETZNER_API_KEY = "uMSBR9nxdtbuvazsVM8YMMDd0PvuynpgJbmzFIO47HblMlh7tlHT8wV05sQ28Squ"; // Replace with your actual API key
|
const PUBLIC_HETZNER_API_KEY = import.meta.env.PUBLIC_HETZNER_API_KEY;
|
||||||
|
|
||||||
// Define fetchServers outside useEffect so it can be reused
|
// Define fetchServers outside useEffect so it can be reused
|
||||||
const fetchServers = async () => {
|
const fetchServers = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.hetzner.cloud/v1/servers", {
|
const response = await fetch("https://api.hetzner.cloud/v1/servers", {
|
||||||
headers: { "Authorization": `Bearer ${HETZNER_API_KEY}` }
|
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setServers(data.servers || []);
|
setServers(data.servers || []);
|
||||||
@@ -42,7 +42,7 @@ export default function HetznerServicesList() {
|
|||||||
const response = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}/actions/${action}`, {
|
const response = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}/actions/${action}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${HETZNER_API_KEY}`,
|
"Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}`,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -77,7 +77,7 @@ export default function HetznerServicesList() {
|
|||||||
const response = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
|
const response = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${HETZNER_API_KEY}`,
|
"Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}`,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ export default function ViewAsUser() {
|
|||||||
const [dataError, setDataError] = useState(null);
|
const [dataError, setDataError] = useState(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||||
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
const USER_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -36,7 +35,7 @@ export default function ViewAsUser() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${USER_API_URL}?query=login-from-admin&siliconId=${id}`,
|
`${PUBLIC_USER_API_URL}?query=login-from-admin&siliconId=${id}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Loader from "./ui/loader";
|
|||||||
const topicPageDesc = 'Cutting-edge discussions on tech, digital services, news, and digital freedom. Stay informed on AI, cybersecurity, privacy, and the future of innovation.';
|
const topicPageDesc = 'Cutting-edge discussions on tech, digital services, news, and digital freedom. Stay informed on AI, cybersecurity, privacy, and the future of innovation.';
|
||||||
|
|
||||||
export default function TopicCreation(props) {
|
export default function TopicCreation(props) {
|
||||||
|
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||||
const { isLoggedIn, loading: authLoading, error: authError } = useIsLoggedIn();
|
const { isLoggedIn, loading: authLoading, error: authError } = useIsLoggedIn();
|
||||||
const [topics, setTopics] = useState([]);
|
const [topics, setTopics] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -13,7 +14,7 @@ export default function TopicCreation(props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTopics = async () => {
|
const fetchTopics = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://host-api.cs1.hz.siliconpin.com/v1/topics/?query=my-topics', {
|
const res = await fetch(`${PUBLIC_TOPIC_API_URL}?query=my-topics`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { Separator } from './ui/separator';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||||
import { CustomTabs } from './ui/tabs';
|
import { CustomTabs } from './ui/tabs';
|
||||||
|
|
||||||
const TOPIC_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/topics/';
|
const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
|
||||||
const MINIO_UPLOAD_URL = 'https://hostapi2.cs1.hz.siliconpin.com/api/storage/upload';
|
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||||
|
|
||||||
const NewTopic = () => {
|
const NewTopic = () => {
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const NewTopic = () => {
|
|||||||
formData.append('api_key', 'wweifwehfwfhwhtuyegbvijvbfvegfreyf');
|
formData.append('api_key', 'wweifwehfwfhwhtuyegbvijvbfvegfreyf');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(MINIO_UPLOAD_URL, {
|
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -185,7 +185,7 @@ const NewTopic = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Submit to API
|
// Submit to API
|
||||||
const response = await fetch(`${TOPIC_API_URL}?query=create-new-topic`, {
|
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=create-new-topic`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ import { Button } from './ui/button';
|
|||||||
import { Separator } from './ui/separator';
|
import { Separator } from './ui/separator';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||||
import { CustomTabs } from './ui/tabs';
|
import { CustomTabs } from './ui/tabs';
|
||||||
const TOPIC_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/topics/';
|
import Cookies from 'js-cookie';
|
||||||
const MINIO_UPLOAD_URL = 'https://hostapi2.cs1.hz.siliconpin.com/api/storage/upload';
|
|
||||||
|
const PUBLIC_MINIO_UPLOAD_URL = 'https://file-vault-prod-pdgctwgsnkiy.siliconpin.com/upload';
|
||||||
|
const IMAGE_URL_PREFIX = 'https://file-delivery.ndgctwgsnkiy.siliconpin.com/';
|
||||||
|
// const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
|
||||||
|
|
||||||
|
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||||
|
|
||||||
const NewTopic = () => {
|
const NewTopic = () => {
|
||||||
|
|
||||||
@@ -26,26 +31,30 @@ const NewTopic = () => {
|
|||||||
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
||||||
const [editorMode, setEditorMode] = useState('edit');
|
const [editorMode, setEditorMode] = useState('edit');
|
||||||
|
|
||||||
// Upload file to MinIO
|
|
||||||
const uploadToMinIO = async (file, onProgress) => {
|
const uploadToMinIO = async (file, onProgress) => {
|
||||||
|
const siliconId = Cookies.get('siliconId'); // Get from cookie or anywhere
|
||||||
|
const token = Cookies.get('token'); // Get from cookie or storage
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('api_key', 'wweifwehfwfhwhtuyegbvijvbfvegfreyf');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(MINIO_UPLOAD_URL, {
|
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
headers: {
|
||||||
credentials: 'include'
|
'x-user-data': siliconId,
|
||||||
|
'Authorization': token,
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Upload failed');
|
throw new Error(`Upload failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(data.publicUrl)
|
// console.log('Upload successful:', data);
|
||||||
return data.publicUrl;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -77,13 +86,48 @@ const NewTopic = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get all default commands and replace the image command
|
// Custom <br> insert command
|
||||||
const allCommands = commands.getCommands().map(cmd => {
|
const lineBreakCommand = {
|
||||||
|
name: 'linebreak',
|
||||||
|
keyCommand: 'linebreak',
|
||||||
|
buttonProps: { 'aria-label': 'Insert line break' },
|
||||||
|
icon: (
|
||||||
|
<svg width="16px" height="16px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M20 7V8.2C20 9.88016 20 10.7202 19.673 11.362C19.3854 11.9265 18.9265 12.3854 18.362 12.673C17.7202 13 16.8802 13 15.2 13H4M4 13L8 9M4 13L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>
|
||||||
|
),
|
||||||
|
execute: () => {
|
||||||
|
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
|
||||||
|
const value = formData.content || "";
|
||||||
|
const breakTag = '<br>';
|
||||||
|
|
||||||
|
const newValue = value.slice(0, start) + breakTag + value.slice(end);
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: newValue
|
||||||
|
}));
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + breakTag.length;
|
||||||
|
textarea.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all commands (same as NewTopic)
|
||||||
|
const allCommands = [
|
||||||
|
...commands.getCommands().map(cmd => {
|
||||||
if (cmd.name === 'image') {
|
if (cmd.name === 'image') {
|
||||||
return customImageCommand;
|
return customImageCommand;
|
||||||
}
|
}
|
||||||
return cmd;
|
return cmd;
|
||||||
});
|
}),
|
||||||
|
lineBreakCommand,
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
// Handle image URL insertion
|
// Handle image URL insertion
|
||||||
@@ -136,7 +180,7 @@ const NewTopic = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Insert markdown for the uploaded image
|
// Insert markdown for the uploaded image
|
||||||
const imgMarkdown = ``;
|
const imgMarkdown = ``;
|
||||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
const startPos = textarea.selectionStart;
|
const startPos = textarea.selectionStart;
|
||||||
@@ -170,14 +214,14 @@ const NewTopic = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload featured image if selected
|
// Upload featured image if selected
|
||||||
let imageUrl = formData.imageUrl;
|
let imageUrl = formData.imageUrl.url;
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
imageUrl = await uploadToMinIO(imageFile, (progress) => {
|
imageUrl = await uploadToMinIO(imageFile, (progress) => {
|
||||||
console.log('imageUrl', imageUrl)
|
console.log('imageUrl', imageUrl)
|
||||||
setUploadProgress(progress);
|
setUploadProgress(progress);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// let finalImageUrl = imageUrl.publicUrl;
|
imageUrl = imageUrl.url;
|
||||||
// Prepare payload
|
// Prepare payload
|
||||||
const payload = {
|
const payload = {
|
||||||
...formData,
|
...formData,
|
||||||
@@ -185,7 +229,7 @@ const NewTopic = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Submit to API
|
// Submit to API
|
||||||
const response = await fetch(`${TOPIC_API_URL}?query=create-new-topic`, {
|
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=create-new-topic`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -295,6 +339,7 @@ const NewTopic = () => {
|
|||||||
<SelectValue placeholder="Select a category" />
|
<SelectValue placeholder="Select a category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" className="z-50">
|
<SelectContent position="popper" className="z-50">
|
||||||
|
<SelectItem value="uncategorized">Uncategorized</SelectItem>
|
||||||
<SelectItem value="php">PHP Hosting</SelectItem>
|
<SelectItem value="php">PHP Hosting</SelectItem>
|
||||||
<SelectItem value="nodejs">Node.js Hosting</SelectItem>
|
<SelectItem value="nodejs">Node.js Hosting</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -308,6 +353,37 @@ const NewTopic = () => {
|
|||||||
<Input type="text" name="title" value={formData.title} onChange={handleChange} placeholder="Enter a descriptive title" required />
|
<Input type="text" name="title" value={formData.title} onChange={handleChange} placeholder="Enter a descriptive title" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Image Upload */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="image">Featured Image <span className="text-xs text-[#4B5563]">(Hero image)</span></Label>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
id="image"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2.5 rounded-full"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.imageUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={formData.imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-h-40 rounded-md border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* <div className="space-y-2">
|
{/* <div className="space-y-2">
|
||||||
<Label htmlFor="slug">URL Slug</Label>
|
<Label htmlFor="slug">URL Slug</Label>
|
||||||
<Input type="text" name="slug" value={formData.slug} onChange={handleChange} readOnly className="bg-gray-50" />
|
<Input type="text" name="slug" value={formData.slug} onChange={handleChange} readOnly className="bg-gray-50" />
|
||||||
@@ -414,37 +490,6 @@ const NewTopic = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Featured Image Upload */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="image">Featured Image</Label>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
id="image"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
accept="image/*"
|
|
||||||
className="cursor-pointer"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2.5 rounded-full"
|
|
||||||
style={{ width: `${uploadProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.imageUrl && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<img
|
|
||||||
src={formData.imageUrl}
|
|
||||||
alt="Preview"
|
|
||||||
className="max-h-40 rounded-md border"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Form Actions */}
|
{/* Form Actions */}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/
|
|||||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||||
import { Label } from "./ui/label";
|
import { Label } from "./ui/label";
|
||||||
// import type { RecordModel } from 'pocketbase';
|
// import type { RecordModel } from 'pocketbase';
|
||||||
const pb = new PocketBase('https://tst-pb.s38.siliconpin.com');
|
const PUBLIC_POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL;
|
||||||
|
const pb = new PocketBase(PUBLIC_POCKETBASE_URL);
|
||||||
|
|
||||||
interface RecordModel {
|
interface RecordModel {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ export default function HestiaCredentialsFetcher() {
|
|||||||
const [serviceName, setServiceName] = useState(null);
|
const [serviceName, setServiceName] = useState(null);
|
||||||
const [serviceOrderId, setServiceOrderId] = useState(null);
|
const [serviceOrderId, setServiceOrderId] = useState(null);
|
||||||
const [userSiliconId, setUserSiliconId] = useState();
|
const [userSiliconId, setUserSiliconId] = useState();
|
||||||
|
const PUBLIC_SERVICE_API_URL = import.meta.env.PUBLIC_SERVICE_API_URL;
|
||||||
const SERVICES_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/services/';
|
const PUBLIC_HOST_API2_USERS = import.meta.env.PUBLIC_HOST_API2_USERS;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -48,7 +47,7 @@ export default function HestiaCredentialsFetcher() {
|
|||||||
status: "active"
|
status: "active"
|
||||||
}
|
}
|
||||||
// console.log('serviceDataPayload', serviceDataPayload)
|
// console.log('serviceDataPayload', serviceDataPayload)
|
||||||
const response = await fetch(`https://hostapi2.cs1.hz.siliconpin.com/api/users/${siliconId}/${query}`, {
|
const response = await fetch(`${PUBLIC_HOST_API2_USERS}/${siliconId}/${query}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -74,7 +73,7 @@ export default function HestiaCredentialsFetcher() {
|
|||||||
// Use the passed orderIdParam or fall back to state
|
// Use the passed orderIdParam or fall back to state
|
||||||
const effectiveOrderId = orderIdParam || serviceOrderId;
|
const effectiveOrderId = orderIdParam || serviceOrderId;
|
||||||
const query = serviceToCall === 'vpn' ? 'get-vpn-cred' : 'get-hestia-cred';
|
const query = serviceToCall === 'vpn' ? 'get-vpn-cred' : 'get-hestia-cred';
|
||||||
const response = await fetch(`${SERVICES_API_URL}?query=${query}&orderId=${effectiveOrderId}`, {
|
const response = await fetch(`${PUBLIC_SERVICE_API_URL}?query=${query}&orderId=${effectiveOrderId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
@@ -136,8 +135,8 @@ console.log('serviceName', serviceName)
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xl font-bold mb-4 text-neutral-950">VPN Credentials</h2>
|
<h2 className="text-xl font-bold mb-2 text-neutral-950">VPN Credentials</h2>
|
||||||
<p className="text-yellow-600 bg-yellow-50 p-3 rounded-md mb-4 text-sm">⚠️ Make sure to save these credentials in a safe place. We do not store them.</p>
|
<p className="text-neutral-950 mb-4">Install WireGuard (Available on Android / IOS / Linux / Windows / FreeBSD) Scan this QR code using WireGuard app or import the config file.</p>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,6 @@ console.log('serviceName', serviceName)
|
|||||||
{creds.qr_code && (
|
{creds.qr_code && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<img src={`data:image/png;base64,${creds.qr_code}`} alt="VPN Configuration QR Code" className="w-48 h-48 border border-gray-300 rounded" />
|
<img src={`data:image/png;base64,${creds.qr_code}`} alt="VPN Configuration QR Code" className="w-48 h-48 border border-gray-300 rounded" />
|
||||||
<p className="text-sm text-neutral-400 mt-2">Scan this QR code with your WireGuard app</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -162,6 +160,7 @@ console.log('serviceName', serviceName)
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap bg-gray-50 p-3 rounded text-sm overflow-x-auto text-neutral-950">{creds.output}</pre>
|
<pre className="whitespace-pre-wrap bg-gray-50 p-3 rounded text-sm overflow-x-auto text-neutral-950">{creds.output}</pre>
|
||||||
|
<p className="text-yellow-600 bg-yellow-50 p-3 rounded-md my-4 text-sm">⚠️ Make sure to save these credentials in a safe place. We do not store them.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ const SignupPage = () => {
|
|||||||
const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false);
|
const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false);
|
||||||
const [status, setStatus] = useState<AuthStatus>({ message: '', isError: false });
|
const [status, setStatus] = useState<AuthStatus>({ message: '', isError: false });
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
const PUBLIC_POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL;
|
||||||
|
|
||||||
const pb = new PocketBase("https://tst-pb.s38.siliconpin.com");
|
const pb = new PocketBase(PUBLIC_POCKETBASE_URL);
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -135,7 +137,7 @@ const SignupPage = () => {
|
|||||||
|
|
||||||
const syncSessionWithBackend = async (authData: AuthResponse, avatarUrl: string) => {
|
const syncSessionWithBackend = async (authData: AuthResponse, avatarUrl: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://host-api.cs1.hz.siliconpin.com/v1/users/?query=login', {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
144
src/components/TestFileUpload.jsx
Normal file
144
src/components/TestFileUpload.jsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const ImageUploadTest = () => {
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [preview, setPreview] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const selectedFile = e.target.files[0];
|
||||||
|
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
// Check if file is an image
|
||||||
|
if (!selectedFile.type.match('image.*')) {
|
||||||
|
setStatus('Error: Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFile(selectedFile);
|
||||||
|
setStatus('');
|
||||||
|
|
||||||
|
// Create preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setPreview(reader.result);
|
||||||
|
reader.readAsDataURL(selectedFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
setStatus('Error: No file selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setStatus('Uploading...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('https://host-api.cs1.hz.siliconpin.com/v1/check/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Upload failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.text();
|
||||||
|
setStatus(`Success: ${result}`);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Error: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '500px', margin: '20px auto', padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
|
||||||
|
<h2>Image Upload Test</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} method='POST' action={'https://file-vault.ndgctwgsnkiy.siliconpin.com/upload'} encType="multipart/form-data">
|
||||||
|
<div style={{ margin: '20px 0' }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
id="file-upload"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label htmlFor="file-upload" style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '10px 15px',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '1px solid #ccc'
|
||||||
|
}}>
|
||||||
|
Choose Image
|
||||||
|
</label>
|
||||||
|
{file && (
|
||||||
|
<span style={{ marginLeft: '10px' }}>{file.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div style={{ margin: '20px 0' }}>
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: '4px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!file || isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: (!file || isLoading) ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Uploading...' : 'Upload Image'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: status.startsWith('Error') ? '#ffebee' : '#e8f5e9',
|
||||||
|
color: status.startsWith('Error') ? '#c62828' : '#2e7d32'
|
||||||
|
}}>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '20px', fontSize: '0.9em', color: '#666' }}>
|
||||||
|
<p>Note: This component tests uploading to:</p>
|
||||||
|
<code>https://file-vault.ndgctwgsnkiy.siliconpin.com/upload</code>
|
||||||
|
<p>Make sure:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Your Go server is running</li>
|
||||||
|
<li>CORS is properly configured</li>
|
||||||
|
<li>You have the <code>siliconId</code> cookie set</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUploadTest;
|
||||||
@@ -27,7 +27,7 @@ interface Message {
|
|||||||
user_type: string;
|
user_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/ticket/index.php';
|
const PUBLIC_TICKET_API_URL = import.meta.env.PUBLIC_TICKET_API_URL;
|
||||||
|
|
||||||
function Ticketing() {
|
function Ticketing() {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
@@ -47,7 +47,7 @@ function Ticketing() {
|
|||||||
|
|
||||||
const fetchTickets = async () => {
|
const fetchTickets = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}?action=get_tickets&status=${activeTab}`);
|
const response = await fetch(`${PUBLIC_TICKET_API_URL}?action=get_tickets&status=${activeTab}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setTickets(data.tickets);
|
setTickets(data.tickets);
|
||||||
@@ -59,7 +59,7 @@ function Ticketing() {
|
|||||||
|
|
||||||
const fetchMessages = async (ticketId: number) => {
|
const fetchMessages = async (ticketId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}?action=get_messages&ticket_id=${ticketId}`);
|
const response = await fetch(`${PUBLIC_TICKET_API_URL}?action=get_messages&ticket_id=${ticketId}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Messages response:', data); // Debug log
|
console.log('Messages response:', data); // Debug log
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@@ -78,7 +78,7 @@ function Ticketing() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_URL, {
|
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -99,7 +99,7 @@ function Ticketing() {
|
|||||||
|
|
||||||
const handleStatusChange = async (ticketId: number, newStatus: 'open' | 'in-progress' | 'resolved' | 'closed') => {
|
const handleStatusChange = async (ticketId: number, newStatus: 'open' | 'in-progress' | 'resolved' | 'closed') => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_URL, {
|
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -124,7 +124,7 @@ function Ticketing() {
|
|||||||
if (!selectedTicket || !newMessage.trim()) return;
|
if (!selectedTicket || !newMessage.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_URL, {
|
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -146,7 +146,7 @@ function Ticketing() {
|
|||||||
|
|
||||||
const handleDeleteTicket = async (ticketId: number) => {
|
const handleDeleteTicket = async (ticketId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_URL, {
|
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
773
src/components/Tools/AudioToText.jsx
Normal file
773
src/components/Tools/AudioToText.jsx
Normal file
@@ -0,0 +1,773 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
const API_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: 'whisper',
|
||||||
|
name: 'Whisper (GPU)',
|
||||||
|
endpoint: 'https://stt-41.siliconpin.com/stt',
|
||||||
|
description: '2 Req / Min, 10 / Day is free'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vosk',
|
||||||
|
name: 'Vosk (CPU)',
|
||||||
|
endpoint: 'https://api.vosk.ai/stt',
|
||||||
|
description: '10 Req / Min, 100 / Day is free'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_MB = 5;
|
||||||
|
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
export default function AudioUploader() {
|
||||||
|
// State management
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [status, setStatus] = useState('No file uploaded yet');
|
||||||
|
const [response, setResponse] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [selectedApi, setSelectedApi] = useState('whisper');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [debugLogs, setDebugLogs] = useState([]);
|
||||||
|
const [recordingTime, setRecordingTime] = useState(0);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [audioBlob, setAudioBlob] = useState(null);
|
||||||
|
const [audioUrl, setAudioUrl] = useState(null);
|
||||||
|
const [showDebug, setShowDebug] = useState(false);
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const mediaRecorderRef = useRef(null);
|
||||||
|
const audioChunksRef = useRef([]);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const audioContextRef = useRef(null);
|
||||||
|
const analyserRef = useRef(null);
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const animationRef = useRef(null);
|
||||||
|
const streamRef = useRef(null);
|
||||||
|
|
||||||
|
// Debug logging with useCallback to prevent infinite loops
|
||||||
|
const addDebugLog = useCallback((message) => {
|
||||||
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
||||||
|
const logMessage = `${timestamp}: ${message}`;
|
||||||
|
setDebugLogs(prev => [...prev.slice(-100), logMessage]);
|
||||||
|
console.debug(logMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Timer effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRecording) {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setRecordingTime(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
// Clean up on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
addDebugLog('Component unmounting - cleaning up resources');
|
||||||
|
stopRecording();
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
if (audioContextRef.current?.state !== 'closed') {
|
||||||
|
audioContextRef.current?.close().catch(err => {
|
||||||
|
addDebugLog(`Error closing AudioContext: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [addDebugLog]);
|
||||||
|
|
||||||
|
// Handle file change - completely stable implementation
|
||||||
|
const handleFileChange = useCallback((e) => {
|
||||||
|
const selectedFile = e.target.files[0];
|
||||||
|
if (!selectedFile) {
|
||||||
|
setFile(null);
|
||||||
|
setStatus('No file selected');
|
||||||
|
addDebugLog('No file selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/webm'].includes(selectedFile.type) &&
|
||||||
|
!selectedFile.name.match(/\.(wav|mp3|ogg|webm)$/i)) {
|
||||||
|
const errorMsg = 'Unsupported file format. Please use WAV, MP3, or OGG';
|
||||||
|
setError(errorMsg);
|
||||||
|
setStatus('Invalid file type');
|
||||||
|
setFile(null);
|
||||||
|
e.target.value = '';
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFile.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
const errorMsg = `File size exceeds ${MAX_FILE_SIZE_MB}MB limit`;
|
||||||
|
setError(errorMsg);
|
||||||
|
setStatus('File too large');
|
||||||
|
setFile(null);
|
||||||
|
e.target.value = '';
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFile(selectedFile);
|
||||||
|
setStatus(`File selected: ${selectedFile.name}`);
|
||||||
|
setResponse(null);
|
||||||
|
setError(null);
|
||||||
|
setAudioBlob(null);
|
||||||
|
setAudioUrl(null);
|
||||||
|
addDebugLog(`File selected: ${selectedFile.name} (${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB)`);
|
||||||
|
}, [addDebugLog]);
|
||||||
|
|
||||||
|
// Create WAV blob - stable implementation
|
||||||
|
const createWavBlob = useCallback(async (audioBlob) => {
|
||||||
|
try {
|
||||||
|
addDebugLog('Starting WAV blob creation');
|
||||||
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||||
|
sampleRate: 16000
|
||||||
|
});
|
||||||
|
|
||||||
|
const decodedData = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
addDebugLog(`Decoded audio data: ${decodedData.length} samples, ${decodedData.numberOfChannels} channels`);
|
||||||
|
|
||||||
|
let audioData;
|
||||||
|
if (decodedData.numberOfChannels > 1) {
|
||||||
|
audioData = new Float32Array(decodedData.length);
|
||||||
|
for (let i = 0; i < decodedData.length; i++) {
|
||||||
|
audioData[i] = (decodedData.getChannelData(0)[i] + decodedData.getChannelData(1)[i]) / 2;
|
||||||
|
}
|
||||||
|
addDebugLog('Converted stereo to mono');
|
||||||
|
} else {
|
||||||
|
audioData = decodedData.getChannelData(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pcmData = new Int16Array(audioData.length);
|
||||||
|
for (let i = 0; i < audioData.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, audioData[i]));
|
||||||
|
pcmData[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||||
|
}
|
||||||
|
addDebugLog(`Converted to 16-bit PCM: ${pcmData.length} samples`);
|
||||||
|
|
||||||
|
const wavHeader = createWaveHeader(pcmData.length * 2, {
|
||||||
|
sampleRate: 16000,
|
||||||
|
numChannels: 1,
|
||||||
|
bitDepth: 16
|
||||||
|
});
|
||||||
|
|
||||||
|
const wavBlob = new Blob([wavHeader, pcmData], { type: 'audio/wav' });
|
||||||
|
addDebugLog(`Created WAV blob: ${(wavBlob.size / 1024).toFixed(2)} KB`);
|
||||||
|
return wavBlob;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = `Error creating WAV blob: ${err.message}`;
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
throw new Error('Failed to process audio recording');
|
||||||
|
}
|
||||||
|
}, [addDebugLog]);
|
||||||
|
|
||||||
|
// Handle submit - stable implementation
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
let fileToSubmit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const apiName = API_OPTIONS.find(api => api.id === selectedApi)?.name;
|
||||||
|
setStatus(`Processing with ${apiName}...`);
|
||||||
|
setError(null);
|
||||||
|
addDebugLog(`Starting submission with ${apiName}`);
|
||||||
|
|
||||||
|
if (audioBlob) {
|
||||||
|
addDebugLog('Processing recorded audio blob');
|
||||||
|
fileToSubmit = await createWavBlob(audioBlob);
|
||||||
|
} else if (file) {
|
||||||
|
addDebugLog('Processing uploaded file');
|
||||||
|
fileToSubmit = file;
|
||||||
|
} else {
|
||||||
|
const errorMsg = 'No audio file selected';
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileToSubmit.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
const errorMsg = `File size exceeds ${MAX_FILE_SIZE_MB}MB limit`;
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('audio', fileToSubmit, 'audio.wav');
|
||||||
|
addDebugLog(`Created FormData with ${(fileToSubmit.size / 1024).toFixed(2)} KB file`);
|
||||||
|
|
||||||
|
const apiConfig = API_OPTIONS.find(api => api.id === selectedApi);
|
||||||
|
if (!apiConfig) {
|
||||||
|
const errorMsg = 'Selected API not found';
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
addDebugLog(`Sending request to ${apiConfig.endpoint}`);
|
||||||
|
const apiResponse = await fetch(apiConfig.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
let errorMessage = `API returned error status: ${apiResponse.status}`;
|
||||||
|
try {
|
||||||
|
const errorData = await apiResponse.json();
|
||||||
|
errorMessage = errorData.message || errorData.error || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
addDebugLog('Failed to parse error response');
|
||||||
|
}
|
||||||
|
addDebugLog(`API error: ${errorMessage}`);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiResponse.json();
|
||||||
|
addDebugLog('Received successful response from API');
|
||||||
|
setResponse({
|
||||||
|
api: selectedApi,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
setStatus('Processing complete');
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err.message.includes('Failed to fetch')
|
||||||
|
? 'Network error: Could not connect to the API server'
|
||||||
|
: err.message;
|
||||||
|
addDebugLog(`Error during submission: ${errorMsg}`);
|
||||||
|
setError(errorMsg);
|
||||||
|
setStatus('Processing failed');
|
||||||
|
setResponse(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
addDebugLog('Submission process completed');
|
||||||
|
}
|
||||||
|
}, [selectedApi, audioBlob, file, createWavBlob, addDebugLog]);
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const createWaveHeader = useCallback((dataLength, config) => {
|
||||||
|
const byteRate = config.sampleRate * config.numChannels * (config.bitDepth / 8);
|
||||||
|
const blockAlign = config.numChannels * (config.bitDepth / 8);
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(44);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
view.setUint32(4, 36 + dataLength, true);
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, config.numChannels, true);
|
||||||
|
view.setUint32(24, config.sampleRate, true);
|
||||||
|
view.setUint32(28, byteRate, true);
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
view.setUint16(34, config.bitDepth, true);
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
view.setUint32(40, dataLength, true);
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const writeString = useCallback((view, offset, string) => {
|
||||||
|
for (let i = 0; i < string.length; i++) {
|
||||||
|
view.setUint8(offset + i, string.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback((text) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
addDebugLog('Text copied to clipboard');
|
||||||
|
}).catch(err => {
|
||||||
|
const errorMsg = 'Failed to copy text to clipboard';
|
||||||
|
addDebugLog(`${errorMsg}: ${err.message}`);
|
||||||
|
setError(errorMsg);
|
||||||
|
});
|
||||||
|
}, [addDebugLog]);
|
||||||
|
|
||||||
|
// Fixed getDisplayText to prevent infinite loops
|
||||||
|
const displayText = useMemo(() => {
|
||||||
|
if (!response?.data) {
|
||||||
|
addDebugLog('No response data to display');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.data === 'string') {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
if (response.data.text) {
|
||||||
|
return response.data.text;
|
||||||
|
}
|
||||||
|
if (response.data.transcript) {
|
||||||
|
return response.data.transcript;
|
||||||
|
}
|
||||||
|
if (response.data.results?.[0]?.alternatives?.[0]?.transcript) {
|
||||||
|
return response.data.results[0].alternatives[0].transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Received response but couldn't extract text. View full response for details.";
|
||||||
|
}, [response, addDebugLog]);
|
||||||
|
|
||||||
|
// Recording functions
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
addDebugLog('Attempting to start recording');
|
||||||
|
setStatus("Requesting microphone access...");
|
||||||
|
|
||||||
|
if (audioBlob) {
|
||||||
|
addDebugLog('Clearing previous recording');
|
||||||
|
setAudioBlob(null);
|
||||||
|
setAudioUrl(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize audio context
|
||||||
|
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)({
|
||||||
|
sampleRate: 16000
|
||||||
|
});
|
||||||
|
addDebugLog(`AudioContext created with sample rate: ${audioContextRef.current.sampleRate}`);
|
||||||
|
|
||||||
|
// Get user media
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
addDebugLog('Microphone access granted, stream created');
|
||||||
|
|
||||||
|
// Setup visualization
|
||||||
|
setupVisualizer(stream);
|
||||||
|
|
||||||
|
// Initialize MediaRecorder
|
||||||
|
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||||
|
audioChunksRef.current = [];
|
||||||
|
|
||||||
|
mediaRecorderRef.current.ondataavailable = (e) => {
|
||||||
|
audioChunksRef.current.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorderRef.current.onstop = () => {
|
||||||
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||||
|
setAudioBlob(audioBlob);
|
||||||
|
setAudioUrl(URL.createObjectURL(audioBlob));
|
||||||
|
setStatus("Recording stopped. Ready to process.");
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorderRef.current.start(100); // Collect data every 100ms
|
||||||
|
setIsRecording(true);
|
||||||
|
setStatus("Recording (16kHz, 16-bit mono)...");
|
||||||
|
addDebugLog('Recording started');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = `Error starting recording: ${err.message}`;
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
setError(errorMsg);
|
||||||
|
setStatus("Recording failed");
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
}, [audioBlob, addDebugLog]);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
addDebugLog('Stop recording initiated');
|
||||||
|
if (!isRecording) {
|
||||||
|
addDebugLog('Not currently recording, ignoring stop request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRecording(false);
|
||||||
|
addDebugLog('Recording state updated to false');
|
||||||
|
|
||||||
|
if (mediaRecorderRef.current?.state === 'recording') {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(track => {
|
||||||
|
track.stop();
|
||||||
|
addDebugLog(`Stopped track: ${track.kind}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
animationRef.current = null;
|
||||||
|
addDebugLog('Visualization animation stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
addDebugLog('Recording successfully stopped');
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = `Error stopping recording: ${err.message}`;
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
setError(errorMsg);
|
||||||
|
setStatus("Recording stop failed");
|
||||||
|
}
|
||||||
|
}, [isRecording, addDebugLog]);
|
||||||
|
|
||||||
|
const playRecording = useCallback(() => {
|
||||||
|
if (audioUrl) {
|
||||||
|
addDebugLog('Playing recording');
|
||||||
|
const audio = new Audio(audioUrl);
|
||||||
|
audio.play();
|
||||||
|
setStatus("Playing recording...");
|
||||||
|
|
||||||
|
audio.onended = () => {
|
||||||
|
addDebugLog('Playback finished');
|
||||||
|
setStatus("Playback finished");
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
addDebugLog('No audio URL available for playback');
|
||||||
|
}
|
||||||
|
}, [audioUrl, addDebugLog]);
|
||||||
|
|
||||||
|
const setupVisualizer = useCallback((stream) => {
|
||||||
|
if (!audioContextRef.current) {
|
||||||
|
addDebugLog('AudioContext not available for visualization');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = audioContextRef.current.createMediaStreamSource(stream);
|
||||||
|
analyserRef.current = audioContextRef.current.createAnalyser();
|
||||||
|
analyserRef.current.fftSize = 64;
|
||||||
|
source.connect(analyserRef.current);
|
||||||
|
addDebugLog('Visualizer audio nodes connected');
|
||||||
|
|
||||||
|
const bufferLength = analyserRef.current.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
animationRef.current = requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
analyserRef.current.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
const barHeight = dataArray[i] / 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = `rgb(${barHeight + 100}, 50, 50)`;
|
||||||
|
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
|
||||||
|
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
addDebugLog('Visualizer animation started');
|
||||||
|
} catch (err) {
|
||||||
|
addDebugLog(`Error setting up visualizer: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, [addDebugLog]);
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const formatTime = useCallback((seconds) => {
|
||||||
|
const mins = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
|
const secs = (seconds % 60).toString().padStart(2, '0');
|
||||||
|
return `${mins}:${secs}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearDebugLogs = useCallback(() => {
|
||||||
|
setDebugLogs([]);
|
||||||
|
addDebugLog('Debug logs cleared');
|
||||||
|
}, [addDebugLog]);
|
||||||
|
|
||||||
|
const toggleDebug = useCallback(() => {
|
||||||
|
setShowDebug(!showDebug);
|
||||||
|
addDebugLog(`Debug panel ${showDebug ? 'hidden' : 'shown'}`);
|
||||||
|
}, [showDebug, addDebugLog]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 max-w-4xl my-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">Speech to Text Converter</h1>
|
||||||
|
<Button
|
||||||
|
onClick={toggleDebug}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{showDebug ? 'Hide Debug' : 'Show Debug'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recording Section */}
|
||||||
|
<div className="mb-8 p-4 border border-dashed border-[#6d9e37] rounded-lg bg-gray-50">
|
||||||
|
<h2 className="text-lg font-semibold mb-3 text-gray-700">Record Audio (16kHz, 16-bit mono)</h2>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full h-20 bg-gray-200 rounded mb-3"
|
||||||
|
style={{ display: isRecording ? 'block' : 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
<Button
|
||||||
|
onClick={startRecording}
|
||||||
|
disabled={isRecording || isLoading}
|
||||||
|
variant="outline"
|
||||||
|
className="px-4 py-2"
|
||||||
|
>
|
||||||
|
Start Recording
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={stopRecording}
|
||||||
|
disabled={!isRecording || isLoading}
|
||||||
|
variant="outline"
|
||||||
|
className="px-4 py-2"
|
||||||
|
>
|
||||||
|
Stop Recording
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={playRecording}
|
||||||
|
disabled={!audioUrl || isRecording || isLoading}
|
||||||
|
variant="outline"
|
||||||
|
className="px-4 py-2"
|
||||||
|
>
|
||||||
|
Play Recording
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<div className="text-center text-[#6d9e37] font-medium">
|
||||||
|
Recording: {formatTime(recordingTime)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{audioUrl && !isRecording && (
|
||||||
|
<div className="flex items-center justify-between bg-white p-3 rounded border mt-2">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 text-[#6d9e37] mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-[#6d9e37]">recording.wav</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[#6d9e37]">
|
||||||
|
{(audioBlob?.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center w-full mb-3 -mt-2">
|
||||||
|
<span className="flex-grow border-b-2 border-[#6d9e37]"></span>
|
||||||
|
<h2 className="mx-2 text-[#6d9e37] font-bold">OR</h2>
|
||||||
|
<span className="flex-grow border-b-2 border-[#6d9e37]"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Upload Section */}
|
||||||
|
<div className="mb-8 p-4 border border-dashed border-[#6d9e37] rounded-lg bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Upload Audio File
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500">Supports WAV, MP3, OGG formats (max {MAX_FILE_SIZE_MB}MB)</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
variant="outline"
|
||||||
|
className="px-4 py-2"
|
||||||
|
disabled={isLoading || isRecording}
|
||||||
|
>
|
||||||
|
Select File
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="audio/*,.wav,.mp3,.ogg"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isLoading || isRecording}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file && (
|
||||||
|
<div className="flex items-center justify-between bg-white p-3 rounded border">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 text-[#6d9e37] mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-[#6d9e37]">{file.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[#6d9e37]">
|
||||||
|
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Selection Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold mb-3 text-gray-700">Select Speech Recognition API</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{API_OPTIONS.map(api => (
|
||||||
|
<div
|
||||||
|
key={api.id}
|
||||||
|
onClick={() => !isLoading && !isRecording && setSelectedApi(api.id)}
|
||||||
|
className={`p-4 border-[1.5px] rounded-lg cursor-pointer transition-all ${
|
||||||
|
selectedApi === api.id
|
||||||
|
? 'border-[1.5px] border-[#6d9e37] bg-[#6d9e3720]'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
} ${isLoading || isRecording ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={selectedApi === api.id}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="mr-2 h-4 w-4 accent-[#6d9e37]"
|
||||||
|
disabled={isLoading || isRecording}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-[#6d9e37]">{api.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{api.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={(!file && !audioBlob) || isLoading || isRecording ||
|
||||||
|
(file && file.size > MAX_FILE_SIZE_BYTES)}
|
||||||
|
className="px-6 py-3 text-lg w-full md:w-auto"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
`Convert with ${API_OPTIONS.find(api => api.id === selectedApi)?.name}`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<p className={`text-sm ${
|
||||||
|
error ? 'text-red-600' :
|
||||||
|
isLoading ? 'text-[#6d9e37]' :
|
||||||
|
isRecording ? 'text-[#6d9e37]' :
|
||||||
|
'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<div className="flex items-center text-red-600">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="font-medium">Error</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||||
|
{error.includes(`${MAX_FILE_SIZE_MB}MB`) && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">
|
||||||
|
Please select a smaller audio file or record a shorter audio
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Display */}
|
||||||
|
{response && (
|
||||||
|
<div className="mt-6 border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
||||||
|
<h3 className="font-medium text-[#6d9e37]">{API_OPTIONS.find(api => api.id === response.api)?.name} Results</h3>
|
||||||
|
{displayText && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(displayText)}
|
||||||
|
className="text-sm">
|
||||||
|
{copied ? (<span className="flex items-center">Copied!</span>) : 'Copy Text'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-white">
|
||||||
|
{displayText ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">Transcription:</h4>
|
||||||
|
<div className="relative">
|
||||||
|
<p className="p-3 bg-gray-50 rounded text-sm text-gray-600 font-medium">{displayText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
No text transcription found in the response.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Debug Section */}
|
||||||
|
{showDebug && (
|
||||||
|
<div className="mt-8 border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
||||||
|
<h3 className="font-medium text-gray-700">Debug Logs</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={clearDebugLogs}
|
||||||
|
className="text-sm">
|
||||||
|
Clear Logs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const blob = new Blob([debugLogs.join('\n')], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `audio-recorder-debug-${new Date().toISOString()}.log`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
className="text-sm">
|
||||||
|
Export Logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white max-h-60 overflow-y-auto">
|
||||||
|
{debugLogs.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{debugLogs.map((log, index) => (
|
||||||
|
<div key={index} className="text-xs font-mono text-gray-600 border-b border-gray-100 pb-1">
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">No debug logs yet. Interactions will appear here.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
445
src/components/Tools/FreeTextToSpeech.jsx
Normal file
445
src/components/Tools/FreeTextToSpeech.jsx
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
const FreeTextToSpeech = () => {
|
||||||
|
// State management
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [voices, setVoices] = useState([]);
|
||||||
|
const [selectedVoice, setSelectedVoice] = useState('');
|
||||||
|
const [pitch, setPitch] = useState(1);
|
||||||
|
const [rate, setRate] = useState(1);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [status, setStatus] = useState('Enter text to convert to speech');
|
||||||
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [debugLogs, setDebugLogs] = useState([]);
|
||||||
|
const [showDebug, setShowDebug] = useState(false);
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const synthRef = useRef(null);
|
||||||
|
const utteranceRef = useRef(null);
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
const addDebugLog = (message) => {
|
||||||
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
||||||
|
const logMessage = `${timestamp}: ${message}`;
|
||||||
|
setDebugLogs(prev => [...prev.slice(-100), logMessage]);
|
||||||
|
console.debug(logMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize speech synthesis
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
synthRef.current = window.speechSynthesis;
|
||||||
|
|
||||||
|
const loadVoices = () => {
|
||||||
|
const availableVoices = synthRef.current.getVoices();
|
||||||
|
setVoices(availableVoices);
|
||||||
|
if (availableVoices.length > 0) {
|
||||||
|
setSelectedVoice(availableVoices[0].name);
|
||||||
|
}
|
||||||
|
addDebugLog(`Loaded ${availableVoices.length} voices`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chrome loads voices asynchronously
|
||||||
|
synthRef.current.onvoiceschanged = loadVoices;
|
||||||
|
loadVoices();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (synthRef.current) {
|
||||||
|
synthRef.current.onvoiceschanged = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle speaking
|
||||||
|
const handleSpeak = () => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
setError('Please enter some text to speak');
|
||||||
|
setStatus('Please enter some text to speak');
|
||||||
|
addDebugLog('No text provided for speech');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!synthRef.current) {
|
||||||
|
setError('Speech synthesis not supported in this browser');
|
||||||
|
setStatus('Speech synthesis not supported');
|
||||||
|
addDebugLog('Speech synthesis API not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPaused) {
|
||||||
|
synthRef.current.resume();
|
||||||
|
setIsPaused(false);
|
||||||
|
setIsSpeaking(true);
|
||||||
|
setStatus('Resumed speaking');
|
||||||
|
addDebugLog('Resumed speech playback');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any current speech
|
||||||
|
synthRef.current.cancel();
|
||||||
|
|
||||||
|
// Create a new utterance
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
utteranceRef.current = utterance;
|
||||||
|
|
||||||
|
// Set utterance properties
|
||||||
|
const selectedVoiceObj = voices.find(v => v.name === selectedVoice);
|
||||||
|
if (selectedVoiceObj) {
|
||||||
|
utterance.voice = selectedVoiceObj;
|
||||||
|
addDebugLog(`Using voice: ${selectedVoiceObj.name} (${selectedVoiceObj.lang})`);
|
||||||
|
}
|
||||||
|
utterance.pitch = pitch;
|
||||||
|
utterance.rate = rate;
|
||||||
|
utterance.volume = volume;
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
utterance.onstart = () => {
|
||||||
|
setIsSpeaking(true);
|
||||||
|
setIsPaused(false);
|
||||||
|
setStatus('Speaking...');
|
||||||
|
addDebugLog('Speech started');
|
||||||
|
};
|
||||||
|
|
||||||
|
utterance.onend = () => {
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
setStatus('Finished speaking');
|
||||||
|
addDebugLog('Speech completed');
|
||||||
|
};
|
||||||
|
|
||||||
|
utterance.onerror = (event) => {
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
setStatus(`Error occurred: ${event.error}`);
|
||||||
|
addDebugLog(`Speech error: ${event.error}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Speak the utterance
|
||||||
|
synthRef.current.speak(utterance);
|
||||||
|
setStatus('Started speaking...');
|
||||||
|
addDebugLog('Initiated speech synthesis');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
if (isSpeaking && !isPaused && synthRef.current) {
|
||||||
|
synthRef.current.pause();
|
||||||
|
setIsPaused(true);
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setStatus('Paused');
|
||||||
|
addDebugLog('Speech paused');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
if (synthRef.current) {
|
||||||
|
synthRef.current.cancel();
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
setStatus('Stopped speaking');
|
||||||
|
addDebugLog('Speech stopped');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
setStatus('Download requires a TTS API service');
|
||||||
|
setError('Download functionality requires a TTS API service');
|
||||||
|
addDebugLog('Download attempted (not implemented)');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy text to clipboard
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
addDebugLog('Text copied to clipboard');
|
||||||
|
}).catch(err => {
|
||||||
|
const errorMsg = 'Failed to copy text to clipboard';
|
||||||
|
addDebugLog(`${errorMsg}: ${err.message}`);
|
||||||
|
setError(errorMsg);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear all inputs and states
|
||||||
|
const clearAll = () => {
|
||||||
|
setText('');
|
||||||
|
setError(null);
|
||||||
|
setStatus('Enter text to convert to speech');
|
||||||
|
|
||||||
|
if (synthRef.current) {
|
||||||
|
synthRef.current.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
|
||||||
|
addDebugLog('All inputs and states cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const clearDebugLogs = () => {
|
||||||
|
setDebugLogs([]);
|
||||||
|
addDebugLog('Debug logs cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDebug = () => {
|
||||||
|
setShowDebug(!showDebug);
|
||||||
|
addDebugLog(`Debug panel ${showDebug ? 'hidden' : 'shown'}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 max-w-4xl my-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">Text to Speech (Using Google)</h1>
|
||||||
|
<Button
|
||||||
|
onClick={toggleDebug}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{showDebug ? 'Hide Debug' : 'Show Debug'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Input Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<label htmlFor="tts-text" className="block text-sm font-medium text-gray-700">
|
||||||
|
Enter Text
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
disabled={!text || isSpeaking}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy Text'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAll}
|
||||||
|
disabled={isSpeaking}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
id="tts-text"
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:border-[#6d9e37] bg-white text-gray-800"
|
||||||
|
placeholder="Type or paste your text here..."
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
disabled={isSpeaking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="mb-6 p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Voice Selection */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="voice-select" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Select Voice
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="voice-select"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:border-[#6d9e37]"
|
||||||
|
value={selectedVoice}
|
||||||
|
onChange={(e) => setSelectedVoice(e.target.value)}
|
||||||
|
disabled={isSpeaking}
|
||||||
|
>
|
||||||
|
{voices.length > 0 ? (
|
||||||
|
voices.map((voice) => (
|
||||||
|
<option key={voice.name} value={voice.name}>
|
||||||
|
{voice.name} ({voice.lang})
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="">Loading voices...</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pitch Control */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pitch" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Pitch: {pitch.toFixed(1)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="pitch"
|
||||||
|
min="0.5"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
value={pitch}
|
||||||
|
onChange={(e) => setPitch(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
||||||
|
disabled={isSpeaking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rate Control */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Speed: {rate.toFixed(1)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="rate"
|
||||||
|
min="0.5"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
value={rate}
|
||||||
|
onChange={(e) => setRate(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
||||||
|
disabled={isSpeaking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume Control */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="volume" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Volume: {volume.toFixed(1)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="volume"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
||||||
|
disabled={isSpeaking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-6">
|
||||||
|
<Button
|
||||||
|
onClick={handleSpeak}
|
||||||
|
disabled={!text.trim() || (isSpeaking && !isPaused)}
|
||||||
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||||
|
>
|
||||||
|
{isPaused ? 'Resume' : 'Speak'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handlePause}
|
||||||
|
disabled={!isSpeaking || isPaused}
|
||||||
|
variant="outline"
|
||||||
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={!isSpeaking && !isPaused}
|
||||||
|
variant="outline"
|
||||||
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
variant="outline"
|
||||||
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||||
|
>
|
||||||
|
Download Audio
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<p className={`text-sm ${
|
||||||
|
error ? 'text-red-600' :
|
||||||
|
isSpeaking ? 'text-[#6d9e37]' :
|
||||||
|
isPaused ? 'text-amber-600' :
|
||||||
|
'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<div className="flex items-center text-red-600">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="font-medium">Error</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Debug Section */}
|
||||||
|
{showDebug && (
|
||||||
|
<div className="mt-8 border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
||||||
|
<h3 className="font-medium text-gray-700">Debug Logs</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={clearDebugLogs}
|
||||||
|
className="text-sm">
|
||||||
|
Clear Logs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const blob = new Blob([debugLogs.join('\n')], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tts-debug-${new Date().toISOString()}.log`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
className="text-sm">
|
||||||
|
Export Logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white max-h-60 overflow-y-auto">
|
||||||
|
{debugLogs.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{debugLogs.map((log, index) => (
|
||||||
|
<div key={index} className="text-xs font-mono text-gray-800 border-b border-gray-100 pb-1">
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">No debug logs yet. Interactions will appear here.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FreeTextToSpeech;
|
||||||
295
src/components/Tools/ImageLabeling.jsx
Normal file
295
src/components/Tools/ImageLabeling.jsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [images, setImages] = useState(Array(9).fill({ url: null, secretKey: null }));
|
||||||
|
const [loading, setLoading] = useState(Array(9).fill(true));
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
|
|
||||||
|
// Fetch initial images with staggered loading
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInitialImages = async () => {
|
||||||
|
try {
|
||||||
|
const newImages = [];
|
||||||
|
const newLoading = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
const imageData = await fetchImage();
|
||||||
|
setImages(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[i] = imageData;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setLoading(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[i] = false;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, i * 150); // Staggered loading effect
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(Array(9).fill(false));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchInitialImages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchImage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api-img-lbeling-jugtfrvbghysvfr.siliconpin.com/get-image');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch image');
|
||||||
|
return await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching image:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitScore = async (imageData, score, index) => {
|
||||||
|
try {
|
||||||
|
setLoading(prev => {
|
||||||
|
const newLoading = [...prev];
|
||||||
|
newLoading[index] = true;
|
||||||
|
return newLoading;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = imageData.url.split('/').pop();
|
||||||
|
const response = await fetch('https://api-img-lbeling-jugtfrvbghysvfr.siliconpin.com/save-score', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: fileName,
|
||||||
|
score: score,
|
||||||
|
secretKey: imageData.secretKey
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to submit score');
|
||||||
|
|
||||||
|
// Animate out the current image
|
||||||
|
setImages(prev => {
|
||||||
|
const newImages = [...prev];
|
||||||
|
newImages[index] = { ...newImages[index], exiting: true };
|
||||||
|
return newImages;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for animation to complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Get new image and animate it in
|
||||||
|
const newImage = await fetchImage();
|
||||||
|
setImages(prev => {
|
||||||
|
const newImages = [...prev];
|
||||||
|
newImages[index] = { ...newImage, entering: true };
|
||||||
|
return newImages;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove animation flags after entrance
|
||||||
|
setTimeout(() => {
|
||||||
|
setImages(prev => {
|
||||||
|
const newImages = [...prev];
|
||||||
|
newImages[index] = { ...newImages[index], entering: false };
|
||||||
|
return newImages;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error submitting score:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(prev => {
|
||||||
|
const newLoading = [...prev];
|
||||||
|
newLoading[index] = false;
|
||||||
|
return newLoading;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStarButtons = (imageData, index) => {
|
||||||
|
if (!imageData.url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="flex justify-center gap-2 mt-4"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<motion.button
|
||||||
|
key={star}
|
||||||
|
onClick={() => submitScore(imageData, star, index)}
|
||||||
|
disabled={loading[index]}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className={`
|
||||||
|
px-4 py-2 rounded-full border-2 font-medium text-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
${loading[index] ?
|
||||||
|
'bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed' :
|
||||||
|
`bg-white ${star >= 3 ?
|
||||||
|
'border-emerald-300 text-emerald-600 hover:bg-emerald-50' :
|
||||||
|
'border-amber-300 text-amber-600 hover:bg-amber-50'}`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{star} ★
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-blue-50 to-purple-50"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="max-w-md p-8 bg-white rounded-2xl shadow-xl text-center"
|
||||||
|
initial={{ scale: 0.9 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
>
|
||||||
|
<div className="text-red-500 text-2xl font-bold mb-4">Oops!</div>
|
||||||
|
<div className="text-gray-600 mb-6">{error}</div>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl hover:shadow-md transition-all"
|
||||||
|
whileHover={{ scale: 1.03 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen py-12 px-4 sm:px-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl font-bold mb-3 text-[#6d9e37]">Image Rating App</h1>
|
||||||
|
<p className="text-lg max-w-2xl mx-auto">Rate these beautiful images from 1 to 5 stars. Your feedback helps improve our collection!</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{images.map((imageData, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{
|
||||||
|
opacity: loading[index] && !imageData.url ? 0.7 : 1,
|
||||||
|
y: 0
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 15,
|
||||||
|
delay: index * 0.05
|
||||||
|
}}
|
||||||
|
className={`relative bg-white rounded-2xl shadow-lg overflow-hidden
|
||||||
|
transition-all hover:shadow-xl ${loading[index] ? 'animate-pulse' : ''}`}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{imageData.url ? (
|
||||||
|
<motion.div
|
||||||
|
key={`image-${index}`}
|
||||||
|
initial={{ opacity: imageData.exiting ? 1 : 0, scale: imageData.exiting ? 1 : 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="h-72 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-4 relative">
|
||||||
|
<motion.img
|
||||||
|
src={imageData.url}
|
||||||
|
alt={`Image ${index}`}
|
||||||
|
className="max-h-full max-w-full object-contain cursor-pointer"
|
||||||
|
onClick={() => setSelectedImage(imageData.url)}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.onerror = null;
|
||||||
|
e.target.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2QxZDFkMSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0zIDNoMTh2MThIM3oiLz48cGF0aCBkPSJNMTkgOUw5IDE5Ii8+PHBhdGggZD0iTTkgOWwxMCAxMCIvPjwvc3ZnPg==';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{renderStarButtons(imageData, index)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key={`skeleton-${index}`}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.7 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="h-72 bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="text-gray-400">Loading image...</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{loading[index] && imageData.url && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-white bg-opacity-80 flex items-center justify-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
|
||||||
|
className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedImage && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
exit={{ scale: 0.9 }}
|
||||||
|
className="relative max-w-4xl w-full"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute -top-10 right-0 text-white text-3xl hover:text-gray-300 transition"
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={selectedImage}
|
||||||
|
alt="Enlarged view"
|
||||||
|
className="max-h-[80vh] w-full object-contain"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
375
src/components/Tools/TextToSpeech.jsx
Normal file
375
src/components/Tools/TextToSpeech.jsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
const TTS_API_ENDPOINT = 'https://tts41-nhdtuisbdhcvdth.siliconpin.com/tts';
|
||||||
|
|
||||||
|
export default function TextToSpeech() {
|
||||||
|
// State management
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [audioUrl, setAudioUrl] = useState(null);
|
||||||
|
const [status, setStatus] = useState('Enter text to convert to speech');
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [debugLogs, setDebugLogs] = useState([]);
|
||||||
|
const [showDebug, setShowDebug] = useState(false);
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
const addDebugLog = useCallback((message) => {
|
||||||
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
||||||
|
const logMessage = `${timestamp}: ${message}`;
|
||||||
|
setDebugLogs(prev => [...prev.slice(-100), logMessage]);
|
||||||
|
console.debug(logMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clean up audio URL on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
addDebugLog('Component unmounting - revoking audio URL');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [audioUrl, addDebugLog]);
|
||||||
|
|
||||||
|
// Handle text to speech conversion
|
||||||
|
const convertToSpeech = useCallback(async () => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
setError('Please enter some text to convert');
|
||||||
|
addDebugLog('No text provided for conversion');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setStatus('Converting text to speech...');
|
||||||
|
setError(null);
|
||||||
|
addDebugLog(`Starting TTS conversion for text: "${text.substring(0, 20)}..."`);
|
||||||
|
|
||||||
|
const response = await fetch(TTS_API_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: text.trim() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMsg = `API returned error status: ${response.status}`;
|
||||||
|
addDebugLog(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBlob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(audioBlob);
|
||||||
|
|
||||||
|
setAudioUrl(url);
|
||||||
|
setStatus('Conversion complete. Ready to play or download.');
|
||||||
|
addDebugLog(`TTS conversion successful. Audio blob size: ${(audioBlob.size / 1024).toFixed(2)} KB`);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err.message.includes('Failed to fetch')
|
||||||
|
? 'Network error: Could not connect to the TTS server'
|
||||||
|
: err.message;
|
||||||
|
addDebugLog(`Error during TTS conversion: ${errorMsg}`);
|
||||||
|
setError(errorMsg);
|
||||||
|
setStatus('Conversion failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
addDebugLog('TTS conversion process completed');
|
||||||
|
}
|
||||||
|
}, [text, addDebugLog]);
|
||||||
|
|
||||||
|
// Handle audio download
|
||||||
|
const downloadAudio = useCallback(() => {
|
||||||
|
if (!audioUrl) return;
|
||||||
|
|
||||||
|
addDebugLog('Initiating audio download');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = audioUrl;
|
||||||
|
a.download = `tts-output-${new Date().toISOString().slice(0, 10)}.wav`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setStatus('Audio download started');
|
||||||
|
addDebugLog('Audio download initiated');
|
||||||
|
}, [audioUrl, addDebugLog]);
|
||||||
|
|
||||||
|
// Handle audio playback
|
||||||
|
const togglePlayback = useCallback(() => {
|
||||||
|
if (!audioUrl) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
setIsPlaying(false);
|
||||||
|
setStatus('Playback stopped');
|
||||||
|
addDebugLog('Playback stopped');
|
||||||
|
} else {
|
||||||
|
audioRef.current.play()
|
||||||
|
.then(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
setStatus('Playing audio...');
|
||||||
|
addDebugLog('Playback started');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setError('Failed to play audio');
|
||||||
|
addDebugLog(`Playback error: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [audioUrl, isPlaying, addDebugLog]);
|
||||||
|
|
||||||
|
// Handle audio playback end
|
||||||
|
const handleAudioEnd = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setStatus('Playback finished');
|
||||||
|
addDebugLog('Playback finished naturally');
|
||||||
|
}, [addDebugLog]);
|
||||||
|
|
||||||
|
// Copy text to clipboard
|
||||||
|
const copyToClipboard = useCallback(() => {
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
addDebugLog('Text copied to clipboard');
|
||||||
|
}).catch(err => {
|
||||||
|
const errorMsg = 'Failed to copy text to clipboard';
|
||||||
|
addDebugLog(`${errorMsg}: ${err.message}`);
|
||||||
|
setError(errorMsg);
|
||||||
|
});
|
||||||
|
}, [text, addDebugLog]);
|
||||||
|
|
||||||
|
// Clear all inputs and states
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setText('');
|
||||||
|
setError(null);
|
||||||
|
setStatus('Enter text to convert to speech');
|
||||||
|
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
setAudioUrl(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
addDebugLog('All inputs and states cleared');
|
||||||
|
}, [audioUrl, isPlaying, addDebugLog]);
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const clearDebugLogs = useCallback(() => {
|
||||||
|
setDebugLogs([]);
|
||||||
|
addDebugLog('Debug logs cleared');
|
||||||
|
}, [addDebugLog]);
|
||||||
|
|
||||||
|
const toggleDebug = useCallback(() => {
|
||||||
|
setShowDebug(!showDebug);
|
||||||
|
addDebugLog(`Debug panel ${showDebug ? 'hidden' : 'shown'}`);
|
||||||
|
}, [showDebug, addDebugLog]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 max-w-4xl my-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div className='flex flex-col space-y-2'>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">Text to Speech (Using our API)</h1>
|
||||||
|
<p className='text-gray-500'>Limitation: 5 Req / Min & 50 Req / Day </p>
|
||||||
|
<p className='text-gray-500'>Need More Purchase from our <a href="/services" className='text-[#6d9e37] font-bold'>Services</a></p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={toggleDebug}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{showDebug ? 'Hide Debug' : 'Show Debug'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Input Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<label htmlFor="tts-text" className="block text-sm font-medium text-gray-700">
|
||||||
|
Enter Text
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
disabled={!text || isLoading}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy Text'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAll}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
id="tts-text"
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:border-[#6d9e37] bg-white text-gray-800"
|
||||||
|
placeholder="Type or paste your text here..."
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-6">
|
||||||
|
<Button
|
||||||
|
onClick={convertToSpeech}
|
||||||
|
disabled={!text.trim() || isLoading}
|
||||||
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Converting...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Convert to Speech'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={togglePlayback}
|
||||||
|
disabled={!audioUrl || isLoading}
|
||||||
|
variant="outline"
|
||||||
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Stop Playback
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Play Audio
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={downloadAudio}
|
||||||
|
disabled={!audioUrl || isLoading}
|
||||||
|
variant="outline"
|
||||||
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Download Audio
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<p className={`text-sm ${
|
||||||
|
error ? 'text-red-600' :
|
||||||
|
isLoading ? 'text-[#6d9e37]' :
|
||||||
|
isPlaying ? 'text-[#6d9e37]' :
|
||||||
|
'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<div className="flex items-center text-red-600">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="font-medium">Error</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio Player (hidden) */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
onEnded={handleAudioEnd}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Debug Section */}
|
||||||
|
{showDebug && (
|
||||||
|
<div className="mt-8 border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
||||||
|
<h3 className="font-medium text-gray-700">Debug Logs</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={clearDebugLogs}
|
||||||
|
className="text-sm">
|
||||||
|
Clear Logs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const blob = new Blob([debugLogs.join('\n')], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tts-debug-${new Date().toISOString()}.log`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
className="text-sm">
|
||||||
|
Export Logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white max-h-60 overflow-y-auto">
|
||||||
|
{debugLogs.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{debugLogs.map((log, index) => (
|
||||||
|
<div key={index} className="text-xs font-mono text-gray-800 border-b border-gray-100 pb-1">
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">No debug logs yet. Interactions will appear here.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/Tools/ToolsCard.jsx
Normal file
36
src/components/Tools/ToolsCard.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../../components/ui/card";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
export default function ToolsCard({key, toolName, toolSlug, toolFeatures, toolImg}) {
|
||||||
|
// console.log('tool data', {key, toolName, toolSlug, toolFeatures, toolImg})
|
||||||
|
return(
|
||||||
|
<>
|
||||||
|
<Card className="flex flex-col justify-between hover:shadow-[0_0_10px_#6d9e37] duration-500">
|
||||||
|
<img className="object-cover aspect-video rounded-t-md" src={toolImg} alt={toolName} />
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col flex-1">
|
||||||
|
<CardTitle className="py-2">{toolName}</CardTitle>
|
||||||
|
<hr className="border-b-2 border-[#6d9e37]" />
|
||||||
|
|
||||||
|
|
||||||
|
<ul className="space-y-2 text-sm mb-4 mt-2">
|
||||||
|
{
|
||||||
|
toolFeatures.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-start">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[#6d9e37] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||||
|
<span className='text-zinc-600'>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<a href={`/web-tools/${toolSlug}`}>
|
||||||
|
<Button onClick={() => window.location.href=`/web-tools/${toolSlug}`} className="w-full">View</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,41 +1,43 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import ImageSlider from "./ImageSlider/ImageSlider"
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./ui/card";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Textarea } from "./ui/textarea";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||||
|
import { token, user_name, pb_id } from '../lib/CookieValues';
|
||||||
|
import CommentSystem from './CommentSystem/CommentSystem';
|
||||||
|
import '../styles/markdown.css';
|
||||||
|
import LikeSystem from './CommentSystem/LikeSystem'
|
||||||
|
|
||||||
|
const COMMENTS_API_URL = 'https://host-api-sxashuasysagibx.siliconpin.com/v1/comments/';
|
||||||
export default function TopicDetail(props) {
|
export default function TopicDetail(props) {
|
||||||
|
// console.log('coockie data', user_name)
|
||||||
const [showCopied, setShowCopied] = useState(false);
|
const [showCopied, setShowCopied] = useState(false);
|
||||||
|
const [allGalleryImages, setAllGalleryImages] = useState([]);
|
||||||
|
const siliconId = '4uPRIGMm';
|
||||||
|
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NTI5MjQxMTQsImlkIjoiZ203NzYyZ2Q2aTNscDAxIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.Aq02zEAcXjyfsFO6MniM0atzzy2rSXx7B_rnilK6OYQ';
|
||||||
if (!props.topic) {
|
if (!props.topic) {
|
||||||
return <div>Topic not found</div>;
|
return <div>Topic not found</div>;
|
||||||
}
|
}
|
||||||
|
const slug = props.topic.slug;
|
||||||
|
const fetchGalleryImage = async () => {
|
||||||
|
try{
|
||||||
|
const response = await fetch(`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com/gallery-image-delevery?slug=${slug}`)
|
||||||
|
const data = await response.json();
|
||||||
|
setAllGalleryImages(data);
|
||||||
|
// console.log('data.images', data.images)
|
||||||
|
}catch(error){
|
||||||
|
// console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGalleryImage()
|
||||||
|
}, [slug])
|
||||||
const shareUrl = typeof window !== 'undefined' ? window.location.href : '';
|
const shareUrl = typeof window !== 'undefined' ? window.location.href : '';
|
||||||
const title = props.topic.title;
|
const title = props.topic.title;
|
||||||
const text = `Check out this article: ${title}`;
|
const text = `Check out this Topic: ${title}`;
|
||||||
|
|
||||||
// const shareOnFacebook = () => {
|
|
||||||
// window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank');
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const shareOnTwitter = () => {
|
|
||||||
// window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`, '_blank');
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const shareOnLinkedIn = () => {
|
|
||||||
// window.open(`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`, '_blank');
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const shareOnReddit = () => {
|
|
||||||
// window.open(`https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`, '_blank');
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const shareOnWhatsApp = () => {
|
|
||||||
// window.open(`https://wa.me/?text=${encodeURIComponent(`${text} ${shareUrl}`)}`, '_blank');
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const shareViaEmail = () => {
|
|
||||||
// window.open(`mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(`${text}\n\n${shareUrl}`)}`);
|
|
||||||
// };
|
|
||||||
|
|
||||||
|
|
||||||
const shareOnSocialMedia = (platform) => {
|
const shareOnSocialMedia = (platform) => {
|
||||||
switch (platform){
|
switch (platform){
|
||||||
@@ -125,12 +127,14 @@ export default function TopicDetail(props) {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
<article className="max-w-4xl mx-auto">
|
<article className="max-w-4xl mx-auto">
|
||||||
<img src={props.topic.img ? props.topic.img : '/assets/images/thumb-place.jpg'} alt={props.topic.title} className="w-full h-[400px] aspect-video object-cover rounded-lg mb-8 shadow-md" />
|
<img src={props.topic.img ? props.topic.img : '/assets/images/thumb-place.jpg'} alt={props.topic.title} className="w-full object-cover rounded-lg mb-8 shadow-md" />
|
||||||
<h1 className="text-4xl font-bold text-[#6d9e37] mb-6">{props.topic.title}</h1>
|
<h1 className="text-4xl font-bold text-[#6d9e37] mb-6">{props.topic.title}</h1>
|
||||||
|
|
||||||
{/* Enhanced Social Share Buttons */}
|
{/* Enhanced Social Share Buttons */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<p className="text-sm text-gray-500 mb-3 font-medium">Share this article:</p>
|
<div className="flex flex-row justify-between">
|
||||||
|
<p className="text-sm text-gray-500 mb-3 font-medium">Share this Topic:</p>
|
||||||
|
<LikeSystem postId={props.topic.id} siliconId={siliconId} token={token} />
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => shareOnSocialMedia('facebook')}
|
onClick={() => shareOnSocialMedia('facebook')}
|
||||||
@@ -211,11 +215,16 @@ export default function TopicDetail(props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div id="markdown-content" className="table-scroll-wrapper font-light mb-8 text-justify prose max-w-none leading-8" dangerouslySetInnerHTML={{ __html: marked.parse(props.topic.content || '') }} ></div>
|
||||||
className="font-light mb-8 text-justify prose max-w-none"
|
{
|
||||||
dangerouslySetInnerHTML={{ __html: marked.parse(props.topic.content || '') }}
|
allGalleryImages && (
|
||||||
></div>
|
<ImageSlider images={allGalleryImages} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<CommentSystem topicId={props.topic.id}/>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bg-[#6d9e37]
|
||||||
@@ -8,9 +8,10 @@ import { Separator } from './ui/separator';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||||
import { CustomTabs } from './ui/tabs';
|
import { CustomTabs } from './ui/tabs';
|
||||||
import Loader from "./ui/loader";
|
import Loader from "./ui/loader";
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
const TOPIC_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/topics/';
|
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||||
const MINIO_UPLOAD_URL = 'https://your-minio-api-endpoint/upload';
|
const PUBLIC_MINIO_UPLOAD_URL = 'https://file-vault-prod-pdgctwgsnkiy.siliconpin.com/upload';
|
||||||
|
// const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const slug = urlParams.get('slug');
|
const slug = urlParams.get('slug');
|
||||||
// console.log('find slug from url', slug);
|
// console.log('find slug from url', slug);
|
||||||
@@ -31,12 +32,28 @@ export default function EditTopic (){
|
|||||||
const [imageUploadFile, setImageUploadFile] = useState(null);
|
const [imageUploadFile, setImageUploadFile] = useState(null);
|
||||||
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
||||||
const [editorMode, setEditorMode] = useState('edit');
|
const [editorMode, setEditorMode] = useState('edit');
|
||||||
|
const [galleryImgDesc, setGalleryImgDesc] = useState('');
|
||||||
|
const [galleryImg, setGalleryImg] = useState('');
|
||||||
|
const [statusMessage, setStatusMessage] = useState({success : "", message: ""});
|
||||||
|
const [allGalleryImages, setAllGalleryImages] = useState([]);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const fetchGalleryImage = async () => {
|
||||||
|
try{
|
||||||
|
const response = await fetch(`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com/gallery-image-delevery?slug=${slug}`)
|
||||||
|
const data = await response.json();
|
||||||
|
setAllGalleryImages(data.images);
|
||||||
|
// console.log('data.images', data.images)
|
||||||
|
}catch(error){
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch topic data on component mount
|
// Fetch topic data on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTopic = async () => {
|
const fetchTopic = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${TOPIC_API_URL}?query=get-single-topic&slug=${slug}`, {
|
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=get-single-topic&slug=${slug}`, {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +70,7 @@ export default function EditTopic (){
|
|||||||
title: topic.title || '',
|
title: topic.title || '',
|
||||||
slug: topic.slug || '',
|
slug: topic.slug || '',
|
||||||
content: topic.content || '',
|
content: topic.content || '',
|
||||||
imageUrl: topic.imageUrl || ''
|
imageUrl: topic.img || ''
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -62,29 +79,65 @@ export default function EditTopic (){
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTopic();
|
fetchTopic(); fetchGalleryImage();
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
// Upload Gallery Images
|
||||||
|
const uploadGalleryImages = async (file, slug) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('description', galleryImgDesc);
|
||||||
|
formData.append('slug', slug);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success === true) {
|
||||||
|
setGalleryImgDesc('');
|
||||||
|
setStatusMessage({ success: true, message: "Image Uploaded! you can upload more images" });
|
||||||
|
setGalleryImg(null);
|
||||||
|
fetchGalleryImage();
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = null;
|
||||||
|
} else {
|
||||||
|
setStatusMessage({ success: false, message: "Failed to upload image" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatusMessage({ success: false, message: "Failed to upload image" });
|
||||||
|
}
|
||||||
|
setIsSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Upload file to MinIO (same as NewTopic)
|
// Upload file to MinIO (same as NewTopic)
|
||||||
const uploadToMinIO = async (file, onProgress) => {
|
const uploadToMinIO = async (file, onProgress) => {
|
||||||
|
const siliconId = Cookies.get('siliconId'); // Get from cookie or anywhere
|
||||||
|
const token = Cookies.get('token'); // Get from cookie or storage
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('bucket', 'siliconpin-uploads');
|
|
||||||
formData.append('folder', 'topic-images');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(MINIO_UPLOAD_URL, {
|
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
headers: {
|
||||||
credentials: 'include',
|
'x-user-data': siliconId,
|
||||||
|
'Authorization': token,
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Upload failed');
|
throw new Error(`Upload failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.url;
|
// console.log('Upload successful:', data);
|
||||||
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -117,13 +170,49 @@ export default function EditTopic (){
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Custom <br> insert command
|
||||||
|
const lineBreakCommand = {
|
||||||
|
name: 'linebreak',
|
||||||
|
keyCommand: 'linebreak',
|
||||||
|
buttonProps: { 'aria-label': 'Insert line break' },
|
||||||
|
icon: (
|
||||||
|
|
||||||
|
<svg width="16px" height="16px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M20 7V8.2C20 9.88016 20 10.7202 19.673 11.362C19.3854 11.9265 18.9265 12.3854 18.362 12.673C17.7202 13 16.8802 13 15.2 13H4M4 13L8 9M4 13L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>
|
||||||
|
),
|
||||||
|
execute: () => {
|
||||||
|
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
const value = formData.content || "";
|
||||||
|
const breakTag = '<br>';
|
||||||
|
|
||||||
|
const newValue = value.slice(0, start) + breakTag + value.slice(end);
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: newValue
|
||||||
|
}));
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + breakTag.length;
|
||||||
|
textarea.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Get all commands (same as NewTopic)
|
// Get all commands (same as NewTopic)
|
||||||
const allCommands = commands.getCommands().map(cmd => {
|
const allCommands = [
|
||||||
|
...commands.getCommands().map(cmd => {
|
||||||
if (cmd.name === 'image') {
|
if (cmd.name === 'image') {
|
||||||
return customImageCommand;
|
return customImageCommand;
|
||||||
}
|
}
|
||||||
return cmd;
|
return cmd;
|
||||||
});
|
}),
|
||||||
|
lineBreakCommand,
|
||||||
|
];
|
||||||
|
|
||||||
// Handle image URL insertion (same as NewTopic)
|
// Handle image URL insertion (same as NewTopic)
|
||||||
const handleInsertImageUrl = () => {
|
const handleInsertImageUrl = () => {
|
||||||
@@ -173,7 +262,7 @@ export default function EditTopic (){
|
|||||||
setUploadProgress(progress);
|
setUploadProgress(progress);
|
||||||
});
|
});
|
||||||
|
|
||||||
const imgMarkdown = ``;
|
const imgMarkdown = ``;
|
||||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
const startPos = textarea.selectionStart;
|
const startPos = textarea.selectionStart;
|
||||||
@@ -213,7 +302,7 @@ export default function EditTopic (){
|
|||||||
setUploadProgress(progress);
|
setUploadProgress(progress);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
imageUrl = imageUrl.url;
|
||||||
// Prepare payload
|
// Prepare payload
|
||||||
const payload = {
|
const payload = {
|
||||||
...formData,
|
...formData,
|
||||||
@@ -221,7 +310,7 @@ export default function EditTopic (){
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Submit to API (PUT request for update)
|
// Submit to API (PUT request for update)
|
||||||
const response = await fetch(`${TOPIC_API_URL}?query=update-topic&slug=${formData.slug}`, {
|
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=update-topic&slug=${formData.slug}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -346,7 +435,7 @@ export default function EditTopic (){
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="slug">URL Slug</Label>
|
{/* <Label htmlFor="slug">URL Slug</Label> */}
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="slug"
|
name="slug"
|
||||||
@@ -354,7 +443,44 @@ export default function EditTopic (){
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="bg-gray-50"
|
className="bg-gray-50"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500">This will be used in the topic URL</p>
|
{/* <p className="text-xs text-gray-500">This will be used in the topic URL</p> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Image Upload */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label >Featured Image</Label>
|
||||||
|
<div className="flex flex-wrap space-x-4">
|
||||||
|
<Label htmlFor="image" className="cursor-pointer">
|
||||||
|
<svg viewBox="0 0 512 512" width="100px" height="100px" fill="#000000">
|
||||||
|
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||||
|
<g id="SVGRepo_tracerCarrier" strokeLinecap="round" strokeLinejoin="round"></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<path fill="#6d9e37" d="M512,0h-40v16h24v32h16V0z M432,0h-40v16h40V0z M352,0h-40v16h40V0z M272,0h-40v16h40V0z M192,0h-40 v16h40V0z M112,0H72v16h40V0z M32,0H0v16h32V0z M16,48H0v40h16V48z M16,128H0v40h16V128z M16,208H0v40h16V208z M16,288H0v40h16V288z M16,368H0v40h16V368z M16,448H0v40h16V448z M56,496H16v16h40V496z M136,496H96v16h40V496z M216,496h-40v16h40V496z M296,496h-40v16 h40V496z M376,496h-40v16h40V496z M456,496h-40v16h40V496z M512,488h-16v8l0,0v16h16V488z M512,408h-16v40h16V408z M512,328h-16v40 h16V328z M512,248h-16v40h16V248z M512,168h-16v40h16V168z M512,88h-16v40h16V88z"></path>
|
||||||
|
<g>
|
||||||
|
<rect x="244" y="175.976" fill="#6d9e37" width="24" height="160.08"></rect>
|
||||||
|
<rect x="175.976" y="244" fill="#6d9e37" width="160.08" height="24"></rect>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
id="image"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="cursor-pointer hidden"
|
||||||
|
/>
|
||||||
|
{formData.imageUrl && (
|
||||||
|
<div className="relative group">
|
||||||
|
<img
|
||||||
|
src={formData.imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-[100px] h-[100px] rounded-md border"
|
||||||
|
/>
|
||||||
|
<button onClick={(e) => {e.preventDefault(); setFormData({...formData, imageUrl : null})}} className="absolute inset-0 opacity-0 group-hover:opacity-100 bg-white/20 transition-all duration-500 text-2xl font-bold text-red-500">X</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Editor */}
|
{/* Content Editor */}
|
||||||
@@ -454,37 +580,42 @@ export default function EditTopic (){
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{
|
||||||
{/* Featured Image Upload */}
|
allGalleryImages && (
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-4 mt-4">
|
||||||
<Label htmlFor="image">Featured Image</Label>
|
{allGalleryImages.map((img, index) => (
|
||||||
|
<img key={index} src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${img}`} alt={`gallery-${index}`} className="w-full h-auto rounded shadow" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="galleryImage">
|
||||||
|
Gallery Image: <span className="text-xs text-gray-500">(First write description (Min 10 Char) to proceed image upload!)</span>
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-x-4 mt-1">
|
||||||
<Input
|
<Input
|
||||||
|
onChange={(e) => setGalleryImgDesc(e.target.value)}
|
||||||
|
value={galleryImgDesc}
|
||||||
|
type="text"
|
||||||
|
placeholder="Write description to proceed image upload!"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={(e) => setGalleryImg(e.target.files[0])}
|
||||||
type="file"
|
type="file"
|
||||||
id="image"
|
className={`${galleryImgDesc.trim().length > 9 ? 'border-[#6d9e37] file:bg-[#6d9e37] file:text-white file:border-0 file:rounded-md' : ''}`}
|
||||||
onChange={handleFileChange}
|
disabled={galleryImgDesc.trim().length < 10}
|
||||||
accept="image/*"
|
|
||||||
className="cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
|
<Button onClick={() => uploadGalleryImages(galleryImg, slug)} disabled={isSubmitting}>
|
||||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
{isSubmitting ? 'Uploading...' : 'Upload'}
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
</Button>
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2.5 rounded-full"
|
|
||||||
style={{ width: `${uploadProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className={`mt-2 text-center ${statusMessage.success === true ? 'text-[#6d9e37]' : statusMessage.success === false ? 'text-red-500' : ''}`}>
|
||||||
|
{statusMessage.success === true && <span className="mr-1">✓</span>}
|
||||||
{formData.imageUrl && (
|
{statusMessage.success === false && <span className="mr-1">❌</span>}
|
||||||
<div className="mt-2">
|
{statusMessage.message}
|
||||||
<img
|
</p>
|
||||||
src={formData.imageUrl}
|
|
||||||
alt="Preview"
|
|
||||||
className="max-h-40 rounded-md border"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">Current Image</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export default function TopicItems(props) {
|
|||||||
return <div className="loading-indicator">Loading...</div>;
|
return <div className="loading-indicator">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
// if (error) {
|
||||||
return <div className="error-message">Error loading authentication status</div>;
|
// return <div className="error-message">Error loading authentication status</div>;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const handleSearchChange = (e) => {
|
const handleSearchChange = (e) => {
|
||||||
setLocalSearchTerm(e.target.value);
|
setLocalSearchTerm(e.target.value);
|
||||||
@@ -42,7 +42,7 @@ export default function TopicItems(props) {
|
|||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
props.topics.length > 0 ? (
|
props.topics.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||||
{
|
{
|
||||||
props.topics.map((topic) => (
|
props.topics.map((topic) => (
|
||||||
<a href={`/topic/${topic.slug}`} key={topic.id} className="hover:scale-[1.02] transition-transform duration-200 ">
|
<a href={`/topic/${topic.slug}`} key={topic.id} className="hover:scale-[1.02] transition-transform duration-200 ">
|
||||||
@@ -52,9 +52,9 @@ export default function TopicItems(props) {
|
|||||||
</div>
|
</div>
|
||||||
<CardContent className="flex-1 p-6">
|
<CardContent className="flex-1 p-6">
|
||||||
<CardTitle className="mb-2 line-clamp-1">{topic.title}</CardTitle>
|
<CardTitle className="mb-2 line-clamp-1">{topic.title}</CardTitle>
|
||||||
<CardDescription className="line-clamp-4 mb-4 text-justify" dangerouslySetInnerHTML={{ __html: marked.parse(topic.content || '') }}></CardDescription>
|
<CardDescription className="line-clamp-6 mb-4 text-justify" dangerouslySetInnerHTML={{ __html: marked.parse(topic.content || '') }}></CardDescription>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-xs text-gray-500"><strong>Author: </strong>{topic.user.split('@')[0]}</p>
|
<p className="text-xs text-gray-500"><strong>Author: </strong>{topic.user_name}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
<strong>Date: </strong>
|
<strong>Date: </strong>
|
||||||
{new Date(topic.timestamp).toLocaleDateString('en-GB', {
|
{new Date(topic.timestamp).toLocaleDateString('en-GB', {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function TopicCreation() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://host-api.cs1.hz.siliconpin.com/v1/topics/?query=get-all-topics&page=${page}`,
|
`https://host-api-sxashuasysagibx.siliconpin.com/v1/topics/?query=get-all-topics&page=${page}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Loader from "./ui/loader";
|
|||||||
const topicPageDesc = 'Cutting-edge discussions on tech, digital services, news, and digital freedom. Stay informed on AI, cybersecurity, privacy, and the future of innovation.';
|
const topicPageDesc = 'Cutting-edge discussions on tech, digital services, news, and digital freedom. Stay informed on AI, cybersecurity, privacy, and the future of innovation.';
|
||||||
|
|
||||||
export default function TopicCreation() {
|
export default function TopicCreation() {
|
||||||
|
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||||
const { isLoggedIn, loading: authLoading, error: authError } = useIsLoggedIn();
|
const { isLoggedIn, loading: authLoading, error: authError } = useIsLoggedIn();
|
||||||
const [topics, setTopics] = useState([]);
|
const [topics, setTopics] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -14,7 +15,7 @@ export default function TopicCreation() {
|
|||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
current_page: 1,
|
current_page: 1,
|
||||||
last_page: 1,
|
last_page: 1,
|
||||||
per_page: 10,
|
per_page: 12,
|
||||||
total: 0
|
total: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export default function TopicCreation() {
|
|||||||
const fetchTopics = async (page, search = '') => {
|
const fetchTopics = async (page, search = '') => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let url = `https://host-api.cs1.hz.siliconpin.com/v1/topics/?query=get-all-topics&page=${page}`;
|
let url = `${PUBLIC_TOPIC_API_URL}?query=get-all-topics&page=${page}`;
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
url += `&search=${encodeURIComponent(search)}`;
|
url += `&search=${encodeURIComponent(search)}`;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { PDFDownloadLink } from '@react-pdf/renderer';
|
|||||||
import InvoicePDF from "../lib/InvoicePDF";
|
import InvoicePDF from "../lib/InvoicePDF";
|
||||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||||
import PasswordUpdateCard from './PasswordUpdateCard';
|
import PasswordUpdateCard from './PasswordUpdateCard';
|
||||||
|
import Index from "../pages/topic/index.astro";
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
@@ -42,7 +43,7 @@ type PaymentData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { isLoggedIn, loading, sessionData } = useIsLoggedIn();
|
const { isLoggedIn, loading, sessionData, balance } = useIsLoggedIn();
|
||||||
const typedSessionData = sessionData as SessionData | null;
|
const typedSessionData = sessionData as SessionData | null;
|
||||||
const [userData, setUserData] = useState<UserData | null>(null);
|
const [userData, setUserData] = useState<UserData | null>(null);
|
||||||
const [invoiceList, setInvoiceList] = useState<any[]>([]);
|
const [invoiceList, setInvoiceList] = useState<any[]>([]);
|
||||||
@@ -52,13 +53,31 @@ export default function ProfilePage() {
|
|||||||
const [toast, setToast] = useState<ToastState>({ visible: false, message: '' });
|
const [toast, setToast] = useState<ToastState>({ visible: false, message: '' });
|
||||||
const [txnId, setTxnId] = useState<string>('');
|
const [txnId, setTxnId] = useState<string>('');
|
||||||
const [userEmail, setUserEmail] = useState<string>('');
|
const [userEmail, setUserEmail] = useState<string>('');
|
||||||
const USER_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
|
const [addBalanceModal, setAddBalanceModal] = useState(false);
|
||||||
const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/invoice/';
|
const [balanceData, setBalanceData] = useState([
|
||||||
|
{
|
||||||
|
currency: "INR",
|
||||||
|
balanceList: ["100", "500", "1000", "1500"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: "USD",
|
||||||
|
balanceList: ["10", "50", "100", "150"]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedAmount, setSelectedAmount] = useState('');
|
||||||
|
const [customAmount, setCustomAmount] = useState('');
|
||||||
|
const [selectedCurrency, setSelectedCurrency] = useState("INR");
|
||||||
|
const [balanceApiloading, setBalanceApiloading] = useState(false);
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
|
||||||
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
const PUBLIC_INVOICE_API_URL = import.meta.env.PUBLIC_INVOICE_API_URL;
|
||||||
|
const currentBalanceList = balanceData.find(item => item.currency === selectedCurrency)?.balanceList || [];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSessionData = async () => {
|
const fetchSessionData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${USER_API_URL}?query=get-user`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=get-user`, {
|
||||||
credentials: 'include', // Crucial for cookies
|
credentials: 'include', // Crucial for cookies
|
||||||
headers: { 'Accept': 'application/json' }
|
headers: { 'Accept': 'application/json' }
|
||||||
}
|
}
|
||||||
@@ -77,7 +96,7 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
const getInvoiceListData = async () => {
|
const getInvoiceListData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${USER_API_URL}?query=invoice-info`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=invoice-info`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include', // Crucial for cookies
|
credentials: 'include', // Crucial for cookies
|
||||||
headers: { 'Accept': 'application/json' }
|
headers: { 'Accept': 'application/json' }
|
||||||
@@ -105,6 +124,42 @@ export default function ProfilePage() {
|
|||||||
getInvoiceListData();
|
getInvoiceListData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
const amountToSend = selectedAmount === "others" ? customAmount : selectedAmount;
|
||||||
|
if (!amountToSend || isNaN(Number(amountToSend))) {
|
||||||
|
setFormError("Please enter a valid amount.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('amount', amountToSend);
|
||||||
|
formData.append('currency', selectedCurrency); // ✅ Include currency
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBalanceApiloading(true);
|
||||||
|
|
||||||
|
const response = await fetch(`https://siliconpin.com/v1/balance/?query=initiate-add-balance`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success === true) {
|
||||||
|
window.location.href = `/add-balance?orderId=${data.transaction_id}`;
|
||||||
|
} else {
|
||||||
|
alert("Something went wrong. Please try again.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
alert("An error occurred while processing your request.");
|
||||||
|
} finally {
|
||||||
|
setBalanceApiloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const showToast = (message: string) => {
|
const showToast = (message: string) => {
|
||||||
@@ -127,7 +182,7 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sessionLogOut = () => {
|
const sessionLogOut = () => {
|
||||||
fetch(`${USER_API_URL}?query=logout`, {
|
fetch(`${PUBLIC_USER_API_URL}?query=logout`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
@@ -163,6 +218,11 @@ export default function ProfilePage() {
|
|||||||
if (!userData) {
|
if (!userData) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
const formattedBalance = new Intl.NumberFormat('en-IN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'INR',
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(balance);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 container mx-auto">
|
<div className="space-y-6 container mx-auto">
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -170,7 +230,17 @@ export default function ProfilePage() {
|
|||||||
<div className="flex-1 lg:max-w-3xl">
|
<div className="flex-1 lg:max-w-3xl">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<div className="flex justify-between">
|
||||||
<CardTitle>Personal Information</CardTitle>
|
<CardTitle>Personal Information</CardTitle>
|
||||||
|
<div className="group relative overflow-hidden w-fit cursor-pointer" onClick={() => {setAddBalanceModal(true)}}>
|
||||||
|
<div className="flex items-center space-x-2 border-y border-l border-[#6d9e37] rounded-full relative z-10 transition-all duration-300">
|
||||||
|
<p className="text-lg font-bold text-center px-2 group-hover:text-white">Balance: {formattedBalance}</p>
|
||||||
|
<Button title="Add Balance" className="text-lg rounded-full group-hover:bg-[#262626] z-10" variant="outline" size="sm">+</Button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-[#6d9e37] translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-in-out z-0 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Update your personal information and avatar.
|
Update your personal information and avatar.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -187,7 +257,7 @@ export default function ProfilePage() {
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="firstName">Full Name</Label>
|
<Label htmlFor="firstName">Full Name</Label>
|
||||||
<Input id="firstName" defaultValue={userData.session_data?.user_name || 'Jhon'} />
|
<Input id="firstName" defaultValue={userData.session_data?.user_name || ''} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">Phone</Label>
|
<Label htmlFor="phone">Phone</Label>
|
||||||
@@ -335,10 +405,10 @@ export default function ProfilePage() {
|
|||||||
<span className="font-bold">Amount:</span>
|
<span className="font-bold">Amount:</span>
|
||||||
<span>₹{selectedData?.amount}</span>
|
<span>₹{selectedData?.amount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
{/* <div className="flex justify-between">
|
||||||
<span className="font-bold">User:</span>
|
<span className="font-bold">User:</span>
|
||||||
<span>{selectedData?.user}</span>
|
<span>{selectedData?.user}</span>
|
||||||
</div>
|
</div> */}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="font-bold">Status:</span>
|
<span className="font-bold">Status:</span>
|
||||||
<span className={`px-2 py-0.5 rounded text-white text-xs ${selectedData?.status === 'pending' ? 'bg-yellow-500' : selectedData?.status === 'completed' ? 'bg-green-500' : 'bg-red-500'}`}>
|
<span className={`px-2 py-0.5 rounded text-white text-xs ${selectedData?.status === 'pending' ? 'bg-yellow-500' : selectedData?.status === 'completed' ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||||
@@ -366,6 +436,76 @@ export default function ProfilePage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add balance modal */}
|
||||||
|
<Dialog open={addBalanceModal} onOpenChange={setAddBalanceModal}>
|
||||||
|
<DialogContent className="max-w-md sm:rounded-2xl p-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="">Add Balance</DialogTitle>
|
||||||
|
<DialogDescription className="">Choose an amount or enter to add to your balance</DialogDescription>
|
||||||
|
{formError && <p className="text-red-600 text-sm mt-2">{formError}</p>}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-3 flex-wrap mb-4">
|
||||||
|
{["INR", "USD"].map((currency) => (
|
||||||
|
<Button
|
||||||
|
key={currency}
|
||||||
|
variant={selectedCurrency === currency ? "default" : "outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCurrency(currency);
|
||||||
|
setSelectedAmount('');
|
||||||
|
setCustomAmount('');
|
||||||
|
setFormError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currency}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{balanceData
|
||||||
|
.find((item) => item.currency === selectedCurrency)
|
||||||
|
?.balanceList.map((amount) => (
|
||||||
|
<Button
|
||||||
|
key={amount}
|
||||||
|
variant={customAmount === amount ? "default" : "outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setCustomAmount(amount);
|
||||||
|
setSelectedAmount(amount);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedCurrency === "INR" ? `₹${amount}` : `$${amount}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 border-b ${
|
||||||
|
customAmount ? "border-[#6d9e37]" : "border-gray-700"
|
||||||
|
} p-2 rounded-md text-[#6d9e37] font-bold outline-none mt-4 w-full`}
|
||||||
|
>
|
||||||
|
<span className="text-sm whitespace-nowrap">You’ve selected</span>
|
||||||
|
<span className="text-lg">
|
||||||
|
{selectedCurrency === "INR" ? "₹" : "$"}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Amount"
|
||||||
|
className="w-20 bg-transparent outline-none text-[#6d9e37] text-lg font-bold text-center"
|
||||||
|
value={customAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomAmount(e.target.value);
|
||||||
|
setSelectedAmount(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm whitespace-nowrap">to top up your balance.</span>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-6 flex justify-between w-full">
|
||||||
|
<Button variant="outline" onClick={() => setAddBalanceModal(false)} className="w-full"> Cancel </Button>
|
||||||
|
<Button disabled={!customAmount || balanceApiloading} onClick={handleNext} className="w-full">{balanceApiloading ? "Processing..." : "Next"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
8
src/lib/CookieValues.js
Normal file
8
src/lib/CookieValues.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// CookieValues.js
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
|
export const token = Cookies.get('token') || null;
|
||||||
|
export const user_name = Cookies.get('user_name') || null;
|
||||||
|
export const pb_id = Cookies.get('pb_id') || null;
|
||||||
|
export const siliconId = Cookies.get('siliconId') || null;
|
||||||
|
export const isLogin = Cookies.get('isLogin') || null;
|
||||||
@@ -1,27 +1,44 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
const USER_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/index.php';
|
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||||
|
|
||||||
export const useIsLoggedIn = () => {
|
export const useIsLoggedIn = () => {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(null); // null means "unknown"
|
const [isLoggedIn, setIsLoggedIn] = useState(null);
|
||||||
const [sessionData, setSessionData] = useState(null);
|
const [sessionData, setSessionData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [balance, setBalance] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkLoginStatus = async () => {
|
const checkLoginStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${USER_API_URL}?query=isLoggedIn`, {
|
const response = await fetch(`${PUBLIC_USER_API_URL}?query=isLoggedIn`, {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Network response was not ok (status: ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setIsLoggedIn(!!data?.isLoggedIn);
|
|
||||||
setSessionData(data?.session_data || null);
|
if (!data.success) {
|
||||||
|
throw new Error('API request was not successful');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoggedIn(!!data.isLoggedIn);
|
||||||
|
setSessionData(data.session_data || null);
|
||||||
|
|
||||||
|
|
||||||
|
const userData = data.userDBData?.[0]; // Get first item in array
|
||||||
|
setBalance(Number(data?.userDBData?.balance || 0));
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
setSessionData(null);
|
setSessionData(null);
|
||||||
|
setBalance(0);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -29,14 +46,14 @@ export const useIsLoggedIn = () => {
|
|||||||
checkLoginStatus();
|
checkLoginStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { isLoggedIn, sessionData, loading, error };
|
return { isLoggedIn, sessionData, loading, error, balance };
|
||||||
};
|
};
|
||||||
|
|
||||||
const IsLoggedIn = ({ children, fallback = null }) => {
|
const IsLoggedIn = ({ children, fallback = null }) => {
|
||||||
const { isLoggedIn, loading, error } = useIsLoggedIn();
|
const { isLoggedIn, loading, error, balance } = useIsLoggedIn();
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (loading) return <div>Loading...</div>;
|
||||||
if (error) return <div>Error checking login status</div>;
|
if (error) return <div>Error checking login status: {error.message}</div>;
|
||||||
|
|
||||||
return isLoggedIn ? children : fallback;
|
return isLoggedIn ? children : fallback;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ const pageImage = "https://images.unsplash.com/photo-1551731409-43eb3e517a1a?q=8
|
|||||||
description={pageDescription}
|
description={pageDescription}
|
||||||
ogImage={pageImage}
|
ogImage={pageImage}
|
||||||
type="website"
|
type="website"
|
||||||
|
canonicalURL="/about-us"
|
||||||
>
|
>
|
||||||
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
<div class="text-center mb-8 sm:mb-12">
|
<div class="text-center mb-8 sm:mb-12">
|
||||||
<h1 class="text-3xl sm:text-4xl font-bold text-[#6d9e37] mb-3 sm:mb-4">About SiliconPin</h1>
|
<h1 class="text-3xl sm:text-4xl font-bold text-[#6d9e37] mb-3 sm:mb-4">About SiliconPin</h1>
|
||||||
<p class="text-lg sm:text-xl max-w-3xl mx-auto text-neutral-300">
|
<p class="text-lg sm:text-xl max-w-3xl mx-auto text-neutral-300">
|
||||||
Empowering businesses with reliable, high-performance hosting solutions
|
We promote awareness about digital freedom by provoding software and hardware development and research
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
7
src/pages/add-balance.astro
Normal file
7
src/pages/add-balance.astro
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import AddBalancePage from "../components/AddBalance";
|
||||||
|
---
|
||||||
|
<Layout title="">
|
||||||
|
<AddBalancePage client:load />
|
||||||
|
</Layout>
|
||||||
@@ -3,22 +3,24 @@ import Layout from '../layouts/Layout.astro';
|
|||||||
import { ContactForm } from '../components/ContactForm';
|
import { ContactForm } from '../components/ContactForm';
|
||||||
|
|
||||||
// Page-specific SEO metadata
|
// Page-specific SEO metadata
|
||||||
const pageTitle = "Contact Us | SiliconPin";
|
const metaTitle = "Contact Us | SiliconPin";
|
||||||
const pageDescription = "Contact SiliconPin for reliable hosting solutions. Our team is available 24/7 to help with your PHP, Node.js, Python, Kubernetes, and K3s hosting needs.";
|
const metaDescription = "Contact SiliconPin for reliable hosting solutions. Our team is available 24/7 to help with your PHP, Node.js, Python, Kubernetes, and K3s hosting needs.";
|
||||||
const pageImage = "https://images.unsplash.com/photo-1551731409-43eb3e517a1a?q=80&w=2000&auto=format&fit=crop";
|
const ogImage = "https://images.unsplash.com/photo-1551731409-43eb3e517a1a?q=80&w=2000&auto=format&fit=crop";
|
||||||
|
|
||||||
// Contact schema for structured data
|
// Contact schema for structured data
|
||||||
const contactSchema = {
|
const contactSchema1 = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "ContactPage",
|
"@type": "ContactPage",
|
||||||
"name": "SiliconPin Contact Page",
|
"name": "Contact SiliconPin",
|
||||||
"description": "Contact SiliconPin for reliable hosting solutions",
|
"url": "https://siliconpin.com/contact/",
|
||||||
"url": "https://siliconpin.com/contact",
|
"description": "Contact page for SiliconPin – Reach out for cloud hosting, VPN, DevOps, and AI services.",
|
||||||
"mainEntityOfPage": {
|
"inLanguage": ["en", "hi", "bn"],
|
||||||
"@type": "Organization",
|
"contactPoint": {
|
||||||
"name": "SiliconPin",
|
"@type": "ContactPoint",
|
||||||
"telephone": "+91-700-160-1485",
|
"telephone": "+91-700-160-1485",
|
||||||
"email": "contact@siliconpin.com",
|
"contactType": "customer service",
|
||||||
|
"availableLanguage": ["English", "Hindi", "Bengali"]
|
||||||
|
},
|
||||||
"address": {
|
"address": {
|
||||||
"@type": "PostalAddress",
|
"@type": "PostalAddress",
|
||||||
"streetAddress": "121 Lalbari, GourBongo Road",
|
"streetAddress": "121 Lalbari, GourBongo Road",
|
||||||
@@ -26,32 +28,92 @@ const contactSchema = {
|
|||||||
"addressRegion": "W.B.",
|
"addressRegion": "W.B.",
|
||||||
"postalCode": "743271",
|
"postalCode": "743271",
|
||||||
"addressCountry": "India"
|
"addressCountry": "India"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactSchema2 = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "SiliconPin",
|
||||||
|
"url": "https://siliconpin.com",
|
||||||
|
"logo": "https://siliconpin.com/images/logo.png",
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"telephone": "+91-700-160-1485",
|
||||||
|
"contactType": "Customer Support",
|
||||||
|
"areaServed": "IN",
|
||||||
|
"availableLanguage": ["English", "Hindi", "Bengali"]
|
||||||
},
|
},
|
||||||
|
"sameAs": [
|
||||||
|
"https://www.linkedin.com/company/siliconpin",
|
||||||
|
"https://x.com/dwd_consultancy",
|
||||||
|
"https://www.facebook.com/dwdsiliconpin",
|
||||||
|
"https://instagram.com/siliconpin.com_"
|
||||||
|
],
|
||||||
"openingHoursSpecification": [
|
"openingHoursSpecification": [
|
||||||
{
|
{
|
||||||
"@type": "OpeningHoursSpecification",
|
"@type": "OpeningHoursSpecification",
|
||||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
"dayOfWeek": "Monday",
|
||||||
|
"opens": "00:00",
|
||||||
|
"closes": "00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
"dayOfWeek": [
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday"
|
||||||
|
],
|
||||||
"opens": "09:00",
|
"opens": "09:00",
|
||||||
"closes": "18:00"
|
"closes": "17:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "OpeningHoursSpecification",
|
"@type": "OpeningHoursSpecification",
|
||||||
"dayOfWeek": "Saturday",
|
"dayOfWeek": "Saturday",
|
||||||
"opens": "10:00",
|
"opens": "10:00",
|
||||||
"closes": "16:00"
|
"closes": "16:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
"dayOfWeek": "Sunday",
|
||||||
|
"opens": "00:00",
|
||||||
|
"closecs": "00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
const contactSchema3 = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "Contact Us - SiliconPin",
|
||||||
|
"url": "https://siliconpin.com/contact/",
|
||||||
|
"description": "Contact the SiliconPin team for cloud hosting, VPN, AI agents, and DevOps services. We’re here to help developers, startups, and businesses.",
|
||||||
|
"inLanguage": ["en", "hi", "bn"],
|
||||||
|
"isPartOf": {
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "SiliconPin",
|
||||||
|
"url": "https://siliconpin.com"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "SiliconPin",
|
||||||
|
"url": "https://siliconpin.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
title={pageTitle}
|
title={metaTitle}
|
||||||
description={pageDescription}
|
description={metaDescription}
|
||||||
ogImage={pageImage}
|
ogImage={ogImage}
|
||||||
type="website"
|
type="website"
|
||||||
|
canonicalURL="/contact-us"
|
||||||
|
newSchema1={JSON.stringify(contactSchema1)}
|
||||||
|
newSchema2={JSON.stringify(contactSchema2)}
|
||||||
|
newSchema3={JSON.stringify(contactSchema3)}
|
||||||
>
|
>
|
||||||
<script type="application/ld+json" set:html={JSON.stringify(contactSchema)}></script>
|
|
||||||
|
|
||||||
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
<div class="text-center mb-8 sm:mb-12">
|
<div class="text-center mb-8 sm:mb-12">
|
||||||
@@ -146,3 +208,15 @@ const contactSchema = {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
<script src="https://suvodip35.github.io/my-chatbot-widget/chatbot.min.js"></script>
|
||||||
|
<script>
|
||||||
|
window.chatbotSettings = {
|
||||||
|
apiKey: "",
|
||||||
|
apiUrl: "",
|
||||||
|
allowedDomains: ["127.0.0.1"],
|
||||||
|
themeColor: "#6d9e37",
|
||||||
|
greetingMessage: "Hi! How can I help you today?",
|
||||||
|
chatbotTitle: "Chat with ঌপি AI",
|
||||||
|
autoOpen: true
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -19,6 +19,7 @@ const defaultSubdomain = randomString();
|
|||||||
description={pageDescription}
|
description={pageDescription}
|
||||||
ogImage={pageImage}
|
ogImage={pageImage}
|
||||||
type="website"
|
type="website"
|
||||||
|
canonicalURL="/get-started"
|
||||||
>
|
>
|
||||||
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
<div class="text-center mb-8 sm:mb-12">
|
<div class="text-center mb-8 sm:mb-12">
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import Layout from '../layouts/Layout.astro';
|
|||||||
|
|
||||||
const schema1 ={
|
const schema1 ={
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Service",
|
||||||
|
"name": "Cloud Hosting, VPN, DevOps & AI Services",
|
||||||
|
"description": "SiliconPin provides cloud hosting, secure VPN, AI tools, and DevOps services tailored for developers, startups, and small businesses.",
|
||||||
|
"serviceType": "Cloud Hosting, VPN, DevOps, AI Tools",
|
||||||
|
"provider": {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": "SiliconPin",
|
"name": "SiliconPin",
|
||||||
"url": "https://siliconpin.com",
|
"url": "https://siliconpin.com",
|
||||||
"logo": "https://siliconpin.com/assets/logo.svg",
|
"logo": "https://siliconpin.com/images/logo.png",
|
||||||
"contactPoint": {
|
"contactPoint": {
|
||||||
"@type": "ContactPoint",
|
"@type": "ContactPoint",
|
||||||
"telephone": "+91-700-160-1485",
|
"telephone": "+91-700-160-1485",
|
||||||
@@ -27,19 +32,82 @@ const schema1 = {
|
|||||||
"https://www.facebook.com/dwdsiliconpin",
|
"https://www.facebook.com/dwdsiliconpin",
|
||||||
"https://instagram.com/siliconpin.com_"
|
"https://instagram.com/siliconpin.com_"
|
||||||
]
|
]
|
||||||
};
|
},
|
||||||
|
"areaServed": {
|
||||||
|
"@type": "Place",
|
||||||
|
"name": "Worldwide"
|
||||||
|
},
|
||||||
|
"url": "https://siliconpin.com/services/"
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema2 ={
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "SiliconPin",
|
||||||
|
"url": "https://siliconpin.com",
|
||||||
|
"logo": "https://siliconpin.com/images/logo.png",
|
||||||
|
"description": "SiliconPin delivers affordable and scalable cloud infrastructure, VPN services, AI tools, and DevOps for modern developers and businesses.",
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"telephone": "+91-700-160-1485",
|
||||||
|
"contactType": "customer service",
|
||||||
|
"availableLanguage": ["English", "Hindi", "Bengali"]
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "121 Lalbari, GourBongo Road",
|
||||||
|
"addressLocality": "Habra",
|
||||||
|
"addressRegion": "W.B.",
|
||||||
|
"postalCode": "743271",
|
||||||
|
"addressCountry": "India"
|
||||||
|
},
|
||||||
|
"sameAs": [
|
||||||
|
"https://www.linkedin.com/company/siliconpin",
|
||||||
|
"https://x.com/dwd_consultancy",
|
||||||
|
"https://www.facebook.com/dwdsiliconpin",
|
||||||
|
"https://instagram.com/siliconpin.com_"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema3 ={
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "Our Services",
|
||||||
|
"url": "https://siliconpin.com/services/",
|
||||||
|
"description": "Explore SiliconPin’s cloud hosting, VPN, AI tools, and DevOps services built for developers, startups, and small businesses.",
|
||||||
|
"inLanguage": ["en", "hi", "bn"],
|
||||||
|
"isPartOf": {
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "SiliconPin",
|
||||||
|
"url": "https://siliconpin.com"
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "SiliconPin",
|
||||||
|
"logo": {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": "https://siliconpin.com/images/logo.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Page-specific SEO metadata
|
// Page-specific SEO metadata
|
||||||
const pageTitle = "SiliconPin - Lets create some digital freedom";
|
const pageTitle = "SiliconPin - Lets create some digital freedom";
|
||||||
const pageDescription = "SiliconPin - easy to deploy apps and tools, freedom oriented apps and tools, high-performance, hosting solutions for PHP, Node.js, Python, Kubernetes (K8s), and K3s, and technical support.";
|
const pageDescription = "SiliconPin - easy to deploy apps and tools, freedom oriented apps and tools, high-performance, hosting solutions for PHP, Node.js, Python, Kubernetes (K8s), and K3s, and technical support.";
|
||||||
const pageImage = "/assets/logo.svg";
|
const ogImage = "/assets/logo.svg";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
title={pageTitle}
|
title={pageTitle}
|
||||||
description={pageDescription}
|
description={pageDescription}
|
||||||
ogImage={pageImage}
|
ogImage={ogImage}
|
||||||
|
keyWords={'here is keywords content'}
|
||||||
newSchema1={JSON.stringify(schema1)}
|
newSchema1={JSON.stringify(schema1)}
|
||||||
|
newSchema2={JSON.stringify(schema2)}
|
||||||
|
newSchema3={JSON.stringify(schema3)}
|
||||||
|
newSchema4={'here is another schema content'}
|
||||||
|
newSchema5={'here is another schema content'}
|
||||||
|
canonicalURL="/"
|
||||||
|
|
||||||
>
|
>
|
||||||
<!-- Canvas background animation -->
|
<!-- Canvas background animation -->
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const pageImage = "https://images.unsplash.com/photo-1551731409-43eb3e517a1a?q=8
|
|||||||
description={pageDescription}
|
description={pageDescription}
|
||||||
ogImage={pageImage}
|
ogImage={pageImage}
|
||||||
type="website"
|
type="website"
|
||||||
|
canonicslURL="/privacy-policy"
|
||||||
>
|
>
|
||||||
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
<div class="text-center mb-8 sm:mb-12">
|
<div class="text-center mb-8 sm:mb-12">
|
||||||
@@ -120,7 +121,7 @@ const pageImage = "https://images.unsplash.com/photo-1551731409-43eb3e517a1a?q=8
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="bg-neutral-800 rounded-lg p-6 sm:p-8 border border-neutral-700">
|
<section id="profile-delete" class="bg-neutral-800 rounded-lg p-6 sm:p-8 border border-neutral-700">
|
||||||
<h2 class="text-2xl font-bold text-white mb-4">6. Your Rights</h2>
|
<h2 class="text-2xl font-bold text-white mb-4">6. Your Rights</h2>
|
||||||
<div class="space-y-4 text-neutral-300">
|
<div class="space-y-4 text-neutral-300">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ import Layout from "../../layouts/Layout.astro";
|
|||||||
import UserProfile from "../../components/UserProfile";
|
import UserProfile from "../../components/UserProfile";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Profile Page">
|
<Layout title="Profile Page" canonicalURL="/profile">
|
||||||
<UserProfile client:load />
|
<UserProfile client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import BuyVPN from "../../components/BuyServices/BuyVPN"
|
import BuyVPN from "../../components/BuyServices/BuyVPN"
|
||||||
---
|
---
|
||||||
<Layout title="Buy VPN | WireGuard | SiliconPin">
|
<Layout title="Buy VPN | WireGuard | SiliconPin" canonicalURL="/services/buy-vpn">
|
||||||
<BuyVPN client:load />
|
<BuyVPN client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -3,6 +3,6 @@ import Layout from "../../layouts/Layout.astro"
|
|||||||
import NewHetznerCloudInstance from "../../components/BuyServices/NewHetznerCloudInstance";
|
import NewHetznerCloudInstance from "../../components/BuyServices/NewHetznerCloudInstance";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Cloud Instance | SiliconPin">
|
<Layout title="Cloud Instance | SiliconPin" canonicalURL="/services/cloud-hetzner-instance">
|
||||||
<NewHetznerCloudInstance client:load />
|
<NewHetznerCloudInstance client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -3,6 +3,6 @@ import Layout from "../../layouts/Layout.astro"
|
|||||||
import NewUthoCloudInstance from "../../components/BuyServices/NewUthoCloudInstance";
|
import NewUthoCloudInstance from "../../components/BuyServices/NewUthoCloudInstance";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Cloud Instance | SiliconPin">
|
<Layout title="Cloud Instance | SiliconPin" canonicalURL="/services/cloud-instance">
|
||||||
<NewUthoCloudInstance client:load />
|
<NewUthoCloudInstance client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
import Layout from "../../layouts/Layout.astro"
|
import Layout from "../../layouts/Layout.astro"
|
||||||
import NewDroplet from "../../components/BuyServices/NewDroplet";
|
import NewDroplet from "../../components/BuyServices/NewDroplet";
|
||||||
---
|
---
|
||||||
<Layout title="Create Droplets | SiliconPin">
|
<Layout title="Create Droplets | SiliconPin" canonicalURL="/services/create-droplets">
|
||||||
<NewDroplet client:load />
|
<NewDroplet client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
// Astro script block
|
// Astro script block
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
import { ServiceCard } from '../../components/ServiceCard';
|
import { ServiceCard } from '../../components/ServiceCard';
|
||||||
|
const PUBLIC_SERVICE_API_URL = import.meta.env.PUBLIC_SERVICE_API_URL;
|
||||||
|
// console.log(PUBLIC_SERVICE_API_URL)
|
||||||
const services = [
|
const services = [
|
||||||
{
|
{
|
||||||
imageUrl: '/assets/images/services/wiregurd-thumb.jpg',
|
imageUrl: '/assets/images/services/wiregurd-thumb.jpg',
|
||||||
@@ -38,17 +40,11 @@ const services = [
|
|||||||
buyButtonText: "",
|
buyButtonText: "",
|
||||||
buyButtonUrl: ""
|
buyButtonUrl: ""
|
||||||
},
|
},
|
||||||
{
|
|
||||||
imageUrl: '/assets/images/services/stt-streaming.png',
|
|
||||||
learnMoreUrl: '',
|
|
||||||
buyButtonText: 'Purchase',
|
|
||||||
buyButtonUrl: '/services/stt-streaming'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
imageUrl: '/assets/images/services/kubernetes-edge.png',
|
imageUrl: '/assets/images/services/kubernetes-edge.png',
|
||||||
learnMoreUrl: '/services/kubernetes',
|
learnMoreUrl: '/services/kubernetes',
|
||||||
buyButtonText: '',
|
buyButtonText: 'Get K8s',
|
||||||
buyButtonUrl: ''
|
buyButtonUrl: '/services/kubernetes'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
imageUrl: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=2000&auto=format&fit=crop',
|
imageUrl: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=2000&auto=format&fit=crop',
|
||||||
@@ -67,12 +63,23 @@ const services = [
|
|||||||
learnMoreUrl: '/services/hire-an-ai-agent',
|
learnMoreUrl: '/services/hire-an-ai-agent',
|
||||||
buyButtonText: '',
|
buyButtonText: '',
|
||||||
buyButtonUrl: ''
|
buyButtonUrl: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageUrl: '/assets/images/services/stt-streaming.png',
|
||||||
|
learnMoreUrl: '',
|
||||||
|
buyButtonText: 'Purchase',
|
||||||
|
buyButtonUrl: '/services/stt-streaming'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageUrl: '/assets/images/services/deployapp-thumb.jpg',
|
||||||
|
learnMoreUrl: '',
|
||||||
|
buyButtonText: 'Get VPS',
|
||||||
|
buyButtonUrl: '/services/cloud-instance/'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const SERVICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/services/?query=all-services-list';
|
|
||||||
let data = [];
|
let data = [];
|
||||||
try {
|
try {
|
||||||
const response = await fetch(SERVICE_API_URL);
|
const response = await fetch(`https://siliconpin.com/v1/services/?query=all-services-list`);
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
// data = [...json.data, ...services];
|
// data = [...json.data, ...services];
|
||||||
@@ -224,8 +231,9 @@ const pageImage = "https://images.unsplash.com/photo-1551731409-43eb3e517a1a?q=8
|
|||||||
ogImage={pageImage}
|
ogImage={pageImage}
|
||||||
type="website"
|
type="website"
|
||||||
newSchema1={JSON.stringify(schema1)}
|
newSchema1={JSON.stringify(schema1)}
|
||||||
|
canonicalURL="/services"
|
||||||
>
|
>
|
||||||
<main class="container mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
<main class="container mx-auto max-w-7xl px-4 sm:px-6 py-8 sm:py-12">
|
||||||
<div class="text-center mb-8 sm:mb-12">
|
<div class="text-center mb-8 sm:mb-12">
|
||||||
<h1 class="text-3xl sm:text-4xl font-bold text-[#6d9e37] mb-3 sm:mb-4">Hosting Services</h1>
|
<h1 class="text-3xl sm:text-4xl font-bold text-[#6d9e37] mb-3 sm:mb-4">Hosting Services</h1>
|
||||||
<p class="text-lg sm:text-xl max-w-3xl mx-auto text-neutral-300">
|
<p class="text-lg sm:text-xl max-w-3xl mx-auto text-neutral-300">
|
||||||
|
|||||||
7
src/pages/services/kubernetes.astro
Normal file
7
src/pages/services/kubernetes.astro
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import NewKubernetisUtho from "../../components/BuyServices/NewKubernetisUtho";
|
||||||
|
---
|
||||||
|
<Layout title="Deploy Kubernetis | Kubernetis | SiliconPin" canonicalURL="/services/kubernetes">
|
||||||
|
<NewKubernetisUtho client:load />
|
||||||
|
</Layout>
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import NewKubernetisUtho from "../../components/BuyServices/NewKubernetisUtho";
|
import NewKubernetisUtho from "../../components/BuyServices/NewKubernetisUtho";
|
||||||
---
|
---
|
||||||
<Layout title="Buy VPN | WireGuard | SiliconPin">
|
<Layout title="Deploy Kubernetis | Kubernetis | SiliconPin" canonicalURL="/services/kubernetes">
|
||||||
<NewKubernetisUtho client:load />
|
<NewKubernetisUtho client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
import Layout from "../../layouts/Layout.astro"
|
import Layout from "../../layouts/Layout.astro"
|
||||||
import STTStreaming from "../../components/BuyServices/STTStreaming"
|
import STTStreaming from "../../components/BuyServices/STTStreaming"
|
||||||
---
|
---
|
||||||
<Layout title="STT Streaming | SiliconPin">
|
<Layout title="STT Streaming | SiliconPin" canonicalURL="/services/stt-streaming">
|
||||||
<STTStreaming client:load />
|
<STTStreaming client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import TopicDetail from "../../components/TopicDetail";
|
import TopicDetail from "../../components/TopicDetail";
|
||||||
|
|
||||||
const TOPIC_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/topics/';
|
const TOPIC_API_URL = 'https://host-api-sxashuasysagibx.siliconpin.com/v1/topics/';
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
|
|
||||||
let topic = null;
|
let topic = null;
|
||||||
@@ -25,7 +25,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const TOPIC_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/topics/';
|
const TOPIC_API_URL = 'https://host-api-sxashuasysagibx.siliconpin.com/v1/topics/';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${TOPIC_API_URL}?query=get-all-topics-for-slug`, {
|
const response = await fetch(`${TOPIC_API_URL}?query=get-all-topics-for-slug`, {
|
||||||
@@ -48,8 +48,6 @@ export async function getStaticPaths() {
|
|||||||
// console.log(topic)
|
// console.log(topic)
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={topic?.title ?? 'Topic | SiliconPin'} ogImage={topic.img? topic.img : '/assets/images/thumb-place.jpg'} >
|
<Layout title={`${topic.title ? topic.title + '- Silicon Topic' : 'Silicon Topic'}`} ogImage={topic.img? topic.img : '/assets/images/thumb-place.jpg'} canonicalURL={`/topic/${topic.slug}`} >
|
||||||
<TopicDetail client:load topic={topic} />
|
<TopicDetail client:load topic={topic} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import TopicsList from "../../components/Topics";
|
import TopicsList from "../../components/Topics";
|
||||||
|
|
||||||
const TOPIC_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/topics/';
|
const TOPIC_API_URL = 'https://siliconpin.com/v1/topics/';
|
||||||
let topics = [];
|
let topics = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +23,6 @@ try {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Topics">
|
<Layout title="Topics" canonicalURL="/topic">
|
||||||
<TopicsList client:load />
|
<TopicsList client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
292
src/pages/web-tools/audio-recorder-wav-16-bit-mono-16khz.astro
Normal file
292
src/pages/web-tools/audio-recorder-wav-16-bit-mono-16khz.astro
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="">
|
||||||
|
<div class="container mx-auto px-4" id="stt-container">
|
||||||
|
<h1>WAV Recorder (16-bit Mono 16kHz)</h1>
|
||||||
|
|
||||||
|
<div id="audioVisualizer"></div>
|
||||||
|
|
||||||
|
<button id="startBtn">Start Recording</button>
|
||||||
|
<button id="stopBtn" disabled>Stop Recording</button>
|
||||||
|
<button id="playBtn" disabled>Play Recording</button>
|
||||||
|
<button id="downloadBtn" disabled>Download WAV</button>
|
||||||
|
|
||||||
|
<div id="status">Ready to record</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
<script is:inline>
|
||||||
|
// DOM elements
|
||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
const stopBtn = document.getElementById('stopBtn');
|
||||||
|
const playBtn = document.getElementById('playBtn');
|
||||||
|
const downloadBtn = document.getElementById('downloadBtn');
|
||||||
|
const audioVisualizer = document.getElementById('audioVisualizer');
|
||||||
|
const statusDisplay = document.getElementById('status');
|
||||||
|
|
||||||
|
// Audio variables
|
||||||
|
let audioContext;
|
||||||
|
let mediaStream;
|
||||||
|
let processor;
|
||||||
|
let recording = false;
|
||||||
|
let audioChunks = [];
|
||||||
|
let audioBlob;
|
||||||
|
let audioUrl;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const config = {
|
||||||
|
sampleRate: 16000, // 16kHz sample rate
|
||||||
|
numChannels: 1, // Mono
|
||||||
|
bitDepth: 16 // 16-bit
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
startBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
statusDisplay.textContent = "Requesting microphone access...";
|
||||||
|
|
||||||
|
// Get microphone access
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
|
// Create audio context with our desired sample rate
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||||
|
sampleRate: config.sampleRate
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a script processor node to process the audio
|
||||||
|
processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
processor.onaudioprocess = processAudio;
|
||||||
|
|
||||||
|
// Create a media stream source
|
||||||
|
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||||
|
|
||||||
|
// Connect the source to the processor and to the destination
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
|
||||||
|
// Setup visualization
|
||||||
|
setupVisualizer(source);
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
recording = true;
|
||||||
|
audioChunks = [];
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
startBtn.disabled = true;
|
||||||
|
stopBtn.disabled = false;
|
||||||
|
statusDisplay.textContent = "Recording...";
|
||||||
|
|
||||||
|
console.log('Recording started at ' + config.sampleRate + 'Hz');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
statusDisplay.textContent = "Error: " + error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process audio data
|
||||||
|
function processAudio(event) {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
// Get audio data (already mono because we set numChannels to 1)
|
||||||
|
const inputData = event.inputBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
// Convert float32 to 16-bit PCM
|
||||||
|
const buffer = new ArrayBuffer(inputData.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
for (let i = 0, offset = 0; i < inputData.length; i++, offset += 2) {
|
||||||
|
const s = Math.max(-1, Math.min(1, inputData[i]));
|
||||||
|
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the chunk
|
||||||
|
audioChunks.push(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
stopBtn.addEventListener('click', () => {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
recording = false;
|
||||||
|
|
||||||
|
// Disconnect processor
|
||||||
|
if (processor) {
|
||||||
|
processor.disconnect();
|
||||||
|
processor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop media stream tracks
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WAV file from collected chunks
|
||||||
|
createWavFile();
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
startBtn.disabled = false;
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
playBtn.disabled = false;
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
statusDisplay.textContent = "Recording stopped. Ready to play or download.";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create WAV file from collected PCM data
|
||||||
|
function createWavFile() {
|
||||||
|
// Combine all chunks into a single buffer
|
||||||
|
const length = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||||
|
const result = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
audioChunks.forEach(chunk => {
|
||||||
|
result.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create WAV header
|
||||||
|
const wavHeader = createWaveHeader(result.length, config);
|
||||||
|
|
||||||
|
// Combine header and PCM data
|
||||||
|
const wavData = new Uint8Array(wavHeader.length + result.length);
|
||||||
|
wavData.set(wavHeader, 0);
|
||||||
|
wavData.set(result, wavHeader.length);
|
||||||
|
|
||||||
|
// Create blob
|
||||||
|
audioBlob = new Blob([wavData], { type: 'audio/wav' });
|
||||||
|
audioUrl = URL.createObjectURL(audioBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WAV header
|
||||||
|
function createWaveHeader(dataLength, config) {
|
||||||
|
const byteRate = config.sampleRate * config.numChannels * (config.bitDepth / 8);
|
||||||
|
const blockAlign = config.numChannels * (config.bitDepth / 8);
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(44);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
// RIFF identifier
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
// RIFF chunk length
|
||||||
|
view.setUint32(4, 36 + dataLength, true);
|
||||||
|
// RIFF type
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
// Format chunk identifier
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
// Format chunk length
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
// Sample format (raw)
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
// Channel count
|
||||||
|
view.setUint16(22, config.numChannels, true);
|
||||||
|
// Sample rate
|
||||||
|
view.setUint32(24, config.sampleRate, true);
|
||||||
|
// Byte rate (sample rate * block align)
|
||||||
|
view.setUint32(28, byteRate, true);
|
||||||
|
// Block align (channel count * bytes per sample)
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
// Bits per sample
|
||||||
|
view.setUint16(34, config.bitDepth, true);
|
||||||
|
// Data chunk identifier
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
// Data chunk length
|
||||||
|
view.setUint32(40, dataLength, true);
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to write strings to DataView
|
||||||
|
function writeString(view, offset, string) {
|
||||||
|
for (let i = 0; i < string.length; i++) {
|
||||||
|
view.setUint8(offset + i, string.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play recorded audio
|
||||||
|
playBtn.addEventListener('click', () => {
|
||||||
|
if (!audioUrl) return;
|
||||||
|
|
||||||
|
const audio = new Audio(audioUrl);
|
||||||
|
audio.play();
|
||||||
|
statusDisplay.textContent = "Playing recording...";
|
||||||
|
|
||||||
|
audio.onended = () => {
|
||||||
|
statusDisplay.textContent = "Playback finished";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download recorded audio as WAV file
|
||||||
|
downloadBtn.addEventListener('click', () => {
|
||||||
|
if (!audioBlob) return;
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = audioUrl;
|
||||||
|
a.download = `recording_${config.sampleRate}Hz_${config.bitDepth}bit.wav`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
statusDisplay.textContent = "Download started";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup audio visualization
|
||||||
|
function setupVisualizer(source) {
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 64;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
const bufferLength = analyser.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = audioVisualizer.offsetWidth;
|
||||||
|
canvas.height = audioVisualizer.offsetHeight;
|
||||||
|
audioVisualizer.innerHTML = '';
|
||||||
|
audioVisualizer.appendChild(canvas);
|
||||||
|
|
||||||
|
const canvasCtx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
canvasCtx.fillStyle = 'rgb(200, 200, 200)';
|
||||||
|
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
const barHeight = dataArray[i] / 2;
|
||||||
|
|
||||||
|
canvasCtx.fillStyle = `rgb(${barHeight + 100}, 50, 50)`;
|
||||||
|
canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
|
||||||
|
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
#stt-container > button {
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#audioVisualizer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
8
src/pages/web-tools/image-labeling.astro
Normal file
8
src/pages/web-tools/image-labeling.astro
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import ImageLabelings from "../../components/Tools/ImageLabeling";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="">
|
||||||
|
<ImageLabelings client:load />
|
||||||
|
</Layout>
|
||||||
71
src/pages/web-tools/index.astro
Normal file
71
src/pages/web-tools/index.astro
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import ToolsCard from "../../components/Tools/ToolsCard";
|
||||||
|
let toolsData = [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"toolName": "Speech To Text",
|
||||||
|
"slug": "speech-to-text",
|
||||||
|
"img": "https://images.unsplash.com/photo-1554224155-6726b3ff858f?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
||||||
|
"features": [
|
||||||
|
"Real-time transcription",
|
||||||
|
"Multiple API integrations (Whisper, Vosk)",
|
||||||
|
"Audio recording capability",
|
||||||
|
"Export transcriptions as text files",
|
||||||
|
"Multi-language support",
|
||||||
|
"Punctuation and formatting options"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"toolName": "Image Resize",
|
||||||
|
"slug": "image-resize",
|
||||||
|
"img": "https://images.unsplash.com/photo-1619597455322-4fbbd820250a?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
||||||
|
"features": [
|
||||||
|
"Bulk image processing",
|
||||||
|
"Preset dimensions for social media",
|
||||||
|
"Maintain aspect ratio",
|
||||||
|
"Quality adjustment",
|
||||||
|
"Multiple output formats (JPG, PNG, WEBP)",
|
||||||
|
"Preview before download",
|
||||||
|
"Drag and drop interface"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"toolName": "Text to Speech",
|
||||||
|
"slug": "text-to-speech",
|
||||||
|
"img": "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
||||||
|
"features": [
|
||||||
|
"High-quality speech synthesis",
|
||||||
|
"Multiple voice options",
|
||||||
|
"Adjustable playback speed",
|
||||||
|
"Download as WAV audio",
|
||||||
|
"Real-time preview",
|
||||||
|
"Text formatting support",
|
||||||
|
"API integration",
|
||||||
|
"Debug and logging features"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
---
|
||||||
|
<Layout title="Web Tools | SiliconPin Tools">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<h2 class="text-2xl font-bold my-4 text-[#6d9e37]">SiliconPin Web Toos</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{
|
||||||
|
toolsData.map((items, index) => (
|
||||||
|
<ToolsCard client:load
|
||||||
|
key={items.id}
|
||||||
|
toolName={items.toolName}
|
||||||
|
toolSlug={items.slug}
|
||||||
|
toolFeatures={items.features}
|
||||||
|
toolImg={items.img}
|
||||||
|
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
128
src/pages/web-tools/speech-to-text.astro
Normal file
128
src/pages/web-tools/speech-to-text.astro
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import AudioToText from "../../components/Tools/AudioToText"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Whisper CPP Light">
|
||||||
|
<AudioToText client:load />
|
||||||
|
</Layout>
|
||||||
|
<!-- <div class="container mx-auto px-4" id="stt-container">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Audio File to STT</h1>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2" for="audioFile">
|
||||||
|
Upload Audio File (WAV format recommended)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="audioFile"
|
||||||
|
accept="audio/*,.wav"
|
||||||
|
class="block w-full text-sm text-gray-500
|
||||||
|
file:mr-4 file:py-2 file:px-4
|
||||||
|
file:rounded-md file:border-0
|
||||||
|
file:text-sm file:font-semibold
|
||||||
|
file:bg-blue-50 file:text-blue-700
|
||||||
|
hover:file:bg-blue-100"
|
||||||
|
>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Supported formats: WAV, MP3, OGG, etc.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="sendBtn"
|
||||||
|
disabled
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Send to STT API
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-2">API Response</h2>
|
||||||
|
<div id="responseContainer" class="p-4 bg-gray-100 rounded-md min-h-20">
|
||||||
|
<p id="statusMessage" class="text-gray-600">No file uploaded yet</p>
|
||||||
|
<pre id="apiResponse" class="hidden mt-2 p-2 bg-white rounded overflow-auto"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
<!-- <script is:inline>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const audioFileInput = document.getElementById('audioFile');
|
||||||
|
const sendBtn = document.getElementById('sendBtn');
|
||||||
|
const statusMessage = document.getElementById('statusMessage');
|
||||||
|
const apiResponse = document.getElementById('apiResponse');
|
||||||
|
const responseContainer = document.getElementById('responseContainer');
|
||||||
|
|
||||||
|
// Enable send button when a file is selected
|
||||||
|
audioFileInput.addEventListener('change', () => {
|
||||||
|
if (audioFileInput.files.length > 0) {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
statusMessage.textContent = `File selected: ${audioFileInput.files[0].name}`;
|
||||||
|
} else {
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
statusMessage.textContent = 'No file selected';
|
||||||
|
}
|
||||||
|
apiResponse.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle sending to STT API
|
||||||
|
sendBtn.addEventListener('click', async () => {
|
||||||
|
if (audioFileInput.files.length === 0) return;
|
||||||
|
|
||||||
|
const file = audioFileInput.files[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.textContent = 'Processing...';
|
||||||
|
statusMessage.textContent = `Sending ${file.name} to STT API...`;
|
||||||
|
apiResponse.classList.add('hidden');
|
||||||
|
responseContainer.classList.remove('bg-red-50', 'bg-green-50');
|
||||||
|
responseContainer.classList.add('bg-blue-50');
|
||||||
|
|
||||||
|
// Create FormData and append the file
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('audio', file);
|
||||||
|
|
||||||
|
// Send to STT API
|
||||||
|
const response = await fetch('https://stt-41.siliconpin.com/stt', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
apiResponse.textContent = JSON.stringify(result, null, 2);
|
||||||
|
apiResponse.classList.remove('hidden');
|
||||||
|
statusMessage.textContent = 'STT API Response:';
|
||||||
|
responseContainer.classList.remove('bg-blue-50');
|
||||||
|
responseContainer.classList.add('bg-green-50');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
statusMessage.textContent = `Error: ${error.message}`;
|
||||||
|
responseContainer.classList.remove('bg-blue-50');
|
||||||
|
responseContainer.classList.add('bg-red-50');
|
||||||
|
} finally {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.textContent = 'Send to STT API';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Additional styling if needed */
|
||||||
|
#stt-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#apiResponse {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
</style> -->
|
||||||
300
src/pages/web-tools/stt2.html
Normal file
300
src/pages/web-tools/stt2.html
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>WAV Recorder (16-bit Mono 16kHz)</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#audioVisualizer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>WAV Recorder (16-bit Mono 16kHz)</h1>
|
||||||
|
|
||||||
|
<div id="audioVisualizer"></div>
|
||||||
|
|
||||||
|
<button id="startBtn">Start Recording</button>
|
||||||
|
<button id="stopBtn" disabled>Stop Recording</button>
|
||||||
|
<button id="playBtn" disabled>Play Recording</button>
|
||||||
|
<button id="downloadBtn" disabled>Download WAV</button>
|
||||||
|
|
||||||
|
<div id="status">Ready to record</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// DOM elements
|
||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
const stopBtn = document.getElementById('stopBtn');
|
||||||
|
const playBtn = document.getElementById('playBtn');
|
||||||
|
const downloadBtn = document.getElementById('downloadBtn');
|
||||||
|
const audioVisualizer = document.getElementById('audioVisualizer');
|
||||||
|
const statusDisplay = document.getElementById('status');
|
||||||
|
|
||||||
|
// Audio variables
|
||||||
|
let audioContext;
|
||||||
|
let mediaStream;
|
||||||
|
let processor;
|
||||||
|
let recording = false;
|
||||||
|
let audioChunks = [];
|
||||||
|
let audioBlob;
|
||||||
|
let audioUrl;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const config = {
|
||||||
|
sampleRate: 16000, // 16kHz sample rate
|
||||||
|
numChannels: 1, // Mono
|
||||||
|
bitDepth: 16 // 16-bit
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
startBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
statusDisplay.textContent = "Requesting microphone access...";
|
||||||
|
|
||||||
|
// Get microphone access
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
|
// Create audio context with our desired sample rate
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||||
|
sampleRate: config.sampleRate
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a script processor node to process the audio
|
||||||
|
processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
processor.onaudioprocess = processAudio;
|
||||||
|
|
||||||
|
// Create a media stream source
|
||||||
|
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||||
|
|
||||||
|
// Connect the source to the processor and to the destination
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
|
||||||
|
// Setup visualization
|
||||||
|
setupVisualizer(source);
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
recording = true;
|
||||||
|
audioChunks = [];
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
startBtn.disabled = true;
|
||||||
|
stopBtn.disabled = false;
|
||||||
|
statusDisplay.textContent = "Recording...";
|
||||||
|
|
||||||
|
console.log('Recording started at ' + config.sampleRate + 'Hz');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
statusDisplay.textContent = "Error: " + error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process audio data
|
||||||
|
function processAudio(event) {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
// Get audio data (already mono because we set numChannels to 1)
|
||||||
|
const inputData = event.inputBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
// Convert float32 to 16-bit PCM
|
||||||
|
const buffer = new ArrayBuffer(inputData.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
for (let i = 0, offset = 0; i < inputData.length; i++, offset += 2) {
|
||||||
|
const s = Math.max(-1, Math.min(1, inputData[i]));
|
||||||
|
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the chunk
|
||||||
|
audioChunks.push(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
stopBtn.addEventListener('click', () => {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
recording = false;
|
||||||
|
|
||||||
|
// Disconnect processor
|
||||||
|
if (processor) {
|
||||||
|
processor.disconnect();
|
||||||
|
processor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop media stream tracks
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WAV file from collected chunks
|
||||||
|
createWavFile();
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
startBtn.disabled = false;
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
playBtn.disabled = false;
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
statusDisplay.textContent = "Recording stopped. Ready to play or download.";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create WAV file from collected PCM data
|
||||||
|
function createWavFile() {
|
||||||
|
// Combine all chunks into a single buffer
|
||||||
|
const length = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||||
|
const result = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
audioChunks.forEach(chunk => {
|
||||||
|
result.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create WAV header
|
||||||
|
const wavHeader = createWaveHeader(result.length, config);
|
||||||
|
|
||||||
|
// Combine header and PCM data
|
||||||
|
const wavData = new Uint8Array(wavHeader.length + result.length);
|
||||||
|
wavData.set(wavHeader, 0);
|
||||||
|
wavData.set(result, wavHeader.length);
|
||||||
|
|
||||||
|
// Create blob
|
||||||
|
audioBlob = new Blob([wavData], { type: 'audio/wav' });
|
||||||
|
audioUrl = URL.createObjectURL(audioBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WAV header
|
||||||
|
function createWaveHeader(dataLength, config) {
|
||||||
|
const byteRate = config.sampleRate * config.numChannels * (config.bitDepth / 8);
|
||||||
|
const blockAlign = config.numChannels * (config.bitDepth / 8);
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(44);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
// RIFF identifier
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
// RIFF chunk length
|
||||||
|
view.setUint32(4, 36 + dataLength, true);
|
||||||
|
// RIFF type
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
// Format chunk identifier
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
// Format chunk length
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
// Sample format (raw)
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
// Channel count
|
||||||
|
view.setUint16(22, config.numChannels, true);
|
||||||
|
// Sample rate
|
||||||
|
view.setUint32(24, config.sampleRate, true);
|
||||||
|
// Byte rate (sample rate * block align)
|
||||||
|
view.setUint32(28, byteRate, true);
|
||||||
|
// Block align (channel count * bytes per sample)
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
// Bits per sample
|
||||||
|
view.setUint16(34, config.bitDepth, true);
|
||||||
|
// Data chunk identifier
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
// Data chunk length
|
||||||
|
view.setUint32(40, dataLength, true);
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to write strings to DataView
|
||||||
|
function writeString(view, offset, string) {
|
||||||
|
for (let i = 0; i < string.length; i++) {
|
||||||
|
view.setUint8(offset + i, string.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play recorded audio
|
||||||
|
playBtn.addEventListener('click', () => {
|
||||||
|
if (!audioUrl) return;
|
||||||
|
|
||||||
|
const audio = new Audio(audioUrl);
|
||||||
|
audio.play();
|
||||||
|
statusDisplay.textContent = "Playing recording...";
|
||||||
|
|
||||||
|
audio.onended = () => {
|
||||||
|
statusDisplay.textContent = "Playback finished";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download recorded audio as WAV file
|
||||||
|
downloadBtn.addEventListener('click', () => {
|
||||||
|
if (!audioBlob) return;
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = audioUrl;
|
||||||
|
a.download = `recording_${config.sampleRate}Hz_${config.bitDepth}bit.wav`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
statusDisplay.textContent = "Download started";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup audio visualization
|
||||||
|
function setupVisualizer(source) {
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 64;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
const bufferLength = analyser.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = audioVisualizer.offsetWidth;
|
||||||
|
canvas.height = audioVisualizer.offsetHeight;
|
||||||
|
audioVisualizer.innerHTML = '';
|
||||||
|
audioVisualizer.appendChild(canvas);
|
||||||
|
|
||||||
|
const canvasCtx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
canvasCtx.fillStyle = 'rgb(200, 200, 200)';
|
||||||
|
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
const barHeight = dataArray[i] / 2;
|
||||||
|
|
||||||
|
canvasCtx.fillStyle = `rgb(${barHeight + 100}, 50, 50)`;
|
||||||
|
canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
|
||||||
|
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
src/pages/web-tools/text-to-speech.astro
Normal file
9
src/pages/web-tools/text-to-speech.astro
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import TextToSpeech from "../../components/Tools/TextToSpeech";
|
||||||
|
import FreeTextToSpeech from "../../components/Tools/FreeTextToSpeech";
|
||||||
|
---
|
||||||
|
<Layout title="">
|
||||||
|
<TextToSpeech client:load />
|
||||||
|
<FreeTextToSpeech client:load />
|
||||||
|
</Layout>
|
||||||
492
src/pages/web-tools/vaani-ai-chat.astro
Normal file
492
src/pages/web-tools/vaani-ai-chat.astro
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro"
|
||||||
|
---
|
||||||
|
<Layout title="Voice Chat">
|
||||||
|
<div class="container mx-auto max-w-2xl h-screen flex flex-col bg-gray-100 my-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-[#6d9e37] text-white p-4 shadow-md">
|
||||||
|
<div class="flex items-center gap-x-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h1 class="font-bold text-lg">Voice Assistant</h1>
|
||||||
|
<p class="text-xs opacity-80" id="status">Ready to record</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Messages -->
|
||||||
|
<div id="chatMessages" class="flex-1 max-h-[400px] overflow-y-auto p-4 space-y-3 bg-gray-50">
|
||||||
|
<!-- Welcome message -->
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-white rounded-xl rounded-tl-none py-2 px-4 max-w-xs shadow-sm">
|
||||||
|
<p class="text-gray-800">Hello! Tap the microphone to start talking with me.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Visualizer -->
|
||||||
|
<div id="audioVisualizer" class="h-16 bg-gray-200 mx-4 rounded-lg flex items-center justify-center">
|
||||||
|
<div class="w-full h-8 flex items-center justify-center space-x-1" id="visualizerBars"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="p-4 bg-white border-t border-gray-200 flex justify-center">
|
||||||
|
<button id="startBtn" class="p-3 bg-[#6d9e37] rounded-full text-white hover:bg-[#6d9e37]/50 transition-colors shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="stopBtn" disabled class="p-3 bg-red-500 rounded-full text-white hover:bg-red-600 transition-colors shadow-lg ml-4 opacity-50 cursor-not-allowed hidden">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
// DOM elements
|
||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
const stopBtn = document.getElementById('stopBtn');
|
||||||
|
const chatMessages = document.getElementById('chatMessages');
|
||||||
|
const audioVisualizer = document.getElementById('audioVisualizer');
|
||||||
|
const visualizerBars = document.getElementById('visualizerBars');
|
||||||
|
const statusDisplay = document.getElementById('status');
|
||||||
|
|
||||||
|
// Audio variables
|
||||||
|
let audioContext;
|
||||||
|
let mediaStream;
|
||||||
|
let processor;
|
||||||
|
let recording = false;
|
||||||
|
let audioChunks = [];
|
||||||
|
let audioBlob;
|
||||||
|
let silenceTimer;
|
||||||
|
let lastSoundTime;
|
||||||
|
let activeAudioElement = null;
|
||||||
|
let lastResponseAudio = null;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const config = {
|
||||||
|
sampleRate: 16000, // 16kHz sample rate
|
||||||
|
numChannels: 1, // Mono
|
||||||
|
bitDepth: 16, // 16-bit
|
||||||
|
silenceThreshold: 0.02, // Threshold for silence detection
|
||||||
|
silenceTimeout: 1500, // Stop after 1.5s of silence
|
||||||
|
maxRecordingTime: 30000 // Max recording time in ms (30 seconds)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create visualizer bars
|
||||||
|
function createVisualizerBars() {
|
||||||
|
visualizerBars.innerHTML = '';
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'bg-gray-300 h-1 w-1 rounded-full transition-all duration-75';
|
||||||
|
bar.style.height = '2px';
|
||||||
|
visualizerBars.appendChild(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message to chat
|
||||||
|
function addMessage(text = null, audioUrl = null, isUser = false, isSystem = false) {
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = `flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`;
|
||||||
|
|
||||||
|
const bubble = document.createElement('div');
|
||||||
|
bubble.className = `rounded-xl py-2 px-4 max-w-xs shadow-sm ${isUser ? 'bg-green-100 rounded-tr-none' : isSystem ? 'bg-gray-200 rounded-tl-none' : 'bg-white rounded-tl-none'}`;
|
||||||
|
|
||||||
|
if (audioUrl) {
|
||||||
|
// Audio message with text
|
||||||
|
const audioId = `audio-${Date.now()}`;
|
||||||
|
bubble.innerHTML = `
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
${text ? `<p class="text-gray-800">${text}</p>` : ''}
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button class="play-btn p-2 rounded-full bg-white shadow" data-audio="${audioId}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
|
<div class="progress-bar bg-green-600 h-2.5 rounded-full" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 time-display">0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio id="${audioId}" src="${audioUrl}" preload="auto"></audio>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Store reference to the last response audio
|
||||||
|
if (!isUser) {
|
||||||
|
lastResponseAudio = { audioId, element: document.getElementById(audioId) };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Text message
|
||||||
|
bubble.innerHTML = `<p class="text-gray-800">${text}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageDiv.appendChild(bubble);
|
||||||
|
chatMessages.appendChild(messageDiv);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
|
||||||
|
return bubble;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play the last response audio
|
||||||
|
function playLastResponse() {
|
||||||
|
if (lastResponseAudio && lastResponseAudio.element) {
|
||||||
|
const audioElement = lastResponseAudio.element;
|
||||||
|
const playBtn = document.querySelector(`[data-audio="${lastResponseAudio.audioId}"]`);
|
||||||
|
|
||||||
|
if (playBtn) {
|
||||||
|
// Trigger click on the play button to ensure all UI updates are handled
|
||||||
|
playBtn.click();
|
||||||
|
} else {
|
||||||
|
// Fallback to direct play if button not found
|
||||||
|
audioElement.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup audio playback controls
|
||||||
|
function setupAudioControls() {
|
||||||
|
document.querySelectorAll('.play-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const audioId = this.getAttribute('data-audio');
|
||||||
|
const audioElement = document.getElementById(audioId);
|
||||||
|
const progressBar = this.closest('.flex').querySelector('.progress-bar');
|
||||||
|
const timeDisplay = this.closest('.flex').querySelector('.time-display');
|
||||||
|
|
||||||
|
// Stop any currently playing audio
|
||||||
|
if (activeAudioElement && activeAudioElement !== audioElement) {
|
||||||
|
activeAudioElement.pause();
|
||||||
|
activeAudioElement.currentTime = 0;
|
||||||
|
const activeBtn = document.querySelector(`[data-audio="${activeAudioElement.id}"]`);
|
||||||
|
if (activeBtn) {
|
||||||
|
activeBtn.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
activeBtn.closest('.flex').querySelector('.progress-bar').style.width = '0%';
|
||||||
|
activeBtn.closest('.flex').querySelector('.time-display').textContent = '0:00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioElement.paused) {
|
||||||
|
// Play audio
|
||||||
|
audioElement.play();
|
||||||
|
activeAudioElement = audioElement;
|
||||||
|
this.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
audioElement.addEventListener('timeupdate', function() {
|
||||||
|
const progress = (audioElement.currentTime / audioElement.duration) * 100;
|
||||||
|
progressBar.style.width = `${progress}%`;
|
||||||
|
|
||||||
|
// Update time display
|
||||||
|
const minutes = Math.floor(audioElement.currentTime / 60);
|
||||||
|
const seconds = Math.floor(audioElement.currentTime % 60).toString().padStart(2, '0');
|
||||||
|
timeDisplay.textContent = `${minutes}:${seconds}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset when finished
|
||||||
|
audioElement.addEventListener('ended', function() {
|
||||||
|
this.currentTime = 0;
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
timeDisplay.textContent = '0:00';
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Pause audio
|
||||||
|
audioElement.pause();
|
||||||
|
this.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
startBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
statusDisplay.textContent = "Starting microphone...";
|
||||||
|
addMessage("Recording...", null, true);
|
||||||
|
|
||||||
|
// Get microphone access
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
|
// Create audio context
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||||
|
sampleRate: config.sampleRate
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create processor node
|
||||||
|
processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
processor.onaudioprocess = processAudio;
|
||||||
|
|
||||||
|
// Create source and connect
|
||||||
|
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
|
||||||
|
// Setup visualization
|
||||||
|
setupVisualizer(source);
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
recording = true;
|
||||||
|
audioChunks = [];
|
||||||
|
lastSoundTime = Date.now();
|
||||||
|
|
||||||
|
// Start silence detection
|
||||||
|
silenceTimer = setInterval(checkSilence, 200);
|
||||||
|
|
||||||
|
// Start max recording time timer
|
||||||
|
setTimeout(() => {
|
||||||
|
if (recording) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}, config.maxRecordingTime);
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
startBtn.classList.add('hidden');
|
||||||
|
stopBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
|
||||||
|
statusDisplay.textContent = "Listening...";
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
statusDisplay.textContent = "Error: " + error.message;
|
||||||
|
addMessage("Error: " + error.message, null, false, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process audio data with silence detection
|
||||||
|
function processAudio(event) {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
const inputData = event.inputBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
// Check for sound activity
|
||||||
|
const isSound = isSoundDetected(inputData);
|
||||||
|
if (isSound) {
|
||||||
|
lastSoundTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to 16-bit PCM
|
||||||
|
const buffer = new ArrayBuffer(inputData.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
for (let i = 0, offset = 0; i < inputData.length; i++, offset += 2) {
|
||||||
|
const s = Math.max(-1, Math.min(1, inputData[i]));
|
||||||
|
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
audioChunks.push(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple sound detection
|
||||||
|
function isSoundDetected(inputData) {
|
||||||
|
for (let i = 0; i < inputData.length; i++) {
|
||||||
|
if (Math.abs(inputData[i]) > config.silenceThreshold) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for silence timeout
|
||||||
|
function checkSilence() {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
const silenceDuration = Date.now() - lastSoundTime;
|
||||||
|
if (silenceDuration > config.silenceTimeout) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop recording and process
|
||||||
|
async function stopRecording() {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
// Clear silence timer
|
||||||
|
clearInterval(silenceTimer);
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
recording = false;
|
||||||
|
|
||||||
|
// Disconnect audio nodes
|
||||||
|
if (processor) {
|
||||||
|
processor.disconnect();
|
||||||
|
processor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop media stream
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WAV file
|
||||||
|
createWavFile();
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
startBtn.classList.remove('hidden');
|
||||||
|
stopBtn.classList.add('hidden');
|
||||||
|
statusDisplay.textContent = "Processing...";
|
||||||
|
|
||||||
|
// Send to API
|
||||||
|
await sendToAPI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual stop button
|
||||||
|
stopBtn.addEventListener('click', stopRecording);
|
||||||
|
|
||||||
|
// Create WAV file
|
||||||
|
function createWavFile() {
|
||||||
|
const length = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||||
|
const result = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
audioChunks.forEach(chunk => {
|
||||||
|
result.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wavHeader = createWaveHeader(result.length, config);
|
||||||
|
const wavData = new Uint8Array(wavHeader.length + result.length);
|
||||||
|
wavData.set(wavHeader, 0);
|
||||||
|
wavData.set(result, wavHeader.length);
|
||||||
|
|
||||||
|
audioBlob = new Blob([wavData], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WAV header
|
||||||
|
function createWaveHeader(dataLength, config) {
|
||||||
|
const byteRate = config.sampleRate * config.numChannels * (config.bitDepth / 8);
|
||||||
|
const blockAlign = config.numChannels * (config.bitDepth / 8);
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(44);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
view.setUint32(4, 36 + dataLength, true);
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, config.numChannels, true);
|
||||||
|
view.setUint32(24, config.sampleRate, true);
|
||||||
|
view.setUint32(28, byteRate, true);
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
view.setUint16(34, config.bitDepth, true);
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
view.setUint32(40, dataLength, true);
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to write strings to DataView
|
||||||
|
function writeString(view, offset, string) {
|
||||||
|
for (let i = 0; i < string.length; i++) {
|
||||||
|
view.setUint8(offset + i, string.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send audio to API
|
||||||
|
async function sendToAPI() {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('audio', audioBlob, 'recording.wav');
|
||||||
|
|
||||||
|
const response = await fetch('https://vaani-pocndhvgmcnbacgb.siliconpin.com/upload-audio', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('API request failed');
|
||||||
|
|
||||||
|
// Check content type to handle both JSON and audio responses
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
let responseText = "Here's what I think about that";
|
||||||
|
let audioData;
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
// Handle JSON response with both text and audio
|
||||||
|
const result = await response.json();
|
||||||
|
responseText = result.text || responseText;
|
||||||
|
console.log('responseText', responseText)
|
||||||
|
audioData = result.audio;
|
||||||
|
} else if (contentType.includes('audio/wav')) {
|
||||||
|
// Handle direct audio response
|
||||||
|
audioData = await response.arrayBuffer();
|
||||||
|
} else {
|
||||||
|
throw new Error('Unexpected response type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioUrl = URL.createObjectURL(new Blob([new Uint8Array(audioData)], { type: 'audio/wav' }));
|
||||||
|
|
||||||
|
// Update UI with both text and audio
|
||||||
|
addMessage(responseText, audioUrl, false);
|
||||||
|
statusDisplay.textContent = "Ready to record";
|
||||||
|
|
||||||
|
// Setup playback controls
|
||||||
|
setupAudioControls();
|
||||||
|
|
||||||
|
// Auto-play the response
|
||||||
|
setTimeout(playLastResponse, 500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
statusDisplay.textContent = "Error: " + error.message;
|
||||||
|
addMessage("Error processing request", null, false, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup audio visualization
|
||||||
|
function setupVisualizer(source) {
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 32;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
const bufferLength = analyser.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
createVisualizerBars();
|
||||||
|
const bars = visualizerBars.querySelectorAll('div');
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
for (let i = 0; i < bars.length; i++) {
|
||||||
|
const value = dataArray[i % bufferLength] / 255;
|
||||||
|
const height = value * 30;
|
||||||
|
bars[i].style.height = `${Math.max(2, height)}px`;
|
||||||
|
bars[i].style.backgroundColor = `rgb(${Math.floor(value * 100 + 155)}, ${Math.floor(value * 50 + 50)}, 50)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
createVisualizerBars();
|
||||||
|
setupAudioControls();
|
||||||
|
</script>
|
||||||
28
src/styles/markdown.css
Normal file
28
src/styles/markdown.css
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/* markdown.css */
|
||||||
|
|
||||||
|
/* Wrapper div scroll korbe */
|
||||||
|
.table-scroll-wrapper {
|
||||||
|
@apply overflow-x-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown-er table scrollable + bordered hobe */
|
||||||
|
.table-scroll-wrapper table {
|
||||||
|
@apply w-max min-w-full border border-gray-300 block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table cell border, padding */
|
||||||
|
.table-scroll-wrapper th,
|
||||||
|
.table-scroll-wrapper td {
|
||||||
|
@apply border border-gray-300 px-4 py-2 text-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* markdown.css */
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #d1d5db; /* Tailwind's gray-300 */
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,9 @@ export default {
|
|||||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
boxShadow: {
|
||||||
|
xl: '0 10px 30px rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
'sp-dark': '#2b323b',
|
'sp-dark': '#2b323b',
|
||||||
'sp-light': '#c7ccd5',
|
'sp-light': '#c7ccd5',
|
||||||
|
|||||||
Reference in New Issue
Block a user