This commit is contained in:
parent
343971540f
commit
45a967644c
|
@ -1,5 +1,8 @@
|
|||
const nextConfig = {
|
||||
output: 'export',
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
|
||||
},
|
||||
};
|
||||
|
||||
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-avatar": "^1.1.2",
|
||||
"@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-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
|
@ -2890,6 +3053,32 @@
|
|||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
@ -4562,6 +4751,19 @@
|
|||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
@ -5218,6 +5420,14 @@
|
|||
"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": {
|
||||
"version": "16.13.1",
|
||||
"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-avatar": "^1.1.2",
|
||||
"@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-select": "^2.2.5",
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"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 './globals.css'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
|
@ -11,10 +12,17 @@ export const metadata = {
|
|||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
(<html lang="en" className="scroll-smooth">
|
||||
<body className={`${inter.className} min-h-screen bg-black text-gray-100`}>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
{children}
|
||||
<body className={`${inter.className} min-h-screen bg-gray-950 text-gray-300 relative`}>
|
||||
<div className="fixed inset-0 -z-10 bg-gradient-to-br from-gray-950 via-black to-gray-950">
|
||||
<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>
|
||||
<AuthProvider>
|
||||
<div className="flex flex-col min-h-screen relative z-10">
|
||||
{children}
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>)
|
||||
);
|
||||
|
|
|
@ -17,6 +17,7 @@ export default function Home() {
|
|||
<Team />
|
||||
</main>
|
||||
<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
|
||||
id="chatwoot-sdk"
|
||||
strategy="afterInteractive"
|
||||
|
@ -39,7 +40,8 @@ export default function Home() {
|
|||
})(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";
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { List } from 'lucide-react'
|
||||
import { List, RefreshCw, Crown } 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() {
|
||||
const [extensions, setExtensions] = useState([]);
|
||||
const [summary, setSummary] = useState({ total: 0, connected: 0, avgLatency: 0 });
|
||||
const [lastUpdated, setLastUpdated] = useState('');
|
||||
const [summary, setSummary] = useState({ total: 0, connected: 0, online: 0, inUse: 0, avgLatency: 0 });
|
||||
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 () => {
|
||||
try {
|
||||
const response = await fetch('https://corsproxy.io/?url=https://pbx.litenet.tel/status/');
|
||||
const text = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, 'text/html');
|
||||
const fetchDirectory = async () => {
|
||||
setLoading(true);
|
||||
setTopUserExt(null);
|
||||
try {
|
||||
const response = await fetch('https://corsproxy.io/?url=https://pbx.litenet.tel/status/');
|
||||
if (!response.ok) {
|
||||
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
|
||||
const summaryText = doc.querySelector('h2.text-center')?.textContent;
|
||||
if (summaryText) {
|
||||
const match = summaryText.match(/(\d+) Endpoints connected to (\d+) Extensions. With an average latency of ([\d.]+)ms/);
|
||||
if (match) {
|
||||
setSummary({
|
||||
total: parseInt(match[1]),
|
||||
connected: parseInt(match[2]),
|
||||
avgLatency: parseFloat(match[3])
|
||||
});
|
||||
// Improved parsing logic
|
||||
// Parse summary data - look for all h2 and h3 elements
|
||||
const h2Elements = Array.from(doc.querySelectorAll('h2.text-center'));
|
||||
const h3Elements = Array.from(doc.querySelectorAll('h3.text-center'));
|
||||
|
||||
let totalEndpoints = 0;
|
||||
let connectedExtensions = 0;
|
||||
let onlineCount = 0;
|
||||
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 (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<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" />
|
||||
Show Directory
|
||||
</button>
|
||||
Directory
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[800px] bg-black text-white border border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">LiteNet Directory</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
{summary.total} Endpoints connected to {summary.connected} Extensions. Average latency: {summary.avgLatency.toFixed(2)}ms
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[400px] w-full rounded-md border border-gray-800 p-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-white">Extension</TableHead>
|
||||
<TableHead className="text-white">Name</TableHead>
|
||||
<TableHead className="text-white">Status</TableHead>
|
||||
<TableHead className="text-white">Endpoints</TableHead>
|
||||
<TableHead className="text-white">Latency</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extensions.map((ext) => (
|
||||
<TableRow key={ext.number}>
|
||||
<TableCell className="font-medium">{ext.number}</TableCell>
|
||||
<TableCell>{ext.name}</TableCell>
|
||||
<TableCell>{ext.status}</TableCell>
|
||||
<TableCell>{ext.endpoints}</TableCell>
|
||||
<TableCell>{ext.latency}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
<div className="text-sm text-gray-400">{lastUpdated}</div>
|
||||
</DialogContent>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<DialogContent
|
||||
className="sm:max-w-[800px] bg-black text-white border border-gray-800 p-0 gap-0"
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
onPointerDownOutside={() => setOpen(false)}
|
||||
forceMount
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="p-6 flex flex-col h-full"
|
||||
>
|
||||
<DialogHeader className="px-0 pb-4 flex-shrink-0">
|
||||
<DialogTitle className="text-2xl font-bold">LiteNet PBX Directory</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Current directory status
|
||||
</DialogDescription>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<Badge variant="outline" className="bg-gray-900">
|
||||
{summary.total} Endpoints
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-gray-900">
|
||||
{summary.connected} Extensions
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-green-900/30">
|
||||
{summary.online} Online
|
||||
</Badge>
|
||||
{summary.inUse > 0 && (
|
||||
<Badge variant="outline" className="bg-orange-900/30">
|
||||
{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>
|
||||
)
|
||||
}
|
|
@ -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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
const features = [
|
||||
{
|
||||
name: 'Free Dial-in and Out',
|
||||
description: 'Call using +1 (610) LITENET / +1 (610) 548 3638',
|
||||
icon: Phone,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
name: 'Conference Rooms',
|
||||
description: 'Host or join conference calls with multiple participants',
|
||||
icon: Users,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
name: 'Voicemail',
|
||||
description: 'Set up your own private voicemail and receive messages from anyone',
|
||||
icon: VoicemailIcon,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
name: 'Intercom and Paging',
|
||||
description: 'Easily communicate with other extensions and page groups',
|
||||
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!</>,
|
||||
icon: Network,
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
name: 'Discord Bot',
|
||||
description: 'Quickly create your own extension, manage page groups, and view Call Detail Records',
|
||||
icon: Bot,
|
||||
color: 'indigo',
|
||||
},
|
||||
{
|
||||
name: 'UCP Account',
|
||||
description: 'Log in to the User Control Panel to manage your account and settings',
|
||||
icon: UserCog,
|
||||
name: 'Web Dashboard',
|
||||
description: 'Log in to the Web Dashboard to manage your account and settings',
|
||||
icon: LayoutDashboard,
|
||||
color: 'pink',
|
||||
},
|
||||
{
|
||||
name: 'And More!',
|
||||
description: 'We\'re always adding new features to LiteNet!',
|
||||
icon: MoreHorizontal,
|
||||
color: 'gray',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Features() {
|
||||
return (
|
||||
(<section id="features" className="container py-24">
|
||||
<h2 className="mb-12 text-center text-3xl font-bold">Features</h2>
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.name} className="flex flex-col items-center text-center">
|
||||
<div className="mb-4 rounded-full bg-blue-500 p-3">
|
||||
<feature.icon className="h-6 w-6 text-gray-100" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-semibold">{feature.name}</h3>
|
||||
<p className="text-gray-400">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>)
|
||||
);
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-600/20 border-blue-600/30 text-blue-400 group-hover:bg-blue-600/30',
|
||||
green: 'bg-green-600/20 border-green-600/30 text-green-400 group-hover:bg-green-600/30',
|
||||
purple: 'bg-purple-600/20 border-purple-600/30 text-purple-400 group-hover:bg-purple-600/30',
|
||||
orange: 'bg-orange-600/20 border-orange-600/30 text-orange-400 group-hover:bg-orange-600/30',
|
||||
cyan: 'bg-cyan-600/20 border-cyan-600/30 text-cyan-400 group-hover:bg-cyan-600/30',
|
||||
indigo: 'bg-indigo-600/20 border-indigo-600/30 text-indigo-400 group-hover:bg-indigo-600/30',
|
||||
pink: 'bg-pink-600/20 border-pink-600/30 text-pink-400 group-hover:bg-pink-600/30',
|
||||
gray: 'bg-gray-600/20 border-gray-600/30 text-gray-400 group-hover:bg-gray-600/30',
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
return (
|
||||
<section id="features" className="relative py-32 overflow-hidden">
|
||||
<div className="container relative">
|
||||
<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 { PrivacyPolicyModal } from './privacy-policy-modal'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-gray-800 bg-black text-gray-300">
|
||||
<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="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
|
||||
<img src="/litenet-logo.png" alt="LiteNet Logo" className="h-6 w-6" />
|
||||
<p className="text-center text-sm leading-loose md:text-left">
|
||||
© 2024 The LiteNet Group. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<TermsOfServiceModal />
|
||||
<PrivacyPolicyModal />
|
||||
<footer className="relative border-t border-gray-800/50 bg-black/30 backdrop-blur-sm text-gray-400 overflow-hidden">
|
||||
<div className="container relative mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
|
||||
{/* Logo and copyright */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-blue-500/20 blur-md" />
|
||||
<img src="/litenet-logo.png" alt="LiteNet Logo" className="relative h-8 w-8 drop-shadow-lg" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
© {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>
|
||||
</footer>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Link from 'next/link'
|
||||
//import {DirectoryModal} from "@/components/directory-modal";
|
||||
import { DirectoryModal } from "@/components/directory-modal";
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Phone } from 'lucide-react'
|
||||
import { LayoutDashboard } from 'lucide-react'
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
|
@ -17,13 +17,13 @@ export default function Header() {
|
|||
<span className="hidden font-bold text-white sm:inline-block">LiteNet</span>
|
||||
</Link>
|
||||
<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
|
||||
</Link>
|
||||
<Link href="#updates" className="text-gray-300 hover:text-white">
|
||||
<Link href="/#updates" className="text-gray-300 hover:text-white">
|
||||
Updates
|
||||
</Link>
|
||||
<Link href="#team" className="text-gray-300 hover:text-white">
|
||||
<Link href="/#team" className="text-gray-300 hover:text-white">
|
||||
Team
|
||||
</Link>
|
||||
</nav>
|
||||
|
@ -31,6 +31,8 @@ export default function Header() {
|
|||
<div
|
||||
className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
|
||||
<DirectoryModal />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-700 bg-gray-900 text-white hover:bg-gray-800"
|
||||
|
@ -63,13 +65,10 @@ export default function Header() {
|
|||
</a>
|
||||
</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" asChild>
|
||||
<a
|
||||
href="https://pbx.litenet.tel/ucp/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
UCP Login
|
||||
</a>
|
||||
<Link href="/dashboard">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,63 +1,105 @@
|
|||
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() {
|
||||
return (
|
||||
(<section
|
||||
className="container flex flex-col items-center justify-center space-y-4 py-24 text-center">
|
||||
<img
|
||||
src="/litenet-logo.png"
|
||||
alt="LiteNet Logo"
|
||||
className="h-24 w-24" />
|
||||
<h1
|
||||
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl">Welcome to LiteNet</h1>
|
||||
<p
|
||||
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!
|
||||
</p>
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-700 bg-gray-900 text-white hover:bg-gray-800"
|
||||
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>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" asChild>
|
||||
<a
|
||||
href="https://pbx.litenet.tel/ucp/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
UCP Login
|
||||
</a>
|
||||
</Button>
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="container relative flex flex-col items-center justify-center space-y-8 py-32 text-center">
|
||||
{/* Logo with glow effect */}
|
||||
<div className="relative group">
|
||||
<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" />
|
||||
<img
|
||||
src="/litenet-logo.png"
|
||||
alt="LiteNet Logo"
|
||||
className="relative h-32 w-32 drop-shadow-2xl group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main heading with gradient text */}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
|
||||
<span className="bg-gradient-to-r from-white via-blue-100 to-white bg-clip-text text-transparent pb-2">
|
||||
Welcome to
|
||||
</span>
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-blue-400 via-blue-300 to-blue-500 bg-clip-text text-transparent pb-2">
|
||||
LiteNet
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="max-w-[48rem] mx-auto text-xl leading-relaxed text-gray-300 sm:text-2xl sm:leading-9">
|
||||
A free community PBX based on FreePBX. Get your own 4-digit extension and start calling other members today!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature highlights */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-blue-400" />
|
||||
<span>Free dial-in & out</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-blue-400" />
|
||||
<span>Active community</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse" />
|
||||
<span>Consistent uptime</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4 pt-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-lg hover:shadow-xl transition-all duration-300 group"
|
||||
asChild
|
||||
>
|
||||
<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>
|
||||
</section>)
|
||||
);
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
@ -10,71 +10,92 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
|
||||
export function PrivacyPolicyModal() {
|
||||
React.useEffect(() => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.location.hash === '#privacy') {
|
||||
document.getElementById('privacy-trigger').click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button id="privacy-trigger" className="text-sm underline underline-offset-4">Privacy Policy</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[625px] bg-black text-white border border-gray-800">
|
||||
<DialogHeader>
|
||||
<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="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>
|
||||
<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>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<DialogContent
|
||||
className="sm:max-w-[625px] bg-black text-white border border-gray-800 p-0 gap-0"
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
onPointerDownOutside={() => setOpen(false)}
|
||||
forceMount
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">2. Call Detail Records (CDR) Logs</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">3. Call Recording</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">3. Call Recording</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">4. Voicemail Privacy</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">4. Voicemail Privacy</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">5. Data Deletion</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">5. Data Deletion</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">6. Data Security</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">6. Data Security</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">7. Changes to This Privacy Policy</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">7. Changes to This Privacy Policy</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">8. Contact Us</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">8. Contact Us</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
<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.
|
||||
</p>
|
||||
</ScrollArea>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,66 +1,150 @@
|
|||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const adminTeam = [
|
||||
{ name: 'Chris Chrome', ext: '1000', image: '/chris.webp', discord: '@chrischrome' },
|
||||
{ name: 'Ramadya', ext: '1003', image: '/ramsaso.webp', discord: '@ramsaso' },
|
||||
//{ name: 'Nick', ext: '1036', image: '/nick.webp', discord: '@gamewell' },
|
||||
{ name: 'Faux Lemons', ext: '1011', image: '/faux_lemons.webp', discord: '@faux_lemons' },
|
||||
{ name: 'ashton', ext: '1007', image: '/ashton.webp', discord: '@ashtoncarlson' },
|
||||
{ name: 'Jaryn', ext: '1005', image: '/jaryn.webp', discord: 'jaryn.'},
|
||||
{ name: 'Maddix', ext: '1019', image: '/maddix.webp', discord: '@maddix6859' },
|
||||
{ name: 'Chris Chrome', ext: '1000', image: '/chris.webp', discord: '@chrischrome', role: 'Project Lead' },
|
||||
{ name: 'Cayden', ext: '1001', image: '/cayden.webp', discord: '@freepbx', role: 'Co-Lead' },
|
||||
{ name: 'Faux Lemons', ext: '1011', image: '/faux_lemons.webp', discord: '@faux_lemons', role: 'Admin' },
|
||||
{ name: 'ashton', ext: '1007', image: '/ashton.webp', discord: '@ashtoncarlson', role: 'Admin' },
|
||||
{ name: 'rocord', ext: '1010', image: '/rocord.webp', discord: '@rocord', role: 'Admin' },
|
||||
{ name: 'Maddix', ext: '1005', image: '/maddix.webp', discord: '@maddix6859', role: 'Admin' },
|
||||
{ name: 'Nick', ext: '1036', image: '/nick.webp', discord: '@gamewell', role: 'Admin' },
|
||||
]
|
||||
|
||||
const modTeam = [
|
||||
{ name: 'rocord', ext: '2222', image: '/rocord.webp', discord: '@rocord' },
|
||||
{ name: 'Vince', ext: '1101', image: '/vince.webp', discord: '@maybvince' },
|
||||
{ name: 'Theliftoperator', ext: '1134', image: '/theliftoperator.webp', discord: '@theliftoperator' },
|
||||
{ name: 'Nik', ext: '1008', image: '/nottimwakefield.webp', discord: '@nottimwakefield' },
|
||||
{ name: 'Vince', ext: '1101', image: '/vince.webp', discord: '@maybvince', role: 'Moderator' },
|
||||
{ name: 'Theliftoperator', ext: '1134', image: '/theliftoperator.webp', discord: '@theliftoperator', role: 'Moderator' },
|
||||
{ name: 'Nik', ext: '1008', image: '/nottimwakefield.webp', discord: '@nottimwakefield', role: 'Moderator' },
|
||||
]
|
||||
|
||||
function TeamMember({ name, ext, image, discord }) {
|
||||
function TeamMember({ name, ext, image, discord, role }) {
|
||||
return (
|
||||
(<Card className="bg-gray-950 text-white">
|
||||
<CardHeader className="flex flex-row items-center gap-4 space-y-0">
|
||||
<Avatar className="h-14 w-14">
|
||||
<AvatarImage src={image} alt={name} />
|
||||
<AvatarFallback>{name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle>{name}</CardTitle>
|
||||
<div className="text-sm text-gray-400">{discord}</div>
|
||||
<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">
|
||||
{/* Animated background */}
|
||||
<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" />
|
||||
|
||||
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-4">
|
||||
<div className="relative">
|
||||
<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">
|
||||
<AvatarImage src={image} alt={name} className="group-hover:scale-110 transition-transform duration-300" />
|
||||
<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>
|
||||
</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>
|
||||
</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() {
|
||||
return (
|
||||
(<section id="team" className="container py-24">
|
||||
<h2 className="mb-12 text-center text-3xl font-bold text-white">Our Team</h2>
|
||||
<div className="space-y-12">
|
||||
<div>
|
||||
<h3 className="mb-4 text-2xl font-semibold text-gray-200">Administration Team</h3>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{adminTeam.map((member) => (
|
||||
<TeamMember key={member.ext} {...member} />
|
||||
))}
|
||||
</div>
|
||||
<section id="team" className="relative py-32 overflow-hidden">
|
||||
<div className="container relative">
|
||||
<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-purple-100 to-white bg-clip-text text-transparent pb-2">
|
||||
Meet the Team
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 leading-relaxed">
|
||||
The dedicated people who keep LiteNet running smoothly and help our community grow.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-2xl font-semibold text-gray-200">Moderation Team</h3>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-2">
|
||||
{modTeam.map((member) => (
|
||||
<TeamMember key={member.ext} {...member} />
|
||||
))}
|
||||
|
||||
<div className="space-y-16">
|
||||
{/* Administration 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-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>
|
||||
</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";
|
||||
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
@ -10,83 +10,104 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
|
||||
export function TermsOfServiceModal() {
|
||||
React.useEffect(() => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.location.hash === '#tos') {
|
||||
document.getElementById('tos-trigger').click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button id="tos-trigger" className="text-sm underline underline-offset-4">Terms of Service</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[625px] bg-black text-white border border-gray-800">
|
||||
<DialogHeader>
|
||||
<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="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>
|
||||
<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>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<DialogContent
|
||||
className="sm:max-w-[625px] bg-black text-white border border-gray-800 p-0 gap-0"
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
onPointerDownOutside={() => setOpen(false)}
|
||||
forceMount
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
<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>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>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>
|
||||
</ul>
|
||||
<h2 className="text-lg font-semibold mb-2">2. Discord Community Rules</h2>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
<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>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>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>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">3. Fax Usage Rules</h2>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
<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>Extremely long faxes are prohibited.</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>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
Note: These fax rules can be waived with prior consent from the recipient.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">3. Fax Usage Rules</h2>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
<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>Extremely long faxes are prohibited.</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>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
Note: These fax rules can be waived with prior consent from the recipient.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">4. Conduct</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">4. Conduct</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">5. Modifications to Terms</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">5. Modifications to Terms</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">6. Termination of Service</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">6. Termination of Service</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">7. Limitation of Liability</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">7. Limitation of Liability</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">8. Governing Law</h2>
|
||||
<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.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-2">8. Governing Law</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
<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.
|
||||
</p>
|
||||
</ScrollArea>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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',
|
||||
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: {
|
||||
id: 'faux_lemons',
|
||||
name: 'Faux Lemons',
|
||||
|
@ -47,7 +56,7 @@ export const Authors = {
|
|||
MADDIX: {
|
||||
id: 'maddix',
|
||||
name: 'Maddix',
|
||||
role: 'Moderator',
|
||||
role: 'Administrator',
|
||||
ext: '1019',
|
||||
discord: '@maddix6859',
|
||||
avatar: '/maddix.webp',
|
||||
|
@ -56,10 +65,10 @@ export const Authors = {
|
|||
ROCORD: {
|
||||
id: 'rocord',
|
||||
name: 'rocord',
|
||||
role: 'Moderator',
|
||||
role: 'Administrator',
|
||||
ext: '2222',
|
||||
discord: '@rocord',
|
||||
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';
|
||||
|
||||
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,
|
||||
title: "LiteNet & Snakecraft Hosting Partnership",
|
||||
|
|
Loading…
Reference in a new issue