1<script lang="ts">
2 import '../app.css';
3 import { setupConvex } from 'convex-svelte';
4 import { env } from '$env/dynamic/public';
5 import { chatState } from '$lib/states/chat.svelte';
6 import { onMount } from 'svelte';
7 import { browser } from '$app/environment';
8
9 // Initialize Convex client for real-time subscriptions
10 if (browser && env.PUBLIC_CONVEX_URL) {
11 setupConvex(env.PUBLIC_CONVEX_URL);
12 }
13
14 let { children, data } = $props();
15 let isVisible = $state(true);
16 let statusInterval = $state<number | undefined>(undefined);
17 let lastUserUpdate = $state<string | null>(null);
18 let updateInProgress = $state(false);
19 let idleTimeout = $state<number | undefined>(undefined);
20
21 // Status update timing constants
22 let lastSentStatus = $state<string | null>(null);
23 let lastStatusSentAt = $state<number>(0);
24 let lastUserActivity = $state<number>(Date.now());
25 const STATUS_UPDATE_INTERVAL = 30000; // 30 seconds
26 const THROTTLE_TIME_MS = 10000; // 10 seconds
27 const FORCE_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes
28 const IDLE_TIMEOUT = 2 * 60 * 1000; // 2 minutes of inactivity = idle
29
30 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';
31
32 async function updateUserStatus(status: UserStatus) {
33 if (!data.user?.id || updateInProgress) return;
34
35 const now = Date.now();
36 const timeSinceLastUpdate = now - lastStatusSentAt;
37
38 if (
39 status === lastSentStatus &&
40 timeSinceLastUpdate < FORCE_UPDATE_INTERVAL &&
41 timeSinceLastUpdate < THROTTLE_TIME_MS
42 ) {
43 console.debug('Skipping status update - no change and within throttle period:', {
44 status,
45 timeSinceLastUpdate,
46 lastUpdate: new Date(lastStatusSentAt).toISOString()
47 });
48 return;
49 }
50
51 updateInProgress = true;
52 const timestamp = new Date().toISOString();
53 console.debug('Updating user status:', {
54 userId: data.user.id,
55 status,
56 timestamp,
57 reason: status !== lastSentStatus ? 'status_changed' : 'force_update',
58 timeSinceLastUpdate,
59 trigger: new Error().stack?.split('\n')[2]?.trim(),
60 hasSession: true
61 });
62
63 try {
64 if (status === 'online' && !document.cookie.includes('session=')) {
65 console.debug('Waiting for session cookie before status update...');
66 await new Promise((resolve) => setTimeout(resolve, 500));
67 }
68
69 const response = await fetch('/api/status', {
70 method: 'POST',
71 headers: { 'Content-Type': 'application/json' },
72 body: JSON.stringify({
73 status,
74 lastSeen: timestamp
75 })
76 });
77
78 if (!response.ok) {
79 console.error('Failed to update user status:', await response.json());
80 } else {
81 lastSentStatus = status;
82 lastStatusSentAt = now;
83 console.debug('Status update successful:', {
84 userId: data.user.id,
85 status,
86 cookiePresent: document.cookie.includes('session=')
87 });
88 }
89 } catch (error) {
90 console.error('Error updating user status:', error);
91 } finally {
92 updateInProgress = false;
93 }
94 }
95
96 function resetIdleTimer() {
97 if (idleTimeout) {
98 window.clearTimeout(idleTimeout);
99 }
100
101 lastUserActivity = Date.now();
102
103 if (lastSentStatus === 'idle') {
104 updateUserStatus('online');
105 }
106
107 idleTimeout = window.setTimeout(() => {
108 if (isVisible && Date.now() - lastUserActivity >= IDLE_TIMEOUT) {
109 updateUserStatus('idle');
110 }
111 }, IDLE_TIMEOUT);
112 }
113
114 function handleUserActivity() {
115 resetIdleTimer();
116 }
117
118 function startStatusTicker() {
119 if (statusInterval) return;
120
121 console.debug('Starting status ticker');
122 const tickHandler = () => {
123 if (isVisible && lastSentStatus !== 'idle') {
124 updateUserStatus('online');
125 }
126 };
127
128 statusInterval = window.setInterval(tickHandler, STATUS_UPDATE_INTERVAL);
129 }
130
131 function stopStatusTicker() {
132 if (!statusInterval) return;
133 console.debug('Stopping status ticker');
134 window.clearInterval(statusInterval);
135 statusInterval = undefined;
136 }
137
138 function handleVisibilityChange() {
139 if (!browser) return;
140 isVisible = document.visibilityState === 'visible';
141
142 if (isVisible) {
143 resetIdleTimer();
144 updateUserStatus('online');
145 startStatusTicker();
146 } else {
147 updateUserStatus('busy');
148 stopStatusTicker();
149 }
150 }
151
152 function cleanup() {
153 if (!browser) return;
154 console.debug('Running cleanup - Setting user status to offline');
155 document.removeEventListener('visibilitychange', handleVisibilityChange);
156 window.removeEventListener('mousemove', handleUserActivity);
157 window.removeEventListener('keydown', handleUserActivity);
158 window.removeEventListener('click', handleUserActivity);
159 if (idleTimeout) {
160 window.clearTimeout(idleTimeout);
161 }
162 stopStatusTicker();
163 updateUserStatus('offline');
164 }
165
166 onMount(() => {
167 if (!browser) return;
168
169 // Set initial online status
170 updateUserStatus('online');
171
172 // Start monitoring for user activity
173 window.addEventListener('mousemove', handleUserActivity);
174 window.addEventListener('keydown', handleUserActivity);
175 window.addEventListener('click', handleUserActivity);
176
177 // Initialize idle timer
178 resetIdleTimer();
179
180 // Start the status ticker
181 startStatusTicker();
182
183 // Add visibility change listener
184 document.addEventListener('visibilitychange', handleVisibilityChange);
185
186 // Add beforeunload listener for cleanup
187 window.addEventListener('beforeunload', cleanup);
188
189 // Cleanup on component destroy
190 return () => {
191 cleanup();
192 window.removeEventListener('beforeunload', cleanup);
193 };
194 });
195
196 $effect(() => {
197 if (!browser) return;
198
199 const currentUserJson = data.user
200 ? JSON.stringify({ user: data.user, sessionId: data.session?.id ?? null })
201 : null;
202 if (currentUserJson === lastUserUpdate) {
203 console.debug('Skipping duplicate user update');
204 return;
205 }
206
207 lastUserUpdate = currentUserJson;
208
209 console.debug('Layout reactive update - Session state:', {
210 timestamp: new Date().toISOString(),
211 hasUser: !!data.user,
212 userDetails: data.user
213 ? {
214 nickname: data.user.nickname,
215 status: data.user.status,
216 id: data.user.id
217 }
218 : null,
219 hasSession: !!data.session,
220 sessionDetails: data.session
221 ? {
222 id: data.session.id,
223 expiresAt: new Date(data.session.expiresAt)
224 }
225 : null,
226 isVisible
227 });
228
229 if (data.session?.id) {
230 console.debug('Updating global chatState with data.user:', data.user);
231 chatState.setCurrentUser(data.user);
232 chatState.setSessionTokenHash(data.session.id);
233 } else {
234 chatState.setCurrentUser(null);
235 }
236 });
237</script>
238
239<svelte:head>
240 <title>{data.meta.title}</title>
241 <meta name="description" content={data.meta.description} />
242 <meta name="keywords" content={data.meta.keywords} />
243 <meta name="language" content="fr" />
244
245 <!-- Open Graph / Facebook -->
246 <meta property="og:locale" content={data.meta.locale} />
247 <meta property="og:type" content={data.meta.type} />
248 <meta property="og:url" content={data.meta.url} />
249 <meta property="og:title" content={data.meta.title} />
250 <meta property="og:description" content={data.meta.description} />
251 <meta property="og:image" content={data.meta.ogImage} />
252
253 <!-- Twitter -->
254 <meta property="twitter:card" content="summary_large_image" />
255 <meta property="twitter:url" content={data.meta.url} />
256 <meta property="twitter:title" content={data.meta.title} />
257 <meta property="twitter:description" content={data.meta.description} />
258 <meta property="twitter:image" content={data.meta.ogImage} />
259
260 <!-- Additional Meta Tags -->
261 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
262 <meta name="theme-color" content="#2195f2" />
263 <link rel="canonical" href={data.meta.url} />
264 <link rel="alternate" hreflang="fr" href={data.meta.url} />
265</svelte:head>
266
267<div class="windows-xp-bg">
268 {@render children()}
269</div>
270
271<style>
272 .windows-xp-bg {
273 position: fixed;
274 top: 0;
275 left: 0;
276 width: 100vw;
277 height: 100vh;
278 margin: 0;
279 padding: 0;
280 overflow: hidden;
281 background: linear-gradient(
282 to bottom,
283 #2195f2 0%,
284 /* Lighter blue at the top */ #4aa6e2 25%,
285 /* Mid-light blue */ #7ab8e0 50%,
286 /* Mid blue */ #86c55c 75%,
287 /* Blue-green transition */ #90d047 100% /* Bright green at the bottom */
288 );
289 }
290</style>