-
-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add online users counter #163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { NextResponse } from 'next/server'; | ||
| import { db } from '@/db'; | ||
| import { activeSessions } from '@/db/schema/sessions'; | ||
| import { cookies } from 'next/headers'; | ||
| import { randomUUID } from 'crypto'; | ||
| import { sql, gte, lt } from 'drizzle-orm'; | ||
|
|
||
| const SESSION_TIMEOUT_MINUTES = 15; | ||
|
|
||
| export async function POST() { | ||
| try { | ||
| const cookieStore = await cookies(); | ||
| let sessionId = cookieStore.get('user_session_id')?.value; | ||
|
|
||
| if (!sessionId) { | ||
| sessionId = randomUUID(); | ||
| } | ||
|
|
||
| await db | ||
| .insert(activeSessions) | ||
| .values({ | ||
| sessionId, | ||
| lastActivity: new Date(), | ||
| }) | ||
| .onConflictDoUpdate({ | ||
| target: activeSessions.sessionId, | ||
| set: { lastActivity: new Date() }, | ||
| }); | ||
|
|
||
| if (Math.random() < 0.05) { | ||
| const cleanupThreshold = new Date( | ||
| Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000 | ||
| ); | ||
|
|
||
| db.delete(activeSessions) | ||
| .where(lt(activeSessions.lastActivity, cleanupThreshold)) | ||
| .catch(err => console.error('Cleanup error:', err)); | ||
| } | ||
|
|
||
| const countThreshold = new Date( | ||
| Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000 | ||
| ); | ||
|
|
||
| const result = await db | ||
| .select({ | ||
| total: sql<number>`count(distinct session_id)`, | ||
| }) | ||
| .from(activeSessions) | ||
| .where(gte(activeSessions.lastActivity, countThreshold)); | ||
|
|
||
| const response = NextResponse.json({ | ||
| online: Number(result[0]?.total || 0), | ||
| }); | ||
|
|
||
| response.cookies.set('user_session_id', sessionId, { | ||
| httpOnly: true, | ||
| secure: process.env.NODE_ENV === 'production', | ||
| sameSite: 'lax', | ||
| maxAge: 60 * 60 * 24 * 30, | ||
| }); | ||
|
|
||
| return response; | ||
| } catch (error) { | ||
| console.error('Join error:', error); | ||
| return NextResponse.json({ online: 0 }, { status: 200 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| 'use client'; | ||
|
|
||
| import { useEffect, useState } from 'react'; | ||
| import { Users, Sparkles } from 'lucide-react'; | ||
|
|
||
| export function OnlineCounterPopup() { | ||
| const [online, setOnline] = useState<number | null>(null); | ||
| const [show, setShow] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| if (sessionStorage.getItem('shown')) return; | ||
|
|
||
| fetch('/api/sessions/activity', { method: 'POST' }) | ||
| .then(r => r.json()) | ||
| .then(data => { | ||
| setOnline(data.online); | ||
| setTimeout(() => setShow(true), 500); | ||
| sessionStorage.setItem('shown', '1'); | ||
| setTimeout(() => setShow(false), 10000); | ||
| }) | ||
| .catch(() => setOnline(null)); | ||
|
Comment on lines
+10
to
+21
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Session tracking stops after the first display in a tab. 🛠️ Suggested fix (always ping, gate UI, and clear timers)- useEffect(() => {
- if (sessionStorage.getItem('shown')) return;
-
- fetch('/api/sessions/activity', { method: 'POST' })
- .then(r => r.json())
- .then(data => {
- setOnline(data.online);
- setTimeout(() => setShow(true), 500);
- sessionStorage.setItem('shown', '1');
- setTimeout(() => setShow(false), 10000);
- })
- .catch(() => setOnline(null));
- }, []);
+ useEffect(() => {
+ const alreadyShown = sessionStorage.getItem('shown');
+ let showTimer: ReturnType<typeof setTimeout> | undefined;
+ let hideTimer: ReturnType<typeof setTimeout> | undefined;
+
+ fetch('/api/sessions/activity', { method: 'POST' })
+ .then(r => r.json())
+ .then(data => {
+ setOnline(data.online);
+ if (!alreadyShown) {
+ showTimer = setTimeout(() => setShow(true), 500);
+ sessionStorage.setItem('shown', '1');
+ hideTimer = setTimeout(() => setShow(false), 10000);
+ }
+ })
+ .catch(() => setOnline(null));
+
+ return () => {
+ if (showTimer) clearTimeout(showTimer);
+ if (hideTimer) clearTimeout(hideTimer);
+ };
+ }, []);🤖 Prompt for AI Agents |
||
| }, []); | ||
|
|
||
| if (!online) return null; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n frontend/components/shared/OnlineCounterPopup.tsxRepository: DevLoversTeam/devlovers.net Length of output: 4716 🏁 Script executed: fd -t f -e ts -e tsx -e js -e json | xargs rg -l "sessions/activity|online" | head -20Repository: DevLoversTeam/devlovers.net Length of output: 255 🏁 Script executed: cat -n frontend/app/api/sessions/activity/route.tsRepository: DevLoversTeam/devlovers.net Length of output: 2324 Use an explicit null check instead of falsy check to support displaying "0 online." 🤖 Prompt for AI Agents |
||
|
|
||
| const getEmoji = (count: number) => { | ||
| if (count === 1) return '🎯'; | ||
| if (count === 2) return '💼'; | ||
| if (count <= 5) return '🚀'; | ||
| if (count <= 10) return '⚡'; | ||
| return '⭐'; | ||
| }; | ||
|
|
||
| const getText = (count: number) => { | ||
| if (count === 1) return 'на крок до цілі'; | ||
| if (count === 2) return 'йдуть до мрії'; | ||
| if (count <= 5) return 'на шляху до оффера'; | ||
| if (count <= 10) return 'ближчі до dream job'; | ||
| return 'крокує до успіху'; | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="fixed bottom-12 right-12 z-50 max-w-md"> | ||
| <div | ||
| className={` | ||
| transition-all duration-500 ease-out | ||
| ${ | ||
| show | ||
| ? 'translate-y-0 opacity-100 scale-100' | ||
| : 'translate-y-4 opacity-0 scale-90' | ||
| } | ||
| `} | ||
| > | ||
| <div className="absolute inset-0 bg-gradient-to-r from-blue-400/20 via-purple-400/20 to-pink-400/20 dark:from-blue-500/30 dark:via-purple-500/30 dark:to-pink-500/30 blur-2xl animate-pulse" /> | ||
|
|
||
| <div className="relative inline-flex items-center gap-3 px-5 py-3.5 rounded-2xl bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 dark:from-blue-600 dark:via-purple-600 dark:to-pink-600 shadow-2xl"> | ||
| <div className="relative flex-shrink-0"> | ||
| <div className="w-10 h-10 rounded-xl bg-white/30 dark:bg-white/25 backdrop-blur-sm flex items-center justify-center"> | ||
| <Users className="w-5 h-5 text-white" /> | ||
| </div> | ||
| <Sparkles | ||
| className="absolute -top-1 -right-1 w-4 h-4 text-yellow-300 dark:text-yellow-200 animate-spin" | ||
| style={{ animationDuration: '3s' }} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="flex items-baseline gap-2"> | ||
| <span className="text-xl">{getEmoji(online)}</span> | ||
| <div className="flex items-baseline gap-1.5"> | ||
| <span className="text-2xl font-black text-white dark:text-yellow-100 drop-shadow-sm"> | ||
| {online} | ||
| </span> | ||
| <span className="text-base font-semibold text-white/95 dark:text-white/90 whitespace-nowrap"> | ||
| {getText(online)} | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="ml-1 flex-shrink-0"> | ||
| <span className="relative flex h-2.5 w-2.5"> | ||
| <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-300 dark:bg-purple-200 opacity-75" /> | ||
| <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-purple-400 dark:bg-purple-300" /> | ||
| </span> | ||
| </div> | ||
|
|
||
| <div className="absolute bottom-0 left-0 right-0 h-1 bg-white/25 dark:bg-white/20 rounded-b-2xl overflow-hidden"> | ||
| <div | ||
| className="h-full bg-white/60 dark:bg-white/50" | ||
| style={{ animation: 'shrink 8s linear forwards' }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="absolute -top-1 left-6 w-1.5 h-1.5 rounded-full bg-blue-400/60 dark:bg-blue-300/70 animate-ping" /> | ||
| <div | ||
| className="absolute -top-2 right-8 w-1 h-1 rounded-full bg-purple-400/60 dark:bg-purple-300/70 animate-ping" | ||
| style={{ animationDelay: '0.5s' }} | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cleanup may never run if the request ends early.
In serverless runtimes, un-awaited DB calls can be terminated when the response returns, leaving stale sessions uncollected and the table growing. Consider awaiting the cleanup (or moving it to a scheduled job) while still swallowing errors to keep the request resilient.
🛠️ Suggested fix (await cleanup safely)
🤖 Prompt for AI Agents