This commit is contained in:
parent
343971540f
commit
45a967644c
|
@ -1,5 +1,8 @@
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'export',
|
output: 'export',
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
210
package-lock.json
generated
210
package-lock.json
generated
|
@ -11,16 +11,20 @@
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"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",
|
||||||
|
"framer-motion": "^12.5.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.1.1",
|
"next": "15.1.1",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
@ -722,6 +726,67 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-label": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-portal": {
|
"node_modules/@radix-ui/react-portal": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz",
|
||||||
|
@ -837,6 +902,73 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.0.2",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.0",
|
||||||
|
"@radix-ui/react-use-size": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
||||||
|
@ -899,6 +1031,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-size": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
@ -2890,6 +3053,32 @@
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.5.0.tgz",
|
||||||
|
"integrity": "sha512-buPlioFbH9/W7rDzYh1C09AuZHAk2D1xTA1BlounJ2Rb9aRg84OXexP0GLd+R83v0khURdMX7b5MKnGTaSg5iA==",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.5.0",
|
||||||
|
"motion-utils": "^12.5.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
@ -4562,6 +4751,19 @@
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.5.0.tgz",
|
||||||
|
"integrity": "sha512-uH2PETDh7m+Hjd1UQQ56yHqwn83SAwNjimNPE/kC+Kds0t4Yh7+29rfo5wezVFpPOv57U4IuWved5d1x0kNhbQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.5.0.tgz",
|
||||||
|
"integrity": "sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA=="
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
@ -5218,6 +5420,14 @@
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
|
|
@ -14,16 +14,22 @@
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"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",
|
||||||
|
"framer-motion": "^12.5.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.1.1",
|
"next": "15.1.1",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
|
5566
pnpm-lock.yaml
Normal file
5566
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
Binary file not shown.
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 24 KiB |
24
src/app/dashboard/page.jsx
Normal file
24
src/app/dashboard/page.jsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import Header from '@/components/header';
|
||||||
|
import Footer from '@/components/footer';
|
||||||
|
import DashboardClient from '@/components/dashboard/dashboard-client';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
function DashboardPageContent() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main className="flex-grow">
|
||||||
|
<DashboardClient />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<DashboardPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import { AuthProvider } from '@/contexts/AuthContext'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
@ -11,10 +12,17 @@ export const metadata = {
|
||||||
export default function RootLayout({ children }) {
|
export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
(<html lang="en" className="scroll-smooth">
|
(<html lang="en" className="scroll-smooth">
|
||||||
<body className={`${inter.className} min-h-screen bg-black text-gray-100`}>
|
<body className={`${inter.className} min-h-screen bg-gray-950 text-gray-300 relative`}>
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="fixed inset-0 -z-10 bg-gradient-to-br from-gray-950 via-black to-gray-950">
|
||||||
{children}
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(50,100,255,0.1),transparent_40%)]" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_80%_20%,rgba(120,50,255,0.1),transparent_40%)]" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(150,150,150,0.05),transparent_50%)]" />
|
||||||
</div>
|
</div>
|
||||||
|
<AuthProvider>
|
||||||
|
<div className="flex flex-col min-h-screen relative z-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>)
|
</html>)
|
||||||
);
|
);
|
||||||
|
|
|
@ -17,6 +17,7 @@ export default function Home() {
|
||||||
<Team />
|
<Team />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
{/* we should be using the modmail tickets thing although i would like find a way to link this up with discord, would be cool
|
||||||
<Script
|
<Script
|
||||||
id="chatwoot-sdk"
|
id="chatwoot-sdk"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
|
@ -39,7 +40,8 @@ export default function Home() {
|
||||||
})(document,"script");
|
})(document,"script");
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
*/}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
114
src/components/dashboard/QuickActionsCard.jsx
Normal file
114
src/components/dashboard/QuickActionsCard.jsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Phone } from 'lucide-react';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DndToggle } from './dnd-toggle';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export function QuickActionsCard({ details, loading }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [isCalling, setIsCalling] = useState(false);
|
||||||
|
const [callMode, setCallMode] = useState('hold');
|
||||||
|
const [callerId, setCallerId] = useState('');
|
||||||
|
const [isCallMeDialogOpen, setIsCallMeDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleCallMe = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setIsCalling(true);
|
||||||
|
try {
|
||||||
|
let url = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/callme?mode=${callMode}`;
|
||||||
|
if (callerId) {
|
||||||
|
url += `&callerId=${encodeURIComponent(callerId)}`;
|
||||||
|
}
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setIsCallMeDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initiate call", error);
|
||||||
|
alert('Failed to initiate call.');
|
||||||
|
} finally {
|
||||||
|
setIsCalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
<Skeleton className="h-4 w-48 mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pt-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
|
<CardDescription>Common actions for your extension.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<DndToggle />
|
||||||
|
<Dialog open={isCallMeDialogOpen} onOpenChange={setIsCallMeDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full"><Phone className="h-4 w-4 mr-2" /> Call Me Test</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="bg-gray-950 border-gray-800 text-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Call Me Test</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Select a mode for the test call. This will call your extension.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="mode" className="text-right">
|
||||||
|
Mode
|
||||||
|
</Label>
|
||||||
|
<Select value={callMode} onValueChange={setCallMode}>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select a mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hold">Music on Hold</SelectItem>
|
||||||
|
<SelectItem value="echo">Echo Test</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="callerId" className="text-right">
|
||||||
|
Caller ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="callerId"
|
||||||
|
value={callerId}
|
||||||
|
onChange={(e) => setCallerId(e.target.value)}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={`Optional (e.g., ${details?.extensionId})`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCallMe} disabled={isCalling} className="w-full bg-blue-600 hover:bg-blue-700">
|
||||||
|
<Phone className="h-4 w-4 mr-2" />
|
||||||
|
{isCalling ? 'Calling...' : 'Initiate Call'}
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
137
src/components/dashboard/SipCredentialsCard.jsx
Normal file
137
src/components/dashboard/SipCredentialsCard.jsx
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Copy, KeyRound, Server, User } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
function CredentialRow({ icon, label, value, onCopy }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between bg-gray-900 p-3 rounded-lg border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{icon}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400">{label}</div>
|
||||||
|
<code className="text-white">{value}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onCopy}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SipCredentialsCard({ details, loading }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [newSecret, setNewSecret] = useState(null);
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const handleResetSecret = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/resetsecret`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setNewSecret(data.newSecret);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset secret", error);
|
||||||
|
alert('Failed to reset secret.');
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
alert('Copied to clipboard!');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64 mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pt-4">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>SIP Credentials</CardTitle>
|
||||||
|
<CardDescription>Use these details to connect your SIP client.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<CredentialRow icon={<Server className="h-5 w-5 text-gray-400" />} label="SIP Server" value="pbx.litenet.tel" onCopy={() => copyToClipboard('pbx.litenet.tel')} />
|
||||||
|
<CredentialRow icon={<User className="h-5 w-5 text-gray-400" />} label="SIP Username" value={details?.extensionId} onCopy={() => copyToClipboard(details?.extensionId)} />
|
||||||
|
|
||||||
|
<Dialog open={!!newSecret} onOpenChange={(open) => !open && setNewSecret(null)}>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" disabled={isResetting} className="w-full">
|
||||||
|
<KeyRound className="h-4 w-4 mr-2" />
|
||||||
|
{isResetting ? 'Resetting...' : 'Reset SIP Secret'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="bg-gray-950 border-gray-800 text-white">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-gray-400">
|
||||||
|
Resetting your SIP secret will require you to update it on all devices registered to this extension. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-gray-700 hover:bg-gray-800">Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={handleResetSecret}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<DialogContent className="bg-gray-950 border-gray-800 text-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Secret Reset Successfully</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Your new SIP secret is shown below. Copy it now, as you will not be able to see it again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center space-x-2 bg-black p-3 rounded-md border border-gray-700">
|
||||||
|
<code className="text-lg font-mono flex-grow text-green-400">{newSecret}</code>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(newSecret)}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
20
src/components/dashboard/StatCard.jsx
Normal file
20
src/components/dashboard/StatCard.jsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export function StatCard({ title, value, icon, loading }) {
|
||||||
|
if (loading) {
|
||||||
|
return <Skeleton className="h-28 w-full bg-gray-900/80" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800 hover:border-blue-600/50 transition-colors duration-300">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-400">{title}</CardTitle>
|
||||||
|
{icon}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-white">{value || '...'}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
173
src/components/dashboard/actions-card.jsx
Normal file
173
src/components/dashboard/actions-card.jsx
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Copy, Phone, KeyRound } from 'lucide-react';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
|
||||||
|
export function ActionsCard({ details }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [isCalling, setIsCalling] = useState(false);
|
||||||
|
const [callMode, setCallMode] = useState('hold');
|
||||||
|
const [callerId, setCallerId] = useState('');
|
||||||
|
const [isCallMeDialogOpen, setIsCallMeDialogOpen] = useState(false);
|
||||||
|
const [newSecret, setNewSecret] = useState(null);
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const handleCallMe = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setIsCalling(true);
|
||||||
|
try {
|
||||||
|
let url = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/callme?mode=${callMode}`;
|
||||||
|
if (callerId) {
|
||||||
|
url += `&callerId=${encodeURIComponent(callerId)}`;
|
||||||
|
}
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setIsCallMeDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initiate call", error);
|
||||||
|
alert('Failed to initiate call.');
|
||||||
|
} finally {
|
||||||
|
setIsCalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSecret = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/resetsecret`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setNewSecret(data.newSecret);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset secret", error);
|
||||||
|
alert('Failed to reset secret.');
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(newSecret);
|
||||||
|
alert('Secret copied to clipboard!');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
|
<CardDescription>Perform common actions for your extension.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Dialog open={isCallMeDialogOpen} onOpenChange={setIsCallMeDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline"><Phone className="h-4 w-4 mr-2" /> Call Me Test</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Call Me Test</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a mode for the test call. This will call your extension.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="mode" className="text-right">
|
||||||
|
Mode
|
||||||
|
</Label>
|
||||||
|
<Select value={callMode} onValueChange={setCallMode}>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select a mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hold">Music on Hold</SelectItem>
|
||||||
|
<SelectItem value="echo">Echo Test</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="callerId" className="text-right">
|
||||||
|
Caller ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="callerId"
|
||||||
|
value={callerId}
|
||||||
|
onChange={(e) => setCallerId(e.target.value)}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={`Optional (defaults to ${details?.extensionId})`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCallMe} disabled={isCalling}>
|
||||||
|
<Phone className="h-4 w-4 mr-2" />
|
||||||
|
{isCalling ? 'Calling...' : 'Initiate Call'}
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={!!newSecret} onOpenChange={(open) => !open && setNewSecret(null)}>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" disabled={isResetting}>
|
||||||
|
<KeyRound className="h-4 w-4 mr-2" />
|
||||||
|
{isResetting ? 'Resetting...' : 'Reset SIP Secret'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="bg-gray-950 border-gray-800 text-white">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-gray-400">
|
||||||
|
Resetting your SIP secret will require you to update it on all devices registered to this extension. Are you sure you want to continue?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-gray-700 hover:bg-gray-800">Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={handleResetSecret}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Secret Reset Successfully</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Your new SIP secret is shown below. Please save it in a secure place.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center space-x-2 bg-gray-900 p-3 rounded-md">
|
||||||
|
<code className="text-lg font-mono flex-grow">{newSecret}</code>
|
||||||
|
<Button variant="ghost" size="icon" onClick={copyToClipboard}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
128
src/components/dashboard/active-calls.jsx
Normal file
128
src/components/dashboard/active-calls.jsx
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { RefreshCw, PhoneOff, PhoneIncoming, ArrowLeftRight, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
function CallCard({ call }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/80 p-4 rounded-lg border border-gray-800 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="text-sm font-semibold text-white">{call.caller.name}</div>
|
||||||
|
<div className="text-xs text-blue-400 font-mono">{call.caller.number}</div>
|
||||||
|
</div>
|
||||||
|
<ArrowLeftRight className="h-5 w-5 text-gray-500 flex-shrink-0" />
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="text-sm font-semibold text-white">{call.connectedLine.name}</div>
|
||||||
|
<div className="text-xs text-blue-400 font-mono">{call.connectedLine.number}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>{call.duration}s</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-green-400">{call.state}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveCalls() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [calls, setCalls] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
|
const fetchCalls = useCallback(async (isManualRefresh = false) => {
|
||||||
|
if (!token) return;
|
||||||
|
if (isManualRefresh) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/calls`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setCalls(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch active calls", error);
|
||||||
|
setCalls([]);
|
||||||
|
} finally {
|
||||||
|
if (isInitialLoad || isManualRefresh) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [token, isInitialLoad]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
fetchCalls();
|
||||||
|
const interval = setInterval(() => fetchCalls(false), 5000); // Refresh every 5 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, fetchCalls]);
|
||||||
|
|
||||||
|
const handleHangup = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/calls`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
fetchCalls(true); // Refresh immediately and show loading state
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to hangup calls", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader className="flex flex-row justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Active Calls</CardTitle>
|
||||||
|
<CardDescription>A list of your current calls.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{calls.length > 0 && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleHangup}>
|
||||||
|
<PhoneOff className="h-4 w-4 mr-2" /> Hangup All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="icon" onClick={() => fetchCalls(true)} disabled={loading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
) : calls.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{calls.map(call => (
|
||||||
|
<CallCard key={call.uniqueId} call={call} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-gray-500 flex flex-col items-center justify-center">
|
||||||
|
<PhoneIncoming className="mx-auto h-12 w-12 mb-4 text-gray-600" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-300">No Active Calls</h3>
|
||||||
|
<p className="text-sm">When you are in a call, it will appear here.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
252
src/components/dashboard/api-keys.jsx
Normal file
252
src/components/dashboard/api-keys.jsx
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Trash2, PlusCircle, Copy, KeyRound } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
export default function ApiKeys() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [keys, setKeys] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newKey, setNewKey] = useState(null);
|
||||||
|
const [expiresInDays, setExpiresInDays] = useState(90);
|
||||||
|
const [filterType, setFilterType] = useState('all');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [keyToDelete, setKeyToDelete] = useState(null);
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const fetchKeys = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setKeys(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch API keys", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchKeys();
|
||||||
|
}, [fetchKeys]);
|
||||||
|
|
||||||
|
const handleCreateKey = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ expiresInDays })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setNewKey(data.key);
|
||||||
|
fetchKeys();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create key", error);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = async (keyToDelete) => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys/${keyToDelete}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
fetchKeys();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete key", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
alert('Key copied to clipboard!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredKeys = keys
|
||||||
|
.filter(key => filterType === 'all' || key.type === filterType)
|
||||||
|
.filter(key => key.key.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-6 bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<CardTitle>API Keys</CardTitle>
|
||||||
|
<CardDescription>Manage your API keys for programmatic access.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={(open) => {
|
||||||
|
if (isCreating) return;
|
||||||
|
setIsCreateDialogOpen(open);
|
||||||
|
if (!open) setNewKey(null);
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button><PlusCircle className="h-4 w-4 mr-2" /> Create New Key</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{newKey ? 'API Key Created' : 'Create New API Key'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{newKey ? 'Your new API key is shown below. Please save it now. You will not be able to see it again.' : 'Set an expiration for your new key.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{newKey ? (
|
||||||
|
<div className="flex items-center space-x-2 bg-gray-900 p-3 rounded-md">
|
||||||
|
<code className="text-sm font-mono flex-grow break-all">{newKey}</code>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(newKey)}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="expiresIn">Expires in (days)</Label>
|
||||||
|
<Input id="expiresIn" type="number" value={expiresInDays} onChange={(e) => setExpiresInDays(parseInt(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreateKey} className="w-full" disabled={isCreating}>
|
||||||
|
{isCreating ? 'Generating...' : 'Generate Key'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-between mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Search keys..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="max-w-xs bg-gray-900 border-gray-700"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 p-1 bg-gray-900 rounded-lg">
|
||||||
|
<Button size="sm" variant={filterType === 'all' ? 'secondary' : 'ghost'} className="w-full sm:w-auto" onClick={() => setFilterType('all')}>All</Button>
|
||||||
|
<Button size="sm" variant={filterType === 'api' ? 'secondary' : 'ghost'} className="w-full sm:w-auto" onClick={() => setFilterType('api')}>API</Button>
|
||||||
|
<Button size="sm" variant={filterType === 'session' ? 'secondary' : 'ghost'} className="w-full sm:w-auto" onClick={() => setFilterType('session')}>Session</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
) : filteredKeys.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Key</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredKeys.map(key => {
|
||||||
|
const isCurrentSession = key.type === 'session' && token?.startsWith(key.key);
|
||||||
|
return (
|
||||||
|
<TableRow key={key.key} className="hover:bg-gray-900">
|
||||||
|
<TableCell className="font-mono">
|
||||||
|
{key.type === 'api' ? `${key.key.substring(0, 12)}...` : key.key}
|
||||||
|
{isCurrentSession && <span className="text-xs text-gray-400 ml-2">(this session)</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className={key.type === 'session' ? 'border-gray-600/50 bg-gray-900/20 text-gray-300' : 'border-blue-600/50 bg-blue-900/20 text-blue-300'}>
|
||||||
|
{key.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{key.ipAddress || 'N/A'}</TableCell>
|
||||||
|
<TableCell>{format(new Date(key.createdAt), 'PP')}</TableCell>
|
||||||
|
<TableCell>{key.expiresAt ? format(new Date(key.expiresAt), 'PP') : 'Never'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{!isCurrentSession && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setKeyToDelete(key)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-10 text-gray-400">
|
||||||
|
<KeyRound className="mx-auto h-12 w-12 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
{searchTerm || filterType !== 'all' ? 'No Matching Keys' : 'No API Keys'}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{searchTerm || filterType !== 'all'
|
||||||
|
? 'Try adjusting your search or filter.'
|
||||||
|
: 'Create your first API key to get started with integrations.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<AlertDialog open={!!keyToDelete} onOpenChange={setKeyToDelete}>
|
||||||
|
<AlertDialogContent className="bg-gray-950 border-gray-800 text-white">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-gray-400">
|
||||||
|
This action cannot be undone. This will permanently delete the key
|
||||||
|
<code className="text-sm font-mono bg-gray-800 rounded p-1 mx-1">
|
||||||
|
{keyToDelete?.type === 'api' ? `${keyToDelete?.key.substring(0, 12)}...` : keyToDelete?.key}
|
||||||
|
</code>
|
||||||
|
and revoke its access.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-gray-700 hover:bg-gray-800">Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => {
|
||||||
|
if (keyToDelete) {
|
||||||
|
handleDeleteKey(keyToDelete.key);
|
||||||
|
}
|
||||||
|
setKeyToDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
22
src/components/dashboard/call-history.jsx
Normal file
22
src/components/dashboard/call-history.jsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { History } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function CallHistory() {
|
||||||
|
return (
|
||||||
|
<Card className="mt-6 bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Call History</CardTitle>
|
||||||
|
<CardDescription>A log of your recent calls.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-10 text-gray-400">
|
||||||
|
<History className="mx-auto h-12 w-12 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">Coming Soon!</h3>
|
||||||
|
<p>This feature is currently under development. Check back later!</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
243
src/components/dashboard/dashboard-client.jsx
Normal file
243
src/components/dashboard/dashboard-client.jsx
Normal file
|
@ -0,0 +1,243 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import ExtensionDetails from './extension-details';
|
||||||
|
import ApiKeys from './api-keys';
|
||||||
|
import CallHistory from './call-history';
|
||||||
|
import { LogOut, User, Phone, Voicemail } from 'lucide-react';
|
||||||
|
import { ActiveCalls } from './active-calls';
|
||||||
|
import { SipCredentialsCard } from './SipCredentialsCard';
|
||||||
|
import { QuickActionsCard } from './QuickActionsCard';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
function OverviewCard({ details, deviceStatus, loading }) {
|
||||||
|
const Stat = ({ icon, label, value, loading }) => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded-lg">{icon}</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400">{label}</div>
|
||||||
|
{loading ? <Skeleton className="h-6 w-24 mt-1" /> : <div className="text-lg font-bold text-white">{value}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-950/50 border border-gray-800 rounded-lg p-6">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<Stat icon={<User className="h-5 w-5 text-blue-400" />} label="Extension" value={details?.extensionId || '...'} loading={loading} />
|
||||||
|
<Stat icon={<Phone className="h-5 w-5 text-green-400" />} label="Device Status" value={deviceStatus?.deviceState || 'Unknown'} loading={loading} />
|
||||||
|
<Stat icon={<Voicemail className="h-5 w-5 text-purple-400" />} label="Voicemail" value={details?.user?.voicemail === 'default' ? 'Enabled' : 'Disabled'} loading={loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function DashboardClient() {
|
||||||
|
const { isLoggedIn, loading, logout, token, noExtension } = useAuth();
|
||||||
|
const [details, setDetails] = useState(null);
|
||||||
|
const [detailsLoading, setDetailsLoading] = useState(true);
|
||||||
|
const [deviceStatus, setDeviceStatus] = useState(null);
|
||||||
|
const [deviceStatusLoading, setDeviceStatusLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setDetailsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDetails = async () => {
|
||||||
|
setDetailsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setDetails(data);
|
||||||
|
} else {
|
||||||
|
setDetails(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch details", err);
|
||||||
|
setDetails(null);
|
||||||
|
} finally {
|
||||||
|
setDetailsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDetails();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setDeviceStatusLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDeviceStatus = async () => {
|
||||||
|
// No need to set loading to true every time for a silent refresh
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/devicestatus`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setDeviceStatus(data);
|
||||||
|
} else {
|
||||||
|
setDeviceStatus(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch device status", err);
|
||||||
|
setDeviceStatus(null);
|
||||||
|
} finally {
|
||||||
|
if (deviceStatusLoading) setDeviceStatusLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDeviceStatus();
|
||||||
|
const interval = setInterval(fetchDeviceStatus, 5000); // Poll every 5 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [token, deviceStatusLoading]);
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
window.location.href = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/auth/discord`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container py-12">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<Skeleton className="h-10 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-28 mb-6" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noExtension) {
|
||||||
|
return (
|
||||||
|
<div className="container flex flex-col items-center justify-center text-center py-24">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">No Extension Found</h1>
|
||||||
|
<p className="text-gray-400 mb-8 max-w-md">
|
||||||
|
It looks like you don't have a LiteNet extension yet. Please join our Discord server and use the <code>/new</code> command to register for one.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="https://discord.litenet.tel" target="_blank" rel="noopener noreferrer">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="mr-2 h-4 w-4">
|
||||||
|
<circle cx="9" cy="12" r="1" />
|
||||||
|
<circle cx="15" cy="12" r="1" />
|
||||||
|
<path d="M7.5 7.5c3.5-1 5.5-1 9 0" />
|
||||||
|
<path d="M7 16.5c3.5 1 6.5 1 10 0" />
|
||||||
|
<path
|
||||||
|
d="M15.5 17c0 1 1.5 3 2 3 1.5 0 2.833-1.667 3.5-3 .667-1.667.5-5.833-1.5-11.5-1.457-1.015-3-1.34-4.5-1.5l-1 2.5" />
|
||||||
|
<path
|
||||||
|
d="M8.5 17c0 1-1.356 3-1.832 3-1.429 0-2.698-1.667-3.333-3-.635-1.667-.476-5.833 1.428-11.5C6.151 4.485 7.545 4.16 9 4l1 2.5" />
|
||||||
|
</svg>
|
||||||
|
Join Discord
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<div className="container flex flex-col items-center justify-center text-center py-24">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Welcome to your Dashboard</h1>
|
||||||
|
<p className="text-gray-400 mb-8">Please log in to manage your extension.</p>
|
||||||
|
<Button onClick={handleLogin}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="mr-2 h-4 w-4">
|
||||||
|
<circle cx="9" cy="12" r="1" />
|
||||||
|
<circle cx="15" cy="12" r="1" />
|
||||||
|
<path d="M7.5 7.5c3.5-1 5.5-1 9 0" />
|
||||||
|
<path d="M7 16.5c3.5 1 6.5 1 10 0" />
|
||||||
|
<path
|
||||||
|
d="M15.5 17c0 1 1.5 3 2 3 1.5 0 2.833-1.667 3.5-3 .667-1.667.5-5.833-1.5-11.5-1.457-1.015-3-1.34-4.5-1.5l-1 2.5" />
|
||||||
|
<path
|
||||||
|
d="M8.5 17c0 1-1.356 3-1.832 3-1.429 0-2.698-1.667-3.333-3-.635-1.667-.476-5.833 1.428-11.5C6.151 4.485 7.545 4.16 9 4l1 2.5" />
|
||||||
|
</svg>
|
||||||
|
Login with Discord
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container py-12">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4 mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{detailsLoading ? (
|
||||||
|
<Skeleton className="h-16 w-16 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<Avatar className="h-16 w-16 border-2 border-blue-500/50">
|
||||||
|
<AvatarImage src={`https://cdn.discordapp.com/avatars/${details?.user?.id}/${details?.user?.avatar}.png`} alt={details?.user?.name} />
|
||||||
|
<AvatarFallback>{details?.user?.name?.[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">My Dashboard</h1>
|
||||||
|
{detailsLoading ? (
|
||||||
|
<Skeleton className="h-6 w-48 mt-2" />
|
||||||
|
) : details ? (
|
||||||
|
<p className="text-gray-400">Welcome back, {details.user?.name}!</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-red-400">Could not load your details. Please try again later.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={logout}>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Card */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<OverviewCard details={details} deviceStatus={deviceStatus} loading={detailsLoading || deviceStatusLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="lg:col-span-2 space-y-8">
|
||||||
|
<ActiveCalls />
|
||||||
|
<CallHistory />
|
||||||
|
<ApiKeys />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="lg:col-span-1 space-y-8">
|
||||||
|
<ExtensionDetails details={details} loading={detailsLoading} />
|
||||||
|
<SipCredentialsCard details={details} loading={detailsLoading} />
|
||||||
|
<QuickActionsCard details={details} loading={detailsLoading} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
78
src/components/dashboard/dnd-toggle.jsx
Normal file
78
src/components/dashboard/dnd-toggle.jsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export function DndToggle() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [dndStatus, setDndStatus] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchDndStatus = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/dnd`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setDndStatus(data.dndStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch DND status", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDndStatus();
|
||||||
|
}, [fetchDndStatus]);
|
||||||
|
|
||||||
|
const handleToggle = async (checked) => {
|
||||||
|
if (!token) return;
|
||||||
|
setDndStatus(checked); // Optimistic update
|
||||||
|
try {
|
||||||
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/dnd`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ dndStatus: checked })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update DND status", error);
|
||||||
|
setDndStatus(!checked); // Revert on failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Do Not Disturb</CardTitle>
|
||||||
|
<CardDescription>When enabled, calls will not ring your extension.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-6 w-11 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-20" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="dnd-mode"
|
||||||
|
checked={dndStatus}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="dnd-mode">{dndStatus ? 'Enabled' : 'Disabled'}</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
62
src/components/dashboard/extension-details.jsx
Normal file
62
src/components/dashboard/extension-details.jsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
function DetailRow({ label, value, isMono = false }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center border-b border-gray-800 py-3">
|
||||||
|
<span className="text-gray-400">{label}</span>
|
||||||
|
<span className={`${isMono ? 'font-mono text-blue-400' : 'font-medium text-white'} text-right`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExtensionDetails({ details, loading }) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
<Skeleton className="h-4 w-48 mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 pt-4">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!details) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Extension Details</CardTitle>
|
||||||
|
<CardDescription>Your user and device information.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-red-400">Could not load extension details.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-950/50 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Extension Details</CardTitle>
|
||||||
|
<CardDescription>Your user and device information.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<DetailRow label="Name" value={details.user?.name} />
|
||||||
|
<DetailRow label="Device ID" value={details.coreDevice?.deviceid} isMono />
|
||||||
|
<DetailRow label="Tech" value={details.coreDevice?.tech} />
|
||||||
|
<DetailRow label="Dial String" value={details.coreDevice?.dial} isMono />
|
||||||
|
<DetailRow label="Voicemail" value={details.user?.voicemail} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,112 +1,373 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { List, RefreshCw, Crown } from 'lucide-react'
|
||||||
import { List } from 'lucide-react'
|
import { TbPlugConnectedX } from 'react-icons/tb'
|
||||||
|
import { MdPhoneInTalk, MdCheck } from 'react-icons/md'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
export function DirectoryModal() {
|
export function DirectoryModal() {
|
||||||
const [extensions, setExtensions] = useState([]);
|
const [extensions, setExtensions] = useState([]);
|
||||||
const [summary, setSummary] = useState({ total: 0, connected: 0, avgLatency: 0 });
|
const [summary, setSummary] = useState({ total: 0, connected: 0, online: 0, inUse: 0, avgLatency: 0 });
|
||||||
const [lastUpdated, setLastUpdated] = useState('');
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showConnectedOnly, setShowConnectedOnly] = useState(false);
|
||||||
|
const [topUserExt, setTopUserExt] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchDirectory = async () => {
|
||||||
const fetchDirectory = async () => {
|
setLoading(true);
|
||||||
try {
|
setTopUserExt(null);
|
||||||
const response = await fetch('https://corsproxy.io/?url=https://pbx.litenet.tel/status/');
|
try {
|
||||||
const text = await response.text();
|
const response = await fetch('https://corsproxy.io/?url=https://pbx.litenet.tel/status/');
|
||||||
const parser = new DOMParser();
|
if (!response.ok) {
|
||||||
const doc = parser.parseFromString(text, 'text/html');
|
throw new Error('Failed to fetch directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(text, 'text/html');
|
||||||
|
|
||||||
// Parse summary data
|
// Improved parsing logic
|
||||||
const summaryText = doc.querySelector('h2.text-center')?.textContent;
|
// Parse summary data - look for all h2 and h3 elements
|
||||||
if (summaryText) {
|
const h2Elements = Array.from(doc.querySelectorAll('h2.text-center'));
|
||||||
const match = summaryText.match(/(\d+) Endpoints connected to (\d+) Extensions. With an average latency of ([\d.]+)ms/);
|
const h3Elements = Array.from(doc.querySelectorAll('h3.text-center'));
|
||||||
if (match) {
|
|
||||||
setSummary({
|
let totalEndpoints = 0;
|
||||||
total: parseInt(match[1]),
|
let connectedExtensions = 0;
|
||||||
connected: parseInt(match[2]),
|
let onlineCount = 0;
|
||||||
avgLatency: parseFloat(match[3])
|
let totalCount = 0;
|
||||||
});
|
let inUseCount = 0;
|
||||||
|
let avgLatency = 0;
|
||||||
|
|
||||||
|
// Check all h2 elements for endpoint/extension info
|
||||||
|
h2Elements.forEach(el => {
|
||||||
|
const text = el.textContent || '';
|
||||||
|
const endpointMatch = text.match(/(\d+)\s+Endpoints/i);
|
||||||
|
const extensionMatch = text.match(/connected to\s+(\d+)\s+Extensions/i);
|
||||||
|
const latencyMatch = text.match(/average latency of\s+([\d.]+)ms/i);
|
||||||
|
|
||||||
|
if (endpointMatch) totalEndpoints = parseInt(endpointMatch[1]);
|
||||||
|
if (extensionMatch) connectedExtensions = parseInt(extensionMatch[1]);
|
||||||
|
if (latencyMatch) avgLatency = parseFloat(latencyMatch[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check all h3 elements for online/total/in-use info
|
||||||
|
h3Elements.forEach(el => {
|
||||||
|
const text = el.textContent || '';
|
||||||
|
const onlineMatch = text.match(/(\d+)\s+online\s*\/\s*(\d+)\s+total/i);
|
||||||
|
const inUseMatch = text.match(/(\d+)\s+In-use/i);
|
||||||
|
|
||||||
|
if (onlineMatch) {
|
||||||
|
onlineCount = parseInt(onlineMatch[1]);
|
||||||
|
totalCount = parseInt(onlineMatch[2]);
|
||||||
|
}
|
||||||
|
if (inUseMatch) inUseCount = parseInt(inUseMatch[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we need to do direct DOM inspection for values
|
||||||
|
if (totalEndpoints === 0 || connectedExtensions === 0) {
|
||||||
|
// Try another approach - directly find specific elements by position/context
|
||||||
|
const mainContentDiv = doc.querySelector('.container .row .col-md-12');
|
||||||
|
if (mainContentDiv) {
|
||||||
|
const contentText = mainContentDiv.textContent || '';
|
||||||
|
|
||||||
|
// Try more flexible regex patterns
|
||||||
|
const fullMatch = contentText.match(/(\d+)\s+Endpoints\s+connected\s+to\s+(\d+)\s+Extensions/i);
|
||||||
|
if (fullMatch) {
|
||||||
|
totalEndpoints = parseInt(fullMatch[1]);
|
||||||
|
connectedExtensions = parseInt(fullMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for online/total pattern
|
||||||
|
const statusMatch = contentText.match(/(\d+)\s+online\s*\/\s*(\d+)\s+total/i);
|
||||||
|
if (statusMatch) {
|
||||||
|
onlineCount = parseInt(statusMatch[1]);
|
||||||
|
totalCount = parseInt(statusMatch[2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse last updated time
|
|
||||||
const timestampElement = doc.getElementById('timestamp');
|
|
||||||
if (timestampElement) {
|
|
||||||
setLastUpdated(timestampElement.textContent || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse extensions
|
|
||||||
const rows = doc.querySelectorAll('table tbody tr');
|
|
||||||
const parsedExtensions = Array.from(rows).map(row => {
|
|
||||||
const cells = row.querySelectorAll('td');
|
|
||||||
return {
|
|
||||||
number: cells[0].textContent?.replace('●', '').trim() || '',
|
|
||||||
name: cells[1].textContent || '',
|
|
||||||
status: cells[2].textContent || '',
|
|
||||||
endpoints: cells[3].textContent || '',
|
|
||||||
latency: cells[4].textContent || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setExtensions(parsedExtensions);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching directory:', error);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// Console log for debugging
|
||||||
|
console.log("Parsed values:", {
|
||||||
|
totalEndpoints,
|
||||||
|
connectedExtensions,
|
||||||
|
onlineCount,
|
||||||
|
inUseCount,
|
||||||
|
avgLatency
|
||||||
|
});
|
||||||
|
|
||||||
|
setSummary({
|
||||||
|
total: totalEndpoints,
|
||||||
|
connected: connectedExtensions,
|
||||||
|
online: onlineCount,
|
||||||
|
inUse: inUseCount,
|
||||||
|
avgLatency: avgLatency
|
||||||
|
});
|
||||||
|
|
||||||
fetchDirectory();
|
// Parse extensions
|
||||||
}, []);
|
const rows = doc.querySelectorAll('table tbody tr');
|
||||||
|
const parsedExtensions = Array.from(rows).map(row => {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
const extCell = cells[0].textContent || '';
|
||||||
|
const statusColor = cells[0].getAttribute('style') || '';
|
||||||
|
|
||||||
|
let statusType = 'offline';
|
||||||
|
if (statusColor.includes('green')) statusType = 'online';
|
||||||
|
else if (statusColor.includes('orange')) statusType = 'in-use';
|
||||||
|
|
||||||
|
return {
|
||||||
|
number: extCell.replace('●', '').trim(),
|
||||||
|
name: cells[1].textContent || '',
|
||||||
|
status: cells[2].textContent || '',
|
||||||
|
statusType: statusType,
|
||||||
|
endpoints: cells[3].textContent || '0',
|
||||||
|
latency: cells[4].textContent || 'No Data',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsedExtensions.length > 0) {
|
||||||
|
const topUser = parsedExtensions.reduce((max, current) => {
|
||||||
|
const maxEndpoints = parseInt(max.endpoints, 10) || 0;
|
||||||
|
const currentEndpoints = parseInt(current.endpoints, 10) || 0;
|
||||||
|
return currentEndpoints > maxEndpoints ? current : max;
|
||||||
|
});
|
||||||
|
if (parseInt(topUser.endpoints, 10) > 0) {
|
||||||
|
setTopUserExt(topUser.number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtensions(parsedExtensions);
|
||||||
|
setError(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching directory:', error);
|
||||||
|
setError('Failed to load directory. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchDirectory();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const getStatusIcon = (statusType) => {
|
||||||
|
switch (statusType) {
|
||||||
|
case 'online':
|
||||||
|
return <MdCheck className="h-5 w-5 text-green-500" />;
|
||||||
|
case 'in-use':
|
||||||
|
return <MdPhoneInTalk className="h-5 w-5 text-orange-500" />;
|
||||||
|
default:
|
||||||
|
return <TbPlugConnectedX className="h-5 w-5 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter extensions based on the switch state
|
||||||
|
const filteredExtensions = showConnectedOnly
|
||||||
|
? extensions.filter(ext => ext.statusType === 'online' || ext.statusType === 'in-use')
|
||||||
|
: extensions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2">
|
<Button variant="outline" className="border-gray-700 bg-gray-900 text-white hover:bg-gray-800">
|
||||||
<List className="mr-2 h-4 w-4" />
|
<List className="mr-2 h-4 w-4" />
|
||||||
Show Directory
|
Directory
|
||||||
</button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[800px] bg-black text-white border border-gray-800">
|
<AnimatePresence>
|
||||||
<DialogHeader>
|
{open && (
|
||||||
<DialogTitle className="text-2xl font-bold">LiteNet Directory</DialogTitle>
|
<DialogContent
|
||||||
<DialogDescription className="text-gray-400">
|
className="sm:max-w-[800px] bg-black text-white border border-gray-800 p-0 gap-0"
|
||||||
{summary.total} Endpoints connected to {summary.connected} Extensions. Average latency: {summary.avgLatency.toFixed(2)}ms
|
onEscapeKeyDown={() => setOpen(false)}
|
||||||
</DialogDescription>
|
onPointerDownOutside={() => setOpen(false)}
|
||||||
</DialogHeader>
|
forceMount
|
||||||
<ScrollArea className="h-[400px] w-full rounded-md border border-gray-800 p-4">
|
>
|
||||||
<Table>
|
<motion.div
|
||||||
<TableHeader>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<TableRow>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<TableHead className="text-white">Extension</TableHead>
|
exit={{ opacity: 0, y: -10 }}
|
||||||
<TableHead className="text-white">Name</TableHead>
|
transition={{ duration: 0.2 }}
|
||||||
<TableHead className="text-white">Status</TableHead>
|
className="p-6 flex flex-col h-full"
|
||||||
<TableHead className="text-white">Endpoints</TableHead>
|
>
|
||||||
<TableHead className="text-white">Latency</TableHead>
|
<DialogHeader className="px-0 pb-4 flex-shrink-0">
|
||||||
</TableRow>
|
<DialogTitle className="text-2xl font-bold">LiteNet PBX Directory</DialogTitle>
|
||||||
</TableHeader>
|
<DialogDescription className="text-gray-400">
|
||||||
<TableBody>
|
Current directory status
|
||||||
{extensions.map((ext) => (
|
</DialogDescription>
|
||||||
<TableRow key={ext.number}>
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
<TableCell className="font-medium">{ext.number}</TableCell>
|
<Badge variant="outline" className="bg-gray-900">
|
||||||
<TableCell>{ext.name}</TableCell>
|
{summary.total} Endpoints
|
||||||
<TableCell>{ext.status}</TableCell>
|
</Badge>
|
||||||
<TableCell>{ext.endpoints}</TableCell>
|
<Badge variant="outline" className="bg-gray-900">
|
||||||
<TableCell>{ext.latency}</TableCell>
|
{summary.connected} Extensions
|
||||||
</TableRow>
|
</Badge>
|
||||||
))}
|
<Badge variant="outline" className="bg-green-900/30">
|
||||||
</TableBody>
|
{summary.online} Online
|
||||||
</Table>
|
</Badge>
|
||||||
</ScrollArea>
|
{summary.inUse > 0 && (
|
||||||
<div className="text-sm text-gray-400">{lastUpdated}</div>
|
<Badge variant="outline" className="bg-orange-900/30">
|
||||||
</DialogContent>
|
{summary.inUse} In Use
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="bg-gray-900">
|
||||||
|
Avg. Latency: {summary.avgLatency.toFixed(2)}ms
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="connected-only"
|
||||||
|
checked={showConnectedOnly}
|
||||||
|
onCheckedChange={setShowConnectedOnly}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="connected-only">Show connected extensions only</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800"
|
||||||
|
onClick={fetchDirectory}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-grow overflow-hidden border border-gray-800 rounded-md">
|
||||||
|
<ScrollArea className="h-[50vh] w-full">
|
||||||
|
<div className="p-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-6 w-full bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
{[...Array(10)].map((_, index) => (
|
||||||
|
<div key={index} className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-4 w-4 rounded-full bg-gray-800" />
|
||||||
|
<Skeleton className="h-4 w-12 bg-gray-800" />
|
||||||
|
<Skeleton className="h-4 w-32 bg-gray-800" />
|
||||||
|
<Skeleton className="h-4 w-8 bg-gray-800" />
|
||||||
|
<Skeleton className="h-4 w-16 bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="text-center py-8 text-red-400"
|
||||||
|
>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4 border-red-800 hover:bg-red-900/30"
|
||||||
|
onClick={fetchDirectory}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && filteredExtensions.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="min-w-full">
|
||||||
|
<table className="min-w-full divide-y divide-gray-800">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Extension</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Endpoints</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Latency</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-transparent divide-y divide-gray-800">
|
||||||
|
{filteredExtensions.map((ext, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={ext.number}
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
delay: index * 0.03
|
||||||
|
}}
|
||||||
|
className="hover:bg-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
|
{getStatusIcon(ext.statusType)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap font-medium">
|
||||||
|
{ext.number}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
|
{ext.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
|
<div className="relative inline-block pr-2">
|
||||||
|
{ext.endpoints}
|
||||||
|
{ext.number === topUserExt && (
|
||||||
|
<Crown className="absolute -top-2 -right-2 h-4 w-4 text-yellow-400 fill-yellow-400 rotate-[30deg]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
|
{ext.latency}
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && filteredExtensions.length === 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="text-center py-8 text-gray-400"
|
||||||
|
>
|
||||||
|
{showConnectedOnly
|
||||||
|
? "No connected extensions found"
|
||||||
|
: "No extensions found"}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end items-center mt-2 px-0 flex-shrink-0 text-xs text-gray-500">
|
||||||
|
{showConnectedOnly
|
||||||
|
? `Showing ${filteredExtensions.length} of ${extensions.length} extensions`
|
||||||
|
: `Showing all ${extensions.length} extensions`}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -1,65 +1,132 @@
|
||||||
import { Phone, Users, VoicemailIcon, Radio, MoreHorizontal, Network, Bot, UserCog } from 'lucide-react'
|
"use client"
|
||||||
|
|
||||||
|
import { Phone, Users, VoicemailIcon, Radio, MoreHorizontal, Network, Bot, LayoutDashboard } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
name: 'Free Dial-in and Out',
|
name: 'Free Dial-in and Out',
|
||||||
description: 'Call using +1 (610) LITENET / +1 (610) 548 3638',
|
description: 'Call using +1 (610) LITENET / +1 (610) 548 3638',
|
||||||
icon: Phone,
|
icon: Phone,
|
||||||
|
color: 'blue',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Conference Rooms',
|
name: 'Conference Rooms',
|
||||||
description: 'Host or join conference calls with multiple participants',
|
description: 'Host or join conference calls with multiple participants',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
color: 'green',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Voicemail',
|
name: 'Voicemail',
|
||||||
description: 'Set up your own private voicemail and receive messages from anyone',
|
description: 'Set up your own private voicemail and receive messages from anyone',
|
||||||
icon: VoicemailIcon,
|
icon: VoicemailIcon,
|
||||||
|
color: 'purple',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Intercom and Paging',
|
name: 'Intercom and Paging',
|
||||||
description: 'Easily communicate with other extensions and page groups',
|
description: 'Easily communicate with other extensions and page groups',
|
||||||
icon: Radio,
|
icon: Radio,
|
||||||
|
color: 'orange',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: <>Access to <Link href="https://astrocom.tel/" className="text-xl font-semibold underline underline-offset-4 text-gray-300">AstroCom</Link></>,
|
name: <>Access to <Link href="https://astrocom.tel/" className="font-semibold underline underline-offset-4 text-blue-300 hover:text-blue-200 transition-colors">AstroCom</Link></>,
|
||||||
description: <>Dial directly to AstroCom straight from your LiteNet extension!</>,
|
description: <>Dial directly to AstroCom straight from your LiteNet extension!</>,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
|
color: 'cyan',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Discord Bot',
|
name: 'Discord Bot',
|
||||||
description: 'Quickly create your own extension, manage page groups, and view Call Detail Records',
|
description: 'Quickly create your own extension, manage page groups, and view Call Detail Records',
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
|
color: 'indigo',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'UCP Account',
|
name: 'Web Dashboard',
|
||||||
description: 'Log in to the User Control Panel to manage your account and settings',
|
description: 'Log in to the Web Dashboard to manage your account and settings',
|
||||||
icon: UserCog,
|
icon: LayoutDashboard,
|
||||||
|
color: 'pink',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'And More!',
|
name: 'And More!',
|
||||||
description: 'We\'re always adding new features to LiteNet!',
|
description: 'We\'re always adding new features to LiteNet!',
|
||||||
icon: MoreHorizontal,
|
icon: MoreHorizontal,
|
||||||
|
color: 'gray',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Features() {
|
const colorClasses = {
|
||||||
return (
|
blue: 'bg-blue-600/20 border-blue-600/30 text-blue-400 group-hover:bg-blue-600/30',
|
||||||
(<section id="features" className="container py-24">
|
green: 'bg-green-600/20 border-green-600/30 text-green-400 group-hover:bg-green-600/30',
|
||||||
<h2 className="mb-12 text-center text-3xl font-bold">Features</h2>
|
purple: 'bg-purple-600/20 border-purple-600/30 text-purple-400 group-hover:bg-purple-600/30',
|
||||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
orange: 'bg-orange-600/20 border-orange-600/30 text-orange-400 group-hover:bg-orange-600/30',
|
||||||
{features.map((feature) => (
|
cyan: 'bg-cyan-600/20 border-cyan-600/30 text-cyan-400 group-hover:bg-cyan-600/30',
|
||||||
<div key={feature.name} className="flex flex-col items-center text-center">
|
indigo: 'bg-indigo-600/20 border-indigo-600/30 text-indigo-400 group-hover:bg-indigo-600/30',
|
||||||
<div className="mb-4 rounded-full bg-blue-500 p-3">
|
pink: 'bg-pink-600/20 border-pink-600/30 text-pink-400 group-hover:bg-pink-600/30',
|
||||||
<feature.icon className="h-6 w-6 text-gray-100" />
|
gray: 'bg-gray-600/20 border-gray-600/30 text-gray-400 group-hover:bg-gray-600/30',
|
||||||
</div>
|
}
|
||||||
<h3 className="mb-2 text-xl font-semibold">{feature.name}</h3>
|
|
||||||
<p className="text-gray-400">{feature.description}</p>
|
export default function Features() {
|
||||||
</div>
|
return (
|
||||||
))}
|
<section id="features" className="relative py-32 overflow-hidden">
|
||||||
</div>
|
<div className="container relative">
|
||||||
</section>)
|
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||||
);
|
<h2 className="text-4xl md:text-5xl font-bold text-white mb-6 bg-gradient-to-r from-white via-blue-100 to-white bg-clip-text text-transparent pb-2">
|
||||||
|
Everything You Need
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-300 leading-relaxed">
|
||||||
|
A comprehensive suite of features for a complete PBX experience, designed to keep you connected with your community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<Card
|
||||||
|
key={feature.name}
|
||||||
|
className="group relative bg-gray-950/50 border-gray-800 hover:border-gray-700 transition-all duration-500 ease-out transform hover:-translate-y-2 hover:shadow-2xl hover:shadow-blue-500/10 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 100}ms`,
|
||||||
|
animation: 'fadeInUp 0.8s ease-out forwards'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Animated background gradient */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-blue-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
|
|
||||||
|
<CardHeader className="flex flex-row items-center gap-4 pb-4 relative">
|
||||||
|
<div className={`rounded-xl p-3 border transition-all duration-300 ${colorClasses[feature.color]}`}>
|
||||||
|
<feature.icon className="h-7 w-7 transition-transform duration-300 group-hover:scale-110" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg text-white group-hover:text-blue-100 transition-colors duration-300">
|
||||||
|
{feature.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="relative">
|
||||||
|
<p className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors duration-300 leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Hover glow effect */}
|
||||||
|
<div className="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/0 via-blue-500/0 to-blue-500/0 group-hover:from-blue-500/5 group-hover:via-transparent group-hover:to-blue-500/5 transition-all duration-500 pointer-events-none" />
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,44 @@
|
||||||
import { TermsOfServiceModal } from './terms-of-service-modal'
|
import { TermsOfServiceModal } from './terms-of-service-modal'
|
||||||
import { PrivacyPolicyModal } from './privacy-policy-modal'
|
import { PrivacyPolicyModal } from './privacy-policy-modal'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-gray-800 bg-black text-gray-300">
|
<footer className="relative border-t border-gray-800/50 bg-black/30 backdrop-blur-sm text-gray-400 overflow-hidden">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-between gap-4 py-10 md:h-24 md:flex-row md:py-0">
|
<div className="container relative mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
|
||||||
<img src="/litenet-logo.png" alt="LiteNet Logo" className="h-6 w-6" />
|
{/* Logo and copyright */}
|
||||||
<p className="text-center text-sm leading-loose md:text-left">
|
<div className="flex items-center gap-3">
|
||||||
© 2024 The LiteNet Group. All rights reserved.
|
<div className="relative">
|
||||||
</p>
|
<div className="absolute inset-0 rounded-full bg-blue-500/20 blur-md" />
|
||||||
</div>
|
<img src="/litenet-logo.png" alt="LiteNet Logo" className="relative h-8 w-8 drop-shadow-lg" />
|
||||||
<div className="flex gap-4 items-center">
|
</div>
|
||||||
<TermsOfServiceModal />
|
<p className="text-sm text-gray-300">
|
||||||
<PrivacyPolicyModal />
|
© {new Date().getFullYear()} The LiteNet Group. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex gap-6 items-center text-sm">
|
||||||
|
<Link href="/#features" className="hover:text-blue-400 transition-colors duration-300 relative group">
|
||||||
|
Features
|
||||||
|
<span className="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-400 transition-all duration-300 group-hover:w-full" />
|
||||||
|
</Link>
|
||||||
|
<Link href="/#updates" className="hover:text-blue-400 transition-colors duration-300 relative group">
|
||||||
|
Updates
|
||||||
|
<span className="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-400 transition-all duration-300 group-hover:w-full" />
|
||||||
|
</Link>
|
||||||
|
<Link href="/#team" className="hover:text-blue-400 transition-colors duration-300 relative group">
|
||||||
|
Team
|
||||||
|
<span className="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-400 transition-all duration-300 group-hover:w-full" />
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Legal links */}
|
||||||
|
<div className="flex gap-6 items-center text-sm">
|
||||||
|
<TermsOfServiceModal />
|
||||||
|
<PrivacyPolicyModal />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
//import {DirectoryModal} from "@/components/directory-modal";
|
import { DirectoryModal } from "@/components/directory-modal";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Phone } from 'lucide-react'
|
import { LayoutDashboard } from 'lucide-react'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
return (
|
return (
|
||||||
|
@ -17,13 +17,13 @@ export default function Header() {
|
||||||
<span className="hidden font-bold text-white sm:inline-block">LiteNet</span>
|
<span className="hidden font-bold text-white sm:inline-block">LiteNet</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center space-x-6 text-sm font-medium">
|
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||||
<Link href="#features" className="text-gray-300 hover:text-white">
|
<Link href="/#features" className="text-gray-300 hover:text-white">
|
||||||
Features
|
Features
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="#updates" className="text-gray-300 hover:text-white">
|
<Link href="/#updates" className="text-gray-300 hover:text-white">
|
||||||
Updates
|
Updates
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="#team" className="text-gray-300 hover:text-white">
|
<Link href="/#team" className="text-gray-300 hover:text-white">
|
||||||
Team
|
Team
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -31,6 +31,8 @@ export default function Header() {
|
||||||
<div
|
<div
|
||||||
className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||||
|
|
||||||
|
<DirectoryModal />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-gray-700 bg-gray-900 text-white hover:bg-gray-800"
|
className="border-gray-700 bg-gray-900 text-white hover:bg-gray-800"
|
||||||
|
@ -63,13 +65,10 @@ export default function Header() {
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" asChild>
|
<Button className="bg-blue-600 text-white hover:bg-blue-700" asChild>
|
||||||
<a
|
<Link href="/dashboard">
|
||||||
href="https://pbx.litenet.tel/ucp/"
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
target="_blank"
|
Dashboard
|
||||||
rel="noopener noreferrer">
|
</Link>
|
||||||
<Phone className="mr-2 h-4 w-4" />
|
|
||||||
UCP Login
|
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,63 +1,105 @@
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Phone } from 'lucide-react'
|
import { LayoutDashboard, ArrowRight, Phone, Users, ChevronDown } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
return (
|
return (
|
||||||
(<section
|
<section className="relative overflow-hidden">
|
||||||
className="container flex flex-col items-center justify-center space-y-4 py-24 text-center">
|
<div className="container relative flex flex-col items-center justify-center space-y-8 py-32 text-center">
|
||||||
<img
|
{/* Logo with glow effect */}
|
||||||
src="/litenet-logo.png"
|
<div className="relative group">
|
||||||
alt="LiteNet Logo"
|
<div className="absolute inset-0 rounded-full bg-blue-500/20 blur-xl group-hover:bg-blue-500/30 transition-all duration-300 scale-150" />
|
||||||
className="h-24 w-24" />
|
<img
|
||||||
<h1
|
src="/litenet-logo.png"
|
||||||
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl">Welcome to LiteNet</h1>
|
alt="LiteNet Logo"
|
||||||
<p
|
className="relative h-32 w-32 drop-shadow-2xl group-hover:scale-105 transition-transform duration-300"
|
||||||
className="max-w-[42rem] leading-normal text-gray-400 sm:text-xl sm:leading-8">
|
/>
|
||||||
A free community PBX based on FreePBX. Get your own 4-digit extension and start calling other members today!
|
</div>
|
||||||
</p>
|
|
||||||
<div className="flex justify-center space-x-4">
|
{/* Main heading with gradient text */}
|
||||||
<Button
|
<div className="space-y-4">
|
||||||
variant="outline"
|
<h1 className="text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
|
||||||
className="border-gray-700 bg-gray-900 text-white hover:bg-gray-800"
|
<span className="bg-gradient-to-r from-white via-blue-100 to-white bg-clip-text text-transparent pb-2">
|
||||||
asChild>
|
Welcome to
|
||||||
<a
|
</span>
|
||||||
href="https://discord.litenet.tel"
|
<br />
|
||||||
target="_blank"
|
<span className="bg-gradient-to-r from-blue-400 via-blue-300 to-blue-500 bg-clip-text text-transparent pb-2">
|
||||||
rel="noopener noreferrer">
|
LiteNet
|
||||||
<svg
|
</span>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</h1>
|
||||||
width="24"
|
|
||||||
height="24"
|
<p className="max-w-[48rem] mx-auto text-xl leading-relaxed text-gray-300 sm:text-2xl sm:leading-9">
|
||||||
viewBox="0 0 24 24"
|
A free community PBX based on FreePBX. Get your own 4-digit extension and start calling other members today!
|
||||||
fill="none"
|
</p>
|
||||||
stroke="currentColor"
|
</div>
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
{/* Feature highlights */}
|
||||||
strokeLinejoin="round"
|
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400">
|
||||||
className="mr-2 h-4 w-4">
|
<div className="flex items-center gap-2">
|
||||||
<circle cx="9" cy="12" r="1" />
|
<Phone className="h-4 w-4 text-blue-400" />
|
||||||
<circle cx="15" cy="12" r="1" />
|
<span>Free dial-in & out</span>
|
||||||
<path d="M7.5 7.5c3.5-1 5.5-1 9 0" />
|
</div>
|
||||||
<path d="M7 16.5c3.5 1 6.5 1 10 0" />
|
<div className="flex items-center gap-2">
|
||||||
<path
|
<Users className="h-4 w-4 text-blue-400" />
|
||||||
d="M15.5 17c0 1 1.5 3 2 3 1.5 0 2.833-1.667 3.5-3 .667-1.667.5-5.833-1.5-11.5-1.457-1.015-3-1.34-4.5-1.5l-1 2.5" />
|
<span>Active community</span>
|
||||||
<path
|
</div>
|
||||||
d="M8.5 17c0 1-1.356 3-1.832 3-1.429 0-2.698-1.667-3.333-3-.635-1.667-.476-5.833 1.428-11.5C6.151 4.485 7.545 4.16 9 4l1 2.5" />
|
<div className="flex items-center gap-2">
|
||||||
</svg>
|
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse" />
|
||||||
Join Discord
|
<span>Consistent uptime</span>
|
||||||
</a>
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" asChild>
|
|
||||||
<a
|
{/* CTA buttons */}
|
||||||
href="https://pbx.litenet.tel/ucp/"
|
<div className="flex flex-col sm:flex-row justify-center gap-4 pt-4">
|
||||||
target="_blank"
|
<Button
|
||||||
rel="noopener noreferrer">
|
size="lg"
|
||||||
<Phone className="mr-2 h-4 w-4" />
|
className="bg-blue-600 hover:bg-blue-700 text-white shadow-lg hover:shadow-xl transition-all duration-300 group"
|
||||||
UCP Login
|
asChild
|
||||||
</a>
|
>
|
||||||
</Button>
|
<Link href="/dashboard">
|
||||||
|
<LayoutDashboard className="mr-2 h-5 w-5 group-hover:scale-110 transition-transform" />
|
||||||
|
Get Started
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-600 bg-gray-900/50 text-white hover:bg-gray-800/80 hover:border-gray-500 shadow-lg hover:shadow-xl transition-all duration-300 group backdrop-blur-sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href="https://discord.litenet.tel" target="_blank" rel="noopener noreferrer">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="mr-2 group-hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
<circle cx="9" cy="12" r="1" />
|
||||||
|
<circle cx="15" cy="12" r="1" />
|
||||||
|
<path d="M7.5 7.5c3.5-1 5.5-1 9 0" />
|
||||||
|
<path d="M7 16.5c3.5 1 6.5 1 10 0" />
|
||||||
|
<path d="M15.5 17c0 1 1.5 3 2 3 1.5 0 2.833-1.667 3.5-3 .667-1.667.5-5.833-1.5-11.5-1.457-1.015-3-1.34-4.5-1.5l-1 2.5" />
|
||||||
|
<path d="M8.5 17c0 1-1.356 3-1.832 3-1.429 0-2.698-1.667-3.333-3-.635-1.667-.476-5.833 1.428-11.5C6.151 4.485 7.545 4.16 9 4l1 2.5" />
|
||||||
|
</svg>
|
||||||
|
Join Discord
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll down arrow */}
|
||||||
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2">
|
||||||
|
<ChevronDown className="h-8 w-8 text-gray-500 animate-bounce" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>)
|
</section>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
@ -10,71 +10,92 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
|
||||||
export function PrivacyPolicyModal() {
|
export function PrivacyPolicyModal() {
|
||||||
React.useEffect(() => {
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (window.location.hash === '#privacy') {
|
if (window.location.hash === '#privacy') {
|
||||||
document.getElementById('privacy-trigger').click();
|
document.getElementById('privacy-trigger').click();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button id="privacy-trigger" className="text-sm underline underline-offset-4">Privacy Policy</button>
|
<button id="privacy-trigger" className="text-sm underline underline-offset-4">Privacy Policy</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[625px] bg-black text-white border border-gray-800">
|
<AnimatePresence>
|
||||||
<DialogHeader>
|
{open && (
|
||||||
<DialogTitle className="text-2xl font-bold">Privacy Policy</DialogTitle>
|
<DialogContent
|
||||||
<DialogDescription className="text-gray-400">
|
className="sm:max-w-[625px] bg-black text-white border border-gray-800 p-0 gap-0"
|
||||||
LiteNet is committed to protecting your privacy. This Privacy Policy explains our practices regarding the collection, use, and disclosure of your information.
|
onEscapeKeyDown={() => setOpen(false)}
|
||||||
</DialogDescription>
|
onPointerDownOutside={() => setOpen(false)}
|
||||||
</DialogHeader>
|
forceMount
|
||||||
<ScrollArea className="h-[400px] w-full rounded-md border border-gray-800 p-4">
|
>
|
||||||
<h2 className="text-lg font-semibold mb-2">1. Information Collection and Use</h2>
|
<motion.div
|
||||||
<p className="mb-4">
|
initial={{ opacity: 0, y: 10 }}
|
||||||
Privacy is a primary goal of LiteNet. We collect and maintain only necessary information to ensure the safety and functionality of our system.
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</p>
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="p-6 flex flex-col max-h-[90vh]"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-0 pb-4">
|
||||||
|
<DialogTitle className="text-2xl font-bold">Privacy Policy</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
LiteNet is committed to protecting your privacy. This Privacy Policy explains our practices regarding the collection, use, and disclosure of your information.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="flex-grow border border-gray-800 rounded-md p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">1. Information Collection and Use</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
Privacy is a primary goal of LiteNet. We collect and maintain only necessary information to ensure the safety and functionality of our system.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">2. Call Detail Records (CDR) Logs</h2>
|
<h2 className="text-lg font-semibold mb-2">2. Call Detail Records (CDR) Logs</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
CDR Logs are accessible only by authorized personnel. These logs are used solely for system debugging purposes and to identify and block malicious callers. Access to these logs is strictly controlled and monitored.
|
CDR Logs are accessible only by authorized personnel. These logs are used solely for system debugging purposes and to identify and block malicious callers. Access to these logs is strictly controlled and monitored.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">3. Call Recording</h2>
|
<h2 className="text-lg font-semibold mb-2">3. Call Recording</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Call recording does not happen on the LiteNet Phone system itself. Individuals may record their calls via their own methods provided it's legal in their jurisdiction. If you record your calls we ask you announce it before a conversation.
|
Call recording does not happen on the LiteNet Phone system itself. Individuals may record their calls via their own methods provided it's legal in their jurisdiction. If you record your calls we ask you announce it before a conversation.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">4. Voicemail Privacy</h2>
|
<h2 className="text-lg font-semibold mb-2">4. Voicemail Privacy</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
LiteNet does not track or monitor voicemails. Users can expect an inherent level of privacy when using the voicemail system. Voicemail messages are accessible only by the owner of the extension.
|
LiteNet does not track or monitor voicemails. Users can expect an inherent level of privacy when using the voicemail system. Voicemail messages are accessible only by the owner of the extension.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">5. Data Deletion</h2>
|
<h2 className="text-lg font-semibold mb-2">5. Data Deletion</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Users have the right to request the deletion of their data at any time. This can be done by using the "Delete your extension" function within the user interface. Upon deletion, all relevant user information, including but not limited to login credentials, voicemail messages, and voicemail PIN numbers, will be immediately and permanently removed from our system.
|
Users have the right to request the deletion of their data at any time. This can be done by using the "Delete your extension" function within the user interface. Upon deletion, all relevant user information, including but not limited to login credentials, voicemail messages, and voicemail PIN numbers, will be immediately and permanently removed from our system.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">6. Data Security</h2>
|
<h2 className="text-lg font-semibold mb-2">6. Data Security</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
We implement a variety of security measures to maintain the safety of your personal information. However, no method of transmission over the Internet or method of electronic storage is 100% secure.
|
We implement a variety of security measures to maintain the safety of your personal information. However, no method of transmission over the Internet or method of electronic storage is 100% secure.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">7. Changes to This Privacy Policy</h2>
|
<h2 className="text-lg font-semibold mb-2">7. Changes to This Privacy Policy</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "last updated" date.
|
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "last updated" date.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">8. Contact Us</h2>
|
<h2 className="text-lg font-semibold mb-2">8. Contact Us</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
If you have any questions or concerns regarding this Privacy Policy, please contact our staff members. They are available to assist you with any privacy-related inquiries.
|
If you have any questions or concerns regarding this Privacy Policy, please contact our staff members. They are available to assist you with any privacy-related inquiries.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-4 font-semibold">
|
<p className="mt-4 font-semibold">
|
||||||
By using LiteNet's services, you acknowledge that you have read and understood this Privacy Policy and agree to its terms.
|
By using LiteNet's services, you acknowledge that you have read and understood this Privacy Policy and agree to its terms.
|
||||||
</p>
|
</p>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</motion.div>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +1,150 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
const adminTeam = [
|
const adminTeam = [
|
||||||
{ name: 'Chris Chrome', ext: '1000', image: '/chris.webp', discord: '@chrischrome' },
|
{ name: 'Chris Chrome', ext: '1000', image: '/chris.webp', discord: '@chrischrome', role: 'Project Lead' },
|
||||||
{ name: 'Ramadya', ext: '1003', image: '/ramsaso.webp', discord: '@ramsaso' },
|
{ name: 'Cayden', ext: '1001', image: '/cayden.webp', discord: '@freepbx', role: 'Co-Lead' },
|
||||||
//{ name: 'Nick', ext: '1036', image: '/nick.webp', discord: '@gamewell' },
|
{ name: 'Faux Lemons', ext: '1011', image: '/faux_lemons.webp', discord: '@faux_lemons', role: 'Admin' },
|
||||||
{ name: 'Faux Lemons', ext: '1011', image: '/faux_lemons.webp', discord: '@faux_lemons' },
|
{ name: 'ashton', ext: '1007', image: '/ashton.webp', discord: '@ashtoncarlson', role: 'Admin' },
|
||||||
{ name: 'ashton', ext: '1007', image: '/ashton.webp', discord: '@ashtoncarlson' },
|
{ name: 'rocord', ext: '1010', image: '/rocord.webp', discord: '@rocord', role: 'Admin' },
|
||||||
{ name: 'Jaryn', ext: '1005', image: '/jaryn.webp', discord: 'jaryn.'},
|
{ name: 'Maddix', ext: '1005', image: '/maddix.webp', discord: '@maddix6859', role: 'Admin' },
|
||||||
{ name: 'Maddix', ext: '1019', image: '/maddix.webp', discord: '@maddix6859' },
|
{ name: 'Nick', ext: '1036', image: '/nick.webp', discord: '@gamewell', role: 'Admin' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const modTeam = [
|
const modTeam = [
|
||||||
{ name: 'rocord', ext: '2222', image: '/rocord.webp', discord: '@rocord' },
|
{ name: 'Vince', ext: '1101', image: '/vince.webp', discord: '@maybvince', role: 'Moderator' },
|
||||||
{ name: 'Vince', ext: '1101', image: '/vince.webp', discord: '@maybvince' },
|
{ name: 'Theliftoperator', ext: '1134', image: '/theliftoperator.webp', discord: '@theliftoperator', role: 'Moderator' },
|
||||||
{ name: 'Theliftoperator', ext: '1134', image: '/theliftoperator.webp', discord: '@theliftoperator' },
|
{ name: 'Nik', ext: '1008', image: '/nottimwakefield.webp', discord: '@nottimwakefield', role: 'Moderator' },
|
||||||
{ name: 'Nik', ext: '1008', image: '/nottimwakefield.webp', discord: '@nottimwakefield' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function TeamMember({ name, ext, image, discord }) {
|
function TeamMember({ name, ext, image, discord, role }) {
|
||||||
return (
|
return (
|
||||||
(<Card className="bg-gray-950 text-white">
|
<Card className="group relative bg-gray-950/50 text-white border-gray-800 transition-all duration-500 ease-out transform hover:-translate-y-2 hover:border-blue-500/50 hover:shadow-2xl hover:shadow-blue-500/10 overflow-hidden">
|
||||||
<CardHeader className="flex flex-row items-center gap-4 space-y-0">
|
{/* Animated background */}
|
||||||
<Avatar className="h-14 w-14">
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 via-transparent to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
<AvatarImage src={image} alt={name} />
|
|
||||||
<AvatarFallback>{name[0]}</AvatarFallback>
|
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-4">
|
||||||
</Avatar>
|
<div className="relative">
|
||||||
<div>
|
<Avatar className="h-16 w-16 border-2 border-gray-700 group-hover:border-blue-500/50 transition-all duration-300 group-hover:scale-105">
|
||||||
<CardTitle>{name}</CardTitle>
|
<AvatarImage src={image} alt={name} className="group-hover:scale-110 transition-transform duration-300" />
|
||||||
<div className="text-sm text-gray-400">{discord}</div>
|
<AvatarFallback className="bg-gray-800 text-white text-lg font-semibold">
|
||||||
|
{name[0]}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-white group-hover:text-blue-100 transition-colors duration-300 text-lg">
|
||||||
|
{name}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs bg-gray-800/50 border-gray-600 text-gray-300">
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors duration-300 mt-1">
|
||||||
|
{discord}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm text-gray-300">x{ext}</div>
|
<CardContent className="relative pt-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-mono text-blue-400 bg-blue-900/20 border border-blue-600/30 rounded-full px-3 py-1.5 inline-block group-hover:bg-blue-900/40 group-hover:border-blue-500/50 transition-all duration-300">
|
||||||
|
x{ext}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 group-hover:text-gray-400 transition-colors duration-300">
|
||||||
|
Extension
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>)
|
|
||||||
);
|
{/* Hover glow effect */}
|
||||||
|
<div className="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/0 via-blue-500/0 to-blue-500/0 group-hover:from-blue-500/5 group-hover:via-transparent group-hover:to-blue-500/5 transition-all duration-500 pointer-events-none" />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Team() {
|
export default function Team() {
|
||||||
return (
|
return (
|
||||||
(<section id="team" className="container py-24">
|
<section id="team" className="relative py-32 overflow-hidden">
|
||||||
<h2 className="mb-12 text-center text-3xl font-bold text-white">Our Team</h2>
|
<div className="container relative">
|
||||||
<div className="space-y-12">
|
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||||
<div>
|
<h2 className="text-4xl md:text-5xl font-bold text-white mb-6 bg-gradient-to-r from-white via-purple-100 to-white bg-clip-text text-transparent pb-2">
|
||||||
<h3 className="mb-4 text-2xl font-semibold text-gray-200">Administration Team</h3>
|
Meet the Team
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-5">
|
</h2>
|
||||||
{adminTeam.map((member) => (
|
<p className="text-xl text-gray-300 leading-relaxed">
|
||||||
<TeamMember key={member.ext} {...member} />
|
The dedicated people who keep LiteNet running smoothly and help our community grow.
|
||||||
))}
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h3 className="mb-4 text-2xl font-semibold text-gray-200">Moderation Team</h3>
|
<div className="space-y-16">
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-2">
|
{/* Administration Team */}
|
||||||
{modTeam.map((member) => (
|
<div>
|
||||||
<TeamMember key={member.ext} {...member} />
|
<div className="flex items-center justify-center mb-8">
|
||||||
))}
|
<div className="flex items-center gap-4">
|
||||||
|
<h3 className="text-2xl font-semibold text-center text-gray-200 bg-gradient-to-r from-blue-400 to-blue-300 bg-clip-text text-transparent pb-2">
|
||||||
|
Administration Team
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{adminTeam.map((member, index) => (
|
||||||
|
<div
|
||||||
|
key={member.ext}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 100}ms`,
|
||||||
|
animation: 'fadeInUp 0.8s ease-out forwards'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TeamMember {...member} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Moderation Team */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-center mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h3 className="text-2xl font-semibold text-center text-gray-200 bg-gradient-to-r from-purple-400 to-purple-300 bg-clip-text text-transparent pb-2">
|
||||||
|
Moderation Team
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{modTeam.map((member, index) => (
|
||||||
|
<div
|
||||||
|
key={member.ext}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${(index + adminTeam.length) * 100}ms`,
|
||||||
|
animation: 'fadeInUp 0.8s ease-out forwards'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TeamMember {...member} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>)
|
|
||||||
);
|
<style jsx>{`
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
@ -10,83 +10,104 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
|
||||||
export function TermsOfServiceModal() {
|
export function TermsOfServiceModal() {
|
||||||
React.useEffect(() => {
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (window.location.hash === '#tos') {
|
if (window.location.hash === '#tos') {
|
||||||
document.getElementById('tos-trigger').click();
|
document.getElementById('tos-trigger').click();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button id="tos-trigger" className="text-sm underline underline-offset-4">Terms of Service</button>
|
<button id="tos-trigger" className="text-sm underline underline-offset-4">Terms of Service</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[625px] bg-black text-white border border-gray-800">
|
<AnimatePresence>
|
||||||
<DialogHeader>
|
{open && (
|
||||||
<DialogTitle className="text-2xl font-bold">Terms of Service</DialogTitle>
|
<DialogContent
|
||||||
<DialogDescription className="text-gray-400">
|
className="sm:max-w-[625px] bg-black text-white border border-gray-800 p-0 gap-0"
|
||||||
Please read our Terms of Service carefully.
|
onEscapeKeyDown={() => setOpen(false)}
|
||||||
</DialogDescription>
|
onPointerDownOutside={() => setOpen(false)}
|
||||||
</DialogHeader>
|
forceMount
|
||||||
<ScrollArea className="h-[400px] w-full rounded-md border border-gray-800 p-4">
|
>
|
||||||
<h2 className="text-lg font-semibold mb-2">1. Acceptance of Terms</h2>
|
<motion.div
|
||||||
<p className="mb-4">
|
initial={{ opacity: 0, y: 10 }}
|
||||||
By accessing or using LiteNet's services, you agree to comply with and be bound by these Terms of Service. If you do not agree to these terms, please do not use our services.
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</p>
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="p-6 flex flex-col max-h-[90vh]"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-0 pb-4">
|
||||||
|
<DialogTitle className="text-2xl font-bold">Terms of Service</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Please read our Terms of Service carefully.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="flex-grow border border-gray-800 rounded-md p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">1. Acceptance of Terms</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
By accessing or using LiteNet's services, you agree to comply with and be bound by these Terms of Service. If you do not agree to these terms, please do not use our services.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">2. Discord Community Rules</h2>
|
<h2 className="text-lg font-semibold mb-2">2. Discord Community Rules</h2>
|
||||||
<ul className="list-disc pl-5 mb-4">
|
<ul className="list-disc pl-5 mb-4">
|
||||||
<li>External drama is strictly prohibited.</li>
|
<li>External drama is strictly prohibited.</li>
|
||||||
<li>Discrimination of any kind, including but not limited to sexism, racism, homophobia, and transphobia, is not tolerated.</li>
|
<li>Discrimination of any kind, including but not limited to sexism, racism, homophobia, and transphobia, is not tolerated.</li>
|
||||||
<li>Users must use common sense in all channels. Spamming and posting NSFW content are prohibited.</li>
|
<li>Users must use common sense in all channels. Spamming and posting NSFW content are prohibited.</li>
|
||||||
<li>Chain paging is not allowed at any time. Keep them brief, especially in major page groups.</li>
|
<li>Chain paging is not allowed at any time. Keep them brief, especially in major page groups.</li>
|
||||||
<li>Spam calling external numbers is explicitly prohibited, and will result in termination.</li>
|
<li>Spam calling external numbers is explicitly prohibited, and will result in termination.</li>
|
||||||
<li>Staff reserve the right to delete messages, warn, mute, kick, and ban users for any reason, within reason.</li>
|
<li>Staff reserve the right to delete messages, warn, mute, kick, and ban users for any reason, within reason.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">3. Fax Usage Rules</h2>
|
<h2 className="text-lg font-semibold mb-2">3. Fax Usage Rules</h2>
|
||||||
<ul className="list-disc pl-5 mb-4">
|
<ul className="list-disc pl-5 mb-4">
|
||||||
<li>NSFW content is prohibited in faxes.</li>
|
<li>NSFW content is prohibited in faxes.</li>
|
||||||
<li>Faxes that would take an excessive amount of time to send are not allowed.</li>
|
<li>Faxes that would take an excessive amount of time to send are not allowed.</li>
|
||||||
<li>Extremely long faxes are prohibited.</li>
|
<li>Extremely long faxes are prohibited.</li>
|
||||||
<li>Sending faxes that waste ink excessively is not permitted.</li>
|
<li>Sending faxes that waste ink excessively is not permitted.</li>
|
||||||
<li>Users should not send faxes they wouldn't want to receive themselves.</li>
|
<li>Users should not send faxes they wouldn't want to receive themselves.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Note: These fax rules can be waived with prior consent from the recipient.
|
Note: These fax rules can be waived with prior consent from the recipient.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">4. Conduct</h2>
|
<h2 className="text-lg font-semibold mb-2">4. Conduct</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Users are expected to behave in a respectful and lawful manner. The absence of a specific written rule does not imply that an action is permitted. LiteNet reserves the right to determine what constitutes appropriate behavior.
|
Users are expected to behave in a respectful and lawful manner. The absence of a specific written rule does not imply that an action is permitted. LiteNet reserves the right to determine what constitutes appropriate behavior.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">5. Modifications to Terms</h2>
|
<h2 className="text-lg font-semibold mb-2">5. Modifications to Terms</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
These Terms of Service are subject to change. LiteNet will make an announcement whenever changes occur. It is the user's responsibility to review these terms periodically.
|
These Terms of Service are subject to change. LiteNet will make an announcement whenever changes occur. It is the user's responsibility to review these terms periodically.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">6. Termination of Service</h2>
|
<h2 className="text-lg font-semibold mb-2">6. Termination of Service</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
LiteNet reserves the right to terminate or suspend access to our services, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms of Service.
|
LiteNet reserves the right to terminate or suspend access to our services, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms of Service.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">7. Limitation of Liability</h2>
|
<h2 className="text-lg font-semibold mb-2">7. Limitation of Liability</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
LiteNet shall not be liable for any indirect, incidental, special, consequential or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses, resulting from your access to or use of or inability to access or use the services.
|
LiteNet shall not be liable for any indirect, incidental, special, consequential or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses, resulting from your access to or use of or inability to access or use the services.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold mb-2">8. Governing Law</h2>
|
<h2 className="text-lg font-semibold mb-2">8. Governing Law</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
These Terms shall be governed and construed in accordance with the laws of the jurisdiction in which LiteNet operates, without regard to its conflict of law provisions.
|
These Terms shall be governed and construed in accordance with the laws of the jurisdiction in which LiteNet operates, without regard to its conflict of law provisions.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-4 font-semibold">
|
<p className="mt-4 font-semibold">
|
||||||
By using LiteNet's services, you acknowledge that you have read, understood, and agree to be bound by these Terms of Service.
|
By using LiteNet's services, you acknowledge that you have read, understood, and agree to be bound by these Terms of Service.
|
||||||
</p>
|
</p>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</motion.div>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
104
src/components/ui/alert-dialog.jsx
Normal file
104
src/components/ui/alert-dialog.jsx
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
35
src/components/ui/badge.jsx
Normal file
35
src/components/ui/badge.jsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
20
src/components/ui/input.jsx
Normal file
20
src/components/ui/input.jsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
16
src/components/ui/label.jsx
Normal file
16
src/components/ui/label.jsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
106
src/components/ui/select.jsx
Normal file
106
src/components/ui/select.jsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
}
|
10
src/components/ui/skeleton.jsx
Normal file
10
src/components/ui/skeleton.jsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (<div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
24
src/components/ui/switch.jsx
Normal file
24
src/components/ui/switch.jsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)} />
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
46
src/components/ui/tabs.jsx
Normal file
46
src/components/ui/tabs.jsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
79
src/contexts/AuthContext.jsx
Normal file
79
src/contexts/AuthContext.jsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [token, setToken] = useState(null);
|
||||||
|
const [noExtension, setNoExtension] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setToken(null);
|
||||||
|
setNoExtension(false);
|
||||||
|
localStorage.removeItem('session_token');
|
||||||
|
router.push('/dashboard');
|
||||||
|
}, [token, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tokenFromParams = searchParams.get('token');
|
||||||
|
const storedToken = localStorage.getItem('session_token');
|
||||||
|
|
||||||
|
if (tokenFromParams) {
|
||||||
|
if (tokenFromParams === 'noext.0') {
|
||||||
|
setNoExtension(true);
|
||||||
|
setToken(null);
|
||||||
|
localStorage.removeItem('session_token');
|
||||||
|
} else {
|
||||||
|
setToken(tokenFromParams);
|
||||||
|
localStorage.setItem('session_token', tokenFromParams);
|
||||||
|
setNoExtension(false);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
// Clean URL
|
||||||
|
router.replace('/dashboard', undefined, { shallow: true });
|
||||||
|
} else if (storedToken) {
|
||||||
|
setToken(storedToken);
|
||||||
|
setNoExtension(false);
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
token,
|
||||||
|
isLoggedIn: !!token,
|
||||||
|
noExtension,
|
||||||
|
loading,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
|
@ -26,6 +26,15 @@ export const Authors = {
|
||||||
avatar: '/nick.webp',
|
avatar: '/nick.webp',
|
||||||
bio: 'Administrator at LiteNet'
|
bio: 'Administrator at LiteNet'
|
||||||
},
|
},
|
||||||
|
JARYN: {
|
||||||
|
id: 'jaryn',
|
||||||
|
name: 'Jaryn',
|
||||||
|
role: 'Previous Administrator',
|
||||||
|
ext: '1144',
|
||||||
|
discord: '@jaryn.',
|
||||||
|
avatar: '/jaryn.webp',
|
||||||
|
bio: 'Administrator at LiteNet'
|
||||||
|
},
|
||||||
FAUX_LEMONS: {
|
FAUX_LEMONS: {
|
||||||
id: 'faux_lemons',
|
id: 'faux_lemons',
|
||||||
name: 'Faux Lemons',
|
name: 'Faux Lemons',
|
||||||
|
@ -47,7 +56,7 @@ export const Authors = {
|
||||||
MADDIX: {
|
MADDIX: {
|
||||||
id: 'maddix',
|
id: 'maddix',
|
||||||
name: 'Maddix',
|
name: 'Maddix',
|
||||||
role: 'Moderator',
|
role: 'Administrator',
|
||||||
ext: '1019',
|
ext: '1019',
|
||||||
discord: '@maddix6859',
|
discord: '@maddix6859',
|
||||||
avatar: '/maddix.webp',
|
avatar: '/maddix.webp',
|
||||||
|
@ -56,10 +65,10 @@ export const Authors = {
|
||||||
ROCORD: {
|
ROCORD: {
|
||||||
id: 'rocord',
|
id: 'rocord',
|
||||||
name: 'rocord',
|
name: 'rocord',
|
||||||
role: 'Moderator',
|
role: 'Administrator',
|
||||||
ext: '2222',
|
ext: '2222',
|
||||||
discord: '@rocord',
|
discord: '@rocord',
|
||||||
avatar: '/rocord.webp',
|
avatar: '/rocord.webp',
|
||||||
bio: 'Website Developer and VoIP Specialist'
|
bio: 'Administrator at LiteNet'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
0
src/data/config.js
Normal file
0
src/data/config.js
Normal file
|
@ -1,6 +1,53 @@
|
||||||
import { Authors } from './authors';
|
import { Authors } from './authors';
|
||||||
|
|
||||||
export const updates = [
|
export const updates = [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: "Web Dashboard Release",
|
||||||
|
timestamp: "2025-07-06T12:00:00Z",
|
||||||
|
author: Authors.ROCORD,
|
||||||
|
summary: "Introducing the new LiteNet Web Dashboard",
|
||||||
|
content: `
|
||||||
|
# Web Dashboard Release
|
||||||
|
Hello LiteNet Community!
|
||||||
|
We're thrilled to announce the release of the **LiteNet Web Dashboard**! 🎉
|
||||||
|
|
||||||
|
This new feature allows you to manage your LiteNet account directly from your web browser, making it easier than ever to access your settings, view call logs, and manage your SIP credentials.
|
||||||
|
## Key Features:
|
||||||
|
- **View Call Logs**: See your recent call history and details.
|
||||||
|
- **Manage SIP Credentials**: Easily view and reset your SIP secret.
|
||||||
|
- **Call Me Test**: Initiate a test call to your extension directly from the dashboard.
|
||||||
|
- **View Extension Details**: Check your extension status, registration info, and current settings.
|
||||||
|
- **Active Call Management**: Monitor and control your active calls, including the ability to end calls remotely.
|
||||||
|
|
||||||
|
## How to Access:
|
||||||
|
To access the Web Dashboard, simply press the **Dashboard** button in the top right corner of the LiteNet website. You can also access it directly at [litenet.tel/dashboard](https://litenet.tel/dashboard).
|
||||||
|
|
||||||
|
We hope you enjoy this new feature! As always, your feedback is welcome. If you have any questions or suggestions, feel free to reach out in the LiteNet Discord server.
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Moderator Applications Now Open",
|
||||||
|
timestamp: "2025-03-10T12:00:00Z",
|
||||||
|
author: Authors.JARYN,
|
||||||
|
summary: "LiteNet is now accepting applications for new moderators",
|
||||||
|
content: `
|
||||||
|
# Moderator Applications Now Open
|
||||||
|
Hello LiteNet Community!
|
||||||
|
|
||||||
|
We're excited to announce that **moderator applications are now open!** If you're passionate about LiteNet and want to help keep our community running smoothly, this is your chance to step up and contribute.
|
||||||
|
|
||||||
|
## Requirements:
|
||||||
|
- Must have basic knowledge of **FreePBX/Asterisk** and the **FreePBX Administration GUI**
|
||||||
|
- Must have been a LiteNet user for at least **2 months**
|
||||||
|
- Must have sent a **minimum of 100 messages** in the LiteNet Discord
|
||||||
|
|
||||||
|
If you meet the requirements and are interested in becoming a moderator, [fill out the application here.](https://forms.gle/xQcfKPzSNc2mFMbc7)
|
||||||
|
|
||||||
|
Applications will be reviewed by the admin team. Let us know if you have any questions!
|
||||||
|
`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "LiteNet & Snakecraft Hosting Partnership",
|
title: "LiteNet & Snakecraft Hosting Partnership",
|
||||||
|
|
Loading…
Reference in a new issue