hardware survey, conferences, mobile support, and directory search
Some checks failed
Deploy to Server / deploy (push) Failing after 1m31s
Some checks failed
Deploy to Server / deploy (push) Failing after 1m31s
This commit is contained in:
parent
0252adca64
commit
b1fd02b67c
|
@ -20,6 +20,7 @@
|
|||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
|
@ -35,6 +35,9 @@ importers:
|
|||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.7
|
||||
version: 1.2.7(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
@ -663,6 +666,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.7':
|
||||
resolution: {integrity: sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==}
|
||||
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
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
|
@ -3147,6 +3163,25 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.7(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-popper': 1.2.7(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-presence': 1.1.4(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
|
28
src/app/conferences/[id]/page.jsx
Normal file
28
src/app/conferences/[id]/page.jsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import Header from '@/components/header';
|
||||
import Footer from '@/components/footer';
|
||||
import ConferenceDetails from '@/components/conferences/conference-details';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
// This tells Next.js to allow pages to be generated on-demand
|
||||
// for params not returned by generateStaticParams.
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
// Since conference rooms are dynamic and may require authentication to list,
|
||||
// we can't statically generate them at build time.
|
||||
// Returning an empty array will prevent build errors, and with dynamicParams = true,
|
||||
// pages will be generated on-demand when visited.
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function ConferenceDetailsPage({ params }) {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Header />
|
||||
<main className="flex-grow">
|
||||
<ConferenceDetails conferenceId={params.id} />
|
||||
</main>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
31
src/app/conferences/page.jsx
Normal file
31
src/app/conferences/page.jsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import Header from '@/components/header';
|
||||
import Footer from '@/components/footer';
|
||||
import ConferenceList from '@/components/conferences/conference-list';
|
||||
import ConferenceDetails from '@/components/conferences/conference-details';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
function ConferencesPageContent({ searchParams }) {
|
||||
const conferenceId = searchParams?.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="flex-grow">
|
||||
{conferenceId ? (
|
||||
<ConferenceDetails conferenceId={conferenceId} />
|
||||
) : (
|
||||
<ConferenceList />
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConferencesPage({ searchParams }) {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ConferencesPageContent searchParams={searchParams} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
24
src/app/hardware-survey/page.jsx
Normal file
24
src/app/hardware-survey/page.jsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import Header from '@/components/header';
|
||||
import Footer from '@/components/footer';
|
||||
import HardwareSurvey from '@/components/hardware-survey';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
function HardwareSurveyPageContent() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="flex-grow">
|
||||
<HardwareSurvey />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwareSurveyPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<HardwareSurveyPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
|
@ -8,22 +8,22 @@ export default function UpdatesPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="container py-24">
|
||||
<header className="mb-12">
|
||||
<h1 className="text-4xl font-bold mb-4">All Updates</h1>
|
||||
<Link href="/" className="text-blue-500 hover:underline">
|
||||
<div className="container py-12 sm:py-24 px-4">
|
||||
<header className="mb-8 sm:mb-12">
|
||||
<h1 className="text-2xl sm:text-4xl font-bold mb-4">All Updates</h1>
|
||||
<Link href="/" className="text-blue-500 hover:underline text-sm sm:text-base">
|
||||
← Back to home
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="grid gap-6 sm:gap-8 grid-cols-1 lg:grid-cols-2">
|
||||
{sortedUpdates.map(post => (
|
||||
<div key={post.id} className="group p-6 border border-gray-800 rounded-lg hover:border-blue-500 transition-colors">
|
||||
<div key={post.id} className="group p-4 sm:p-6 border border-gray-800 rounded-lg hover:border-blue-500 transition-colors">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<img
|
||||
src={post.author.avatar}
|
||||
alt={post.author.name}
|
||||
className="w-10 h-10 rounded-full"
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 rounded-full"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
|
@ -32,15 +32,15 @@ export default function UpdatesPage() {
|
|||
<p className="text-xs text-gray-400">{post.author.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-2">{post.title}</h3>
|
||||
<h3 className="text-lg sm:text-2xl font-semibold mb-2">{post.title}</h3>
|
||||
<DateDisplay
|
||||
timestamp={post.timestamp}
|
||||
className="text-sm text-gray-400 mb-3 block"
|
||||
className="text-xs sm:text-sm text-gray-400 mb-3 block"
|
||||
/>
|
||||
<p className="text-gray-400 mb-4">{post.summary}</p>
|
||||
<p className="text-sm sm:text-base text-gray-400 mb-4 leading-relaxed">{post.summary}</p>
|
||||
<Link
|
||||
href={`/updates/${post.id}`}
|
||||
className="text-blue-500 hover:underline inline-flex items-center"
|
||||
className="text-blue-500 hover:underline inline-flex items-center text-sm"
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
|
|
231
src/components/conferences/conference-details.jsx
Normal file
231
src/components/conferences/conference-details.jsx
Normal file
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, User, Mic, MicOff, Crown, ArrowLeft, ServerCrash, UserX, Phone, Lock, LogIn } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export default function ConferenceDetails({ conferenceId }) {
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const [conferenceInfo, setConferenceInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const { token, isLoggedIn } = useAuth();
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [currentUserExtension, setCurrentUserExtension] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && token) {
|
||||
const fetchUserDetails = async () => {
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/me`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCurrentUserExtension(data.extensionId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch user details", err);
|
||||
}
|
||||
};
|
||||
fetchUserDetails();
|
||||
}
|
||||
}, [isLoggedIn, token]);
|
||||
|
||||
const fetchDetails = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Fetch both participants and conference room info
|
||||
const [participantsRes, roomsRes] = await Promise.all([
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences/${conferenceId}`),
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences`)
|
||||
]);
|
||||
|
||||
if (!participantsRes.ok) {
|
||||
const errorData = await participantsRes.json();
|
||||
if (participantsRes.status === 401) throw new Error("Unauthorized: Please log in to view conference details.");
|
||||
throw new Error(errorData.error || `Failed to fetch details for conference ${conferenceId}`);
|
||||
}
|
||||
const participantsData = await participantsRes.json();
|
||||
setParticipants(Array.isArray(participantsData) ? participantsData : []);
|
||||
|
||||
if (roomsRes.ok) {
|
||||
const roomsData = await roomsRes.json();
|
||||
const currentRoom = roomsData.find(room => room.conferenceId === conferenceId);
|
||||
setConferenceInfo(currentRoom);
|
||||
} else {
|
||||
// If fetching all rooms fails, we can assume we don't know the lock status
|
||||
setConferenceInfo(null);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch details for ${conferenceId}`, err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [conferenceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetails();
|
||||
const interval = setInterval(fetchDetails, 5000); // Refresh every 5 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchDetails]);
|
||||
|
||||
const handleConnectMe = async () => {
|
||||
if (!token) return;
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences/${conferenceId}/connectme`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.error || 'Failed to connect to conference');
|
||||
}
|
||||
// Optionally show a success message
|
||||
} catch (err) {
|
||||
console.error("Failed to connect", err);
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isUserInConference = participants.some(p => p.callerIdNum === currentUserExtension);
|
||||
|
||||
const ConnectButton = () => {
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<Button asChild className="w-full sm:w-auto">
|
||||
<Link href="/dashboard">
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
Sign in to Connect
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (conferenceInfo?.locked) {
|
||||
return (
|
||||
<Button disabled className="w-full sm:w-auto">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Conference is Locked
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUserInConference) {
|
||||
return (
|
||||
<Button disabled className="w-full sm:w-auto">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Already in Conference
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleConnectMe} disabled={isConnecting || !currentUserExtension} className="w-full sm:w-auto">
|
||||
<Phone className={`mr-2 h-4 w-4 ${isConnecting ? 'animate-pulse' : ''}`} />
|
||||
{isConnecting ? 'Connecting...' : 'Connect Me'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-12 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/conferences">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Conferences
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
Conference <span className="text-blue-400">{conferenceId}</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300">
|
||||
Participants currently in the room.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 mt-6">
|
||||
<Button onClick={fetchDetails} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="bg-red-950/50 border-red-800 mb-8">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<ServerCrash className="h-5 w-5 text-red-400" />
|
||||
<div className="text-red-100">
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i} className="bg-gray-950/50 border-gray-800 p-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : participants.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{participants.map(p => (
|
||||
<Card key={p.channel} className={`bg-gray-950/50 border-gray-800 transition-all duration-300 ${p.talking ? 'border-green-500/50 shadow-lg shadow-green-500/10' : ''}`}>
|
||||
<CardContent className="p-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-full ${p.talking ? 'bg-green-900/50' : 'bg-gray-800'}`}>
|
||||
<User className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">{p.callerIdName}</div>
|
||||
<div className="text-sm text-blue-400 font-mono">{p.callerIdNum}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{p.admin && <Badge variant="outline" className="border-yellow-500/50 bg-yellow-900/20 text-yellow-300"><Crown className="h-3 w-3 mr-1" /> Admin</Badge>}
|
||||
{p.muted ? <MicOff className="h-5 w-5 text-red-400" title="Muted" /> : <Mic className="h-5 w-5 text-green-400" title="Unmuted" />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 text-gray-500 flex flex-col items-center justify-center border-2 border-dashed border-gray-800 rounded-lg">
|
||||
<UserX className="mx-auto h-16 w-16 mb-4 text-gray-600" />
|
||||
<h3 className="text-2xl font-semibold text-gray-300">Room is Empty</h3>
|
||||
<p className="mt-2">There are no participants in this conference room.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
133
src/components/conferences/conference-list.jsx
Normal file
133
src/components/conferences/conference-list.jsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Users, Lock, Unlock, ArrowRight, ServerCrash, Radio } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export default function ConferenceList() {
|
||||
const [conferences, setConferences] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchConferences = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Acting as an unauthenticated user, so no auth token is sent.
|
||||
// This assumes the endpoint is public.
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences`);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
if (res.status === 401) {
|
||||
throw new Error("Unauthorized: Please log in to view conferences.");
|
||||
}
|
||||
throw new Error(errorData.error || 'Failed to fetch conferences');
|
||||
}
|
||||
const data = await res.json();
|
||||
setConferences(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch conferences", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConferences();
|
||||
const interval = setInterval(fetchConferences, 15000); // Refresh every 15 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchConferences]);
|
||||
|
||||
return (
|
||||
<div className="container py-12 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4 bg-gradient-to-r from-white via-green-100 to-white bg-clip-text text-transparent">
|
||||
Active Conferences
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||
Join a conversation or see who's currently in a conference.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 mt-6">
|
||||
<Button onClick={fetchConferences} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="bg-red-950/50 border-red-800 mb-8">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<ServerCrash className="h-5 w-5 text-red-400" />
|
||||
<div className="text-red-100">
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="bg-gray-950/50 border-gray-800 p-6">
|
||||
<Skeleton className="h-6 w-3/4 mb-4" />
|
||||
<Skeleton className="h-4 w-1/2 mb-6" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : conferences.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{conferences.map(room => (
|
||||
<Card key={room.conferenceId} className="group bg-gray-950/50 border-gray-800 hover:border-blue-500/50 transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-2xl font-bold flex items-center gap-3">
|
||||
<Radio className="h-6 w-6 text-blue-400" />
|
||||
{room.conferenceId}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{room.parties} participant(s)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<Users className="h-5 w-5" />
|
||||
<span>{room.parties}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
{room.locked ? <Lock className="h-5 w-5 text-red-400" /> : <Unlock className="h-5 w-5 text-green-400" />}
|
||||
<span>{room.locked ? 'Locked' : 'Open'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/conferences?id=${room.conferenceId}`}>
|
||||
View <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 text-gray-500 flex flex-col items-center justify-center border-2 border-dashed border-gray-800 rounded-lg">
|
||||
<Radio className="mx-auto h-16 w-16 mb-4 text-gray-600" />
|
||||
<h3 className="text-2xl font-semibold text-gray-300">No Active Conferences</h3>
|
||||
<p className="mt-2">When a conference starts, it will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -75,12 +75,12 @@ export function QuickActionsCard({ details, loading }) {
|
|||
</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">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-4 sm:items-center sm:gap-4">
|
||||
<Label htmlFor="mode" className="text-sm font-medium sm:text-right">
|
||||
Mode
|
||||
</Label>
|
||||
<Select value={callMode} onValueChange={setCallMode}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectTrigger className="sm:col-span-3">
|
||||
<SelectValue placeholder="Select a mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
@ -89,15 +89,15 @@ export function QuickActionsCard({ details, loading }) {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="callerId" className="text-right">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-4 sm:items-center sm:gap-4">
|
||||
<Label htmlFor="callerId" className="text-sm font-medium sm:text-right">
|
||||
Caller ID
|
||||
</Label>
|
||||
<Input
|
||||
id="callerId"
|
||||
value={callerId}
|
||||
onChange={(e) => setCallerId(e.target.value)}
|
||||
className="col-span-3"
|
||||
className="sm:col-span-3"
|
||||
placeholder={`Optional (e.g., ${details?.extensionId})`}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -155,19 +155,20 @@ export default function ApiKeys() {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between mb-4">
|
||||
<div className="flex flex-col gap-4 mb-4 sm:flex-row sm:justify-between">
|
||||
<Input
|
||||
placeholder="Search keys..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="max-w-xs bg-gray-900 border-gray-700"
|
||||
className="w-full sm: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>
|
||||
<Button size="sm" variant={filterType === 'all' ? 'secondary' : 'ghost'} className="flex-1 sm:flex-none text-xs sm:text-sm" onClick={() => setFilterType('all')}>All</Button>
|
||||
<Button size="sm" variant={filterType === 'api' ? 'secondary' : 'ghost'} className="flex-1 sm:flex-none text-xs sm:text-sm" onClick={() => setFilterType('api')}>API</Button>
|
||||
<Button size="sm" variant={filterType === 'session' ? 'secondary' : 'ghost'} className="flex-1 sm:flex-none text-xs sm:text-sm" onClick={() => setFilterType('session')}>Session</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
|
@ -178,12 +179,12 @@ export default function ApiKeys() {
|
|||
<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>
|
||||
<TableHead className="min-w-[120px]">Key</TableHead>
|
||||
<TableHead className="min-w-[80px]">Type</TableHead>
|
||||
<TableHead className="min-w-[100px]">IP Address</TableHead>
|
||||
<TableHead className="min-w-[100px]">Created</TableHead>
|
||||
<TableHead className="min-w-[100px]">Expires</TableHead>
|
||||
<TableHead className="text-right min-w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
@ -228,6 +229,7 @@ export default function ApiKeys() {
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AlertDialog open={!!keyToDelete} onOpenChange={(open) => !open && setKeyToDelete(null)}>
|
||||
|
|
|
@ -25,8 +25,8 @@ function OverviewCard({ details, deviceStatus, loading }) {
|
|||
);
|
||||
|
||||
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">
|
||||
<div className="bg-gray-950/50 border border-gray-800 rounded-lg p-4 sm:p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm: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} />
|
||||
|
@ -190,41 +190,41 @@ export default function DashboardClient() {
|
|||
}
|
||||
|
||||
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">
|
||||
<div className="container py-6 sm:py-12 px-4">
|
||||
<div className="flex flex-col gap-4 mb-6 sm:mb-8 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
{detailsLoading ? (
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
<Skeleton className="h-12 w-12 sm:h-16 sm:w-16 rounded-full" />
|
||||
) : (
|
||||
<Avatar className="h-16 w-16 border-2 border-blue-500/50">
|
||||
<Avatar className="h-12 w-12 sm:h-16 sm: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>
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-white">My Dashboard</h1>
|
||||
{detailsLoading ? (
|
||||
<Skeleton className="h-6 w-48 mt-2" />
|
||||
<Skeleton className="h-4 sm:h-6 w-32 sm:w-48 mt-1 sm:mt-2" />
|
||||
) : details ? (
|
||||
<p className="text-gray-400">Welcome back, {details.user?.name}!</p>
|
||||
<p className="text-sm sm:text-base text-gray-400">Welcome back, {details.user?.name}!</p>
|
||||
) : (
|
||||
<p className="text-red-400">Could not load your details. Please try again later.</p>
|
||||
<p className="text-sm sm:text-base text-red-400">Could not load your details. Please try again later.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={logout}>
|
||||
<Button variant="outline" onClick={logout} className="w-full sm:w-auto">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Overview Card */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-6 sm: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">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 sm:gap-8">
|
||||
{/* Main Content */}
|
||||
<main className="lg:col-span-2 space-y-8">
|
||||
<ActiveCalls />
|
||||
|
|
|
@ -12,14 +12,16 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export function DirectoryModal() {
|
||||
export function DirectoryModal({ isMobile = false }) {
|
||||
const [extensions, setExtensions] = 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 [searchTerm, setSearchTerm] = useState('');
|
||||
const [topUserExt, setTopUserExt] = useState(null);
|
||||
|
||||
const fetchDirectory = async () => {
|
||||
|
@ -171,15 +173,28 @@ export function DirectoryModal() {
|
|||
}
|
||||
};
|
||||
|
||||
// Filter extensions based on the switch state
|
||||
const filteredExtensions = showConnectedOnly
|
||||
? extensions.filter(ext => ext.statusType === 'online' || ext.statusType === 'in-use')
|
||||
: extensions;
|
||||
// Filter extensions based on the switch state and search term
|
||||
const filteredExtensions = extensions
|
||||
.filter(ext => {
|
||||
if (showConnectedOnly && !['online', 'in-use'].includes(ext.statusType)) {
|
||||
return false;
|
||||
}
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return ext.number.includes(term) || ext.name.toLowerCase().includes(term);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-gray-700 bg-gray-900 text-white hover:bg-gray-800">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`border-gray-700 bg-gray-900 text-white hover:bg-gray-800 ${
|
||||
isMobile ? 'w-full' : ''
|
||||
}`}
|
||||
>
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
Directory
|
||||
</Button>
|
||||
|
@ -187,7 +202,7 @@ export function DirectoryModal() {
|
|||
<AnimatePresence>
|
||||
{open && (
|
||||
<DialogContent
|
||||
className="sm:max-w-[800px] bg-black text-white border border-gray-800 p-0 gap-0"
|
||||
className="sm:max-w-[900px] w-[95vw] bg-black text-white border border-gray-800 p-0 gap-0 rounded-xl"
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
onPointerDownOutside={() => setOpen(false)}
|
||||
forceMount
|
||||
|
@ -197,48 +212,57 @@ export function DirectoryModal() {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="p-6 flex flex-col h-full"
|
||||
className="p-4 sm:p-6 flex flex-col h-[85vh] sm:h-auto sm:max-h-[85vh]"
|
||||
>
|
||||
<DialogHeader className="px-0 pb-4 flex-shrink-0">
|
||||
<DialogTitle className="text-2xl font-bold">LiteNet PBX Directory</DialogTitle>
|
||||
<DialogTitle className="text-xl sm: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">
|
||||
<Badge variant="outline" className="bg-gray-900 text-xs">
|
||||
{summary.total} Endpoints
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-gray-900">
|
||||
<Badge variant="outline" className="bg-gray-900 text-xs">
|
||||
{summary.connected} Extensions
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-green-900/30">
|
||||
<Badge variant="outline" className="bg-green-900/30 text-xs">
|
||||
{summary.online} Online
|
||||
</Badge>
|
||||
{summary.inUse > 0 && (
|
||||
<Badge variant="outline" className="bg-orange-900/30">
|
||||
<Badge variant="outline" className="bg-orange-900/30 text-xs">
|
||||
{summary.inUse} In Use
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="bg-gray-900">
|
||||
<Badge variant="outline" className="bg-gray-900 text-xs">
|
||||
Avg. Latency: {summary.avgLatency.toFixed(2)}ms
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4 flex-shrink-0">
|
||||
<div className="w-full sm:w-auto flex-grow">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by extension or name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-gray-900 border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<Label htmlFor="connected-only" className="text-sm">Show connected extensions only</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 w-full sm:w-auto"
|
||||
onClick={fetchDirectory}
|
||||
disabled={loading}
|
||||
>
|
||||
|
@ -247,8 +271,8 @@ export function DirectoryModal() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow overflow-hidden border border-gray-800 rounded-md">
|
||||
<ScrollArea className="h-[50vh] w-full">
|
||||
<div className="flex-1 min-h-0 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{loading && (
|
||||
<div className="space-y-3">
|
||||
|
@ -292,7 +316,37 @@ export function DirectoryModal() {
|
|||
transition={{ duration: 0.3 }}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="min-w-full">
|
||||
{/* Mobile card view */}
|
||||
<div className="block sm:hidden space-y-3">
|
||||
{filteredExtensions.map((ext, index) => (
|
||||
<motion.div
|
||||
key={ext.number}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
delay: index * 0.03
|
||||
}}
|
||||
className="bg-gray-900/50 p-3 rounded-lg border border-gray-800"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(ext.statusType)}
|
||||
<span className="font-medium text-sm">{ext.number}</span>
|
||||
{ext.number === topUserExt && (
|
||||
<Crown className="h-3 w-3 text-yellow-400 fill-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{ext.latency}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-300 truncate">{ext.name}</div>
|
||||
<div className="text-xs text-gray-500">{ext.endpoints} endpoints</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="min-w-full divide-y divide-gray-800">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -350,8 +404,8 @@ export function DirectoryModal() {
|
|||
transition={{ duration: 0.3 }}
|
||||
className="text-center py-8 text-gray-400"
|
||||
>
|
||||
{showConnectedOnly
|
||||
? "No connected extensions found"
|
||||
{showConnectedOnly || searchTerm
|
||||
? "No matching extensions found"
|
||||
: "No extensions found"}
|
||||
</motion.div>
|
||||
)}
|
||||
|
@ -360,9 +414,7 @@ export function DirectoryModal() {
|
|||
</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`}
|
||||
{`Showing ${filteredExtensions.length} of ${extensions.length} extensions`}
|
||||
</div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
|
|
|
@ -70,16 +70,16 @@ 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">
|
||||
<div className="text-center max-w-3xl mx-auto mb-16 px-4">
|
||||
<h2 className="text-3xl sm: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">
|
||||
<p className="text-lg sm: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">
|
||||
<div className="grid gap-6 sm:gap-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((feature, index) => (
|
||||
<Card
|
||||
key={feature.name}
|
||||
|
|
|
@ -5,21 +5,24 @@ import Link from 'next/link'
|
|||
export default function Footer() {
|
||||
return (
|
||||
<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">
|
||||
<div className="container relative mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<div className="flex flex-col items-center gap-6 sm:gap-8 text-center">
|
||||
{/* Logo and copyright */}
|
||||
<div className="flex flex-col items-center gap-3 sm:gap-4">
|
||||
<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" />
|
||||
<img src="/litenet-logo.png" alt="LiteNet Logo" className="relative h-6 w-6 sm:h-8 sm:w-8 drop-shadow-lg" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
© {new Date().getFullYear()} The LiteNet Group. All rights reserved.
|
||||
<span className="text-base sm:text-lg font-semibold text-white">The LiteNet Group</span>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-gray-300">
|
||||
© {new Date().getFullYear()} All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex gap-6 items-center text-sm">
|
||||
<nav className="flex flex-col gap-4 items-center text-sm sm:flex-row sm:gap-6">
|
||||
<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" />
|
||||
|
@ -32,10 +35,18 @@ export default function Footer() {
|
|||
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>
|
||||
<Link href="/hardware-survey" className="hover:text-blue-400 transition-colors duration-300 relative group">
|
||||
Hardware Survey
|
||||
<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="/conferences" className="hover:text-blue-400 transition-colors duration-300 relative group">
|
||||
Conferences
|
||||
<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">
|
||||
<div className="flex flex-col gap-4 items-center text-sm sm:flex-row sm:gap-6">
|
||||
<TermsOfServiceModal />
|
||||
<PrivacyPolicyModal />
|
||||
</div>
|
||||
|
|
360
src/components/hardware-survey.jsx
Normal file
360
src/components/hardware-survey.jsx
Normal file
|
@ -0,0 +1,360 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, HardDrive, Phone, Wifi, Server, AlertCircle, TrendingUp, Users, Calendar } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
function StatCard({ title, value, icon, color = "blue", loading = false }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-gray-950/50 border-gray-800">
|
||||
<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>
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
blue: 'text-blue-400 bg-blue-900/20',
|
||||
green: 'text-green-400 bg-green-900/20',
|
||||
purple: 'text-purple-400 bg-purple-900/20',
|
||||
orange: 'text-orange-400 bg-orange-900/20',
|
||||
red: 'text-red-400 bg-red-900/20'
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-950/50 border-gray-800 hover:border-gray-700 transition-colors">
|
||||
<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>
|
||||
<div className={`p-2 rounded-lg ${colorClasses[color]}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryChart({ title, data, loading = false }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-gray-950/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const sortedData = Object.entries(data).sort(([,a], [,b]) => b - a);
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
const colors = [
|
||||
'bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-orange-500', 'bg-red-500',
|
||||
'bg-cyan-500', 'bg-pink-500', 'bg-indigo-500', 'bg-yellow-500', 'bg-gray-500'
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-950/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">{title}</CardTitle>
|
||||
<CardDescription>Distribution of {title.toLowerCase()} in the network</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{sortedData.map(([name, count], index) => {
|
||||
const percentage = total > 0 ? (count / total * 100).toFixed(1) : 0;
|
||||
return (
|
||||
<div key={name} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-300 truncate flex-1" title={name}>{name}</span>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
<span className="text-gray-400 text-xs min-w-[3rem] text-right">
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-500 ${colors[index % colors.length]}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="pt-3 border-t border-gray-800">
|
||||
<div className="text-sm text-gray-400">
|
||||
Total: <span className="text-white font-semibold">{total}</span> devices
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TopDevicesCard({ phoneTypes, brands, loading = false }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-gray-950/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const topPhoneType = Object.entries(phoneTypes).sort(([,a], [,b]) => b - a)[0];
|
||||
const topBrand = Object.entries(brands).sort(([,a], [,b]) => b - a)[0];
|
||||
const totalDevices = Object.values(phoneTypes).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-950/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Network Highlights</CardTitle>
|
||||
<CardDescription>Key statistics from the hardware survey</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400">Most Popular Type</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-white font-medium">{topPhoneType?.[0] || 'N/A'}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{topPhoneType?.[1] || 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400">Top Brand</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-green-400" />
|
||||
<span className="text-white font-medium">{topBrand?.[0] || 'N/A'}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{topBrand?.[1] || 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-gray-800">
|
||||
<div className="text-sm text-gray-400">
|
||||
Total Devices: <span className="text-white font-semibold">{totalDevices}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Device Types: <span className="text-white font-semibold">{Object.keys(phoneTypes).length}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Brands: <span className="text-white font-semibold">{Object.keys(brands).length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwareSurvey() {
|
||||
const [surveyData, setSurveyData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
|
||||
const fetchSurveyData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'https://api.litenet.tel'}/system/survey`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch survey data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSurveyData(data);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
console.error('Error fetching survey data:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSurveyData();
|
||||
}, []);
|
||||
|
||||
const totalDevices = surveyData ? Object.values(surveyData.phoneTypes).reduce((sum, count) => sum + count, 0) : 0;
|
||||
const totalBrands = surveyData ? Object.keys(surveyData.brands).length : 0;
|
||||
const totalTypes = surveyData ? Object.keys(surveyData.phoneTypes).length : 0;
|
||||
const needsCategorization = surveyData ? surveyData.needsCategorization.length : 0;
|
||||
|
||||
return (
|
||||
<div className="container py-12 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4 bg-gradient-to-r from-white via-blue-100 to-white bg-clip-text text-transparent">
|
||||
Hardware Survey
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||
Real-time insights into the LiteNet PBX hardware ecosystem
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-6">
|
||||
<Button
|
||||
onClick={fetchSurveyData}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? 'Refreshing...' : 'Refresh Data'}
|
||||
</Button>
|
||||
{lastRefresh && (
|
||||
<div className="text-sm text-gray-400 flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Last updated: {format(lastRefresh, 'PPp')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="bg-red-950/50 border-red-800 mb-8">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
<div className="text-red-100">
|
||||
Failed to load survey data: {error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Total Devices"
|
||||
value={totalDevices}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
color="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Device Types"
|
||||
value={totalTypes}
|
||||
icon={<Phone className="h-4 w-4" />}
|
||||
color="green"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Brands"
|
||||
value={totalBrands}
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
color="purple"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Needs Categorization"
|
||||
value={needsCategorization}
|
||||
icon={<AlertCircle className="h-4 w-4" />}
|
||||
color={needsCategorization > 0 ? "orange" : "green"}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data timestamp */}
|
||||
{surveyData?.lastUpdated && (
|
||||
<div className="text-center mb-8">
|
||||
<Badge variant="outline" className="bg-gray-900 text-gray-300">
|
||||
Survey data from {format(new Date(surveyData.lastUpdated), 'PPp')}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8 mb-8">
|
||||
<CategoryChart
|
||||
title="Phone Types"
|
||||
data={surveyData?.phoneTypes || {}}
|
||||
loading={loading}
|
||||
/>
|
||||
<CategoryChart
|
||||
title="Brands"
|
||||
data={surveyData?.brands || {}}
|
||||
loading={loading}
|
||||
/>
|
||||
<TopDevicesCard
|
||||
phoneTypes={surveyData?.phoneTypes || {}}
|
||||
brands={surveyData?.brands || {}}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Uncategorized devices */}
|
||||
{surveyData?.needsCategorization && surveyData.needsCategorization.length > 0 && (
|
||||
<Card className="bg-yellow-950/50 border-yellow-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-400" />
|
||||
Devices Needing Categorization
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
These devices haven't been categorized yet and may need manual review
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{surveyData.needsCategorization.map((device, index) => (
|
||||
<Badge key={index} variant="outline" className="bg-yellow-900/20 text-yellow-300">
|
||||
{device.ua}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="text-center mt-12 text-gray-400">
|
||||
<p className="text-sm">
|
||||
This survey is automatically generated from active devices on the LiteNet PBX system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,12 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link'
|
||||
import { DirectoryModal } from "@/components/directory-modal";
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LayoutDashboard } from 'lucide-react'
|
||||
import { LayoutDashboard, Menu, X } from 'lucide-react'
|
||||
|
||||
export default function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
(<header
|
||||
className="sticky top-0 z-50 w-full border-b border-gray-800 bg-black/95 backdrop-blur supports-[backdrop-filter]:bg-black/60">
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-800 bg-black/95 backdrop-blur supports-[backdrop-filter]:bg-black/60">
|
||||
<div className="container flex h-14 items-center">
|
||||
<div className="mr-4 hidden md:flex">
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
|
@ -26,11 +30,27 @@ export default function Header() {
|
|||
<Link href="/#team" className="text-gray-300 hover:text-white">
|
||||
Team
|
||||
</Link>
|
||||
<Link href="/hardware-survey" className="text-gray-300 hover:text-white">
|
||||
Hardware Survey
|
||||
</Link>
|
||||
<Link href="/conferences" className="text-gray-300 hover:text-white">
|
||||
Conferences
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
|
||||
{/* Mobile Logo */}
|
||||
<Link href="/" className="flex items-center space-x-2 md:hidden">
|
||||
<img
|
||||
src="/litenet-logo.png"
|
||||
alt="LiteNet Logo"
|
||||
className="h-6 w-6" />
|
||||
<span className="font-bold text-white">LiteNet</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||
{/* Desktop Action Buttons */}
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<DirectoryModal />
|
||||
|
||||
<Button
|
||||
|
@ -56,14 +76,13 @@ export default function Header() {
|
|||
<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" />
|
||||
<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>
|
||||
<Link href="/dashboard">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
|
@ -71,8 +90,112 @@ export default function Header() {
|
|||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="md:hidden p-2"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-6 w-6 transition-transform duration-200" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6 transition-transform duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</header>)
|
||||
</div>
|
||||
|
||||
{/* Mobile menu with animation */}
|
||||
<div className={`md:hidden overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
mobileMenuOpen ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}>
|
||||
<div className="py-4">
|
||||
<div className="container">
|
||||
<nav className="flex flex-col space-y-4">
|
||||
<Link
|
||||
href="/#features"
|
||||
className="text-gray-300 hover:text-white transition-colors px-2 py-2 rounded-md hover:bg-gray-800/50"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Features
|
||||
</Link>
|
||||
<Link
|
||||
href="/#updates"
|
||||
className="text-gray-300 hover:text-white transition-colors px-2 py-2 rounded-md hover:bg-gray-800/50"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Updates
|
||||
</Link>
|
||||
<Link
|
||||
href="/#team"
|
||||
className="text-gray-300 hover:text-white transition-colors px-2 py-2 rounded-md hover:bg-gray-800/50"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Team
|
||||
</Link>
|
||||
<Link
|
||||
href="/hardware-survey"
|
||||
className="text-gray-300 hover:text-white transition-colors px-2 py-2 rounded-md hover:bg-gray-800/50"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Hardware Survey
|
||||
</Link>
|
||||
<Link
|
||||
href="/conferences"
|
||||
className="text-gray-300 hover:text-white transition-colors px-2 py-2 rounded-md hover:bg-gray-800/50"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Conferences
|
||||
</Link>
|
||||
|
||||
<div className="pt-4 border-t border-gray-800 space-y-3">
|
||||
<div onClick={() => setMobileMenuOpen(false)}>
|
||||
<DirectoryModal isMobile={true} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full 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="16"
|
||||
height="16"
|
||||
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="w-full bg-blue-600 text-white hover:bg-blue-700" asChild>
|
||||
<Link href="/dashboard">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ export default function Hero() {
|
|||
|
||||
{/* 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">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight sm:text-5xl md:text-6xl 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>
|
||||
|
@ -28,13 +28,13 @@ export default function Hero() {
|
|||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="max-w-[48rem] mx-auto text-xl leading-relaxed text-gray-300 sm:text-2xl sm:leading-9">
|
||||
<p className="max-w-[48rem] mx-auto text-base leading-relaxed text-gray-300 sm:text-xl sm:leading-8 px-4">
|
||||
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 flex-wrap justify-center gap-4 sm:gap-6 text-sm text-gray-400 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-blue-400" />
|
||||
<span>Free dial-in & out</span>
|
||||
|
@ -50,7 +50,7 @@ export default function Hero() {
|
|||
</div>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4 pt-4">
|
||||
<div className="flex flex-col gap-4 pt-4 px-4 w-full max-w-md mx-auto sm:flex-row sm:justify-center sm:max-w-none">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-lg hover:shadow-xl transition-all duration-300 group"
|
||||
|
|
|
@ -11,7 +11,6 @@ const adminTeam = [
|
|||
{ 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 = [
|
||||
|
@ -72,11 +71,11 @@ export default function Team() {
|
|||
return (
|
||||
<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">
|
||||
<div className="text-center max-w-3xl mx-auto mb-16 px-4">
|
||||
<h2 className="text-3xl sm: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">
|
||||
<p className="text-lg sm:text-xl text-gray-300 leading-relaxed">
|
||||
The dedicated people who keep LiteNet running smoothly and help our community grow.
|
||||
</p>
|
||||
</div>
|
||||
|
@ -86,12 +85,12 @@ export default function 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">
|
||||
<h3 className="text-xl sm: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">
|
||||
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{adminTeam.map((member, index) => (
|
||||
<div
|
||||
key={member.ext}
|
||||
|
@ -110,12 +109,12 @@ export default function 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">
|
||||
<h3 className="text-xl sm: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">
|
||||
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{modTeam.map((member, index) => (
|
||||
<div
|
||||
key={member.ext}
|
||||
|
|
26
src/components/ui/tooltip.jsx
Normal file
26
src/components/ui/tooltip.jsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm text-gray-200 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
Loading…
Reference in a new issue