feat: Enhance chat room loading and session management
- Implement session token validation and cookie management in server hooks - Add initial loading state and loading dots for chat room messages - Improve message loading with scroll position preservation - Update chat state to handle public and authenticated message retrieval - Modify API routes to support public and authenticated message access
- commit
- 3275a9629d36752ab01640dcbd177bfc5161d0d4
- author
- Kit · · 2025-02-15 18:07 UTC
- parent
- 840793d
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
9 files · +460 -38
A .cursor/rules/global.mdc +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | --- | |
| 2 | + | description: | |
| 3 | + | globs: | |
| 4 | + | --- | |
| 5 | + | # Debugging | |
| 6 | + | - Always add console.debug for client sided code | |
| 7 | + | - Always add either console.log or follow the logger implementation logic is there is one for server/backend code⏎̸ | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | --- | |||
| 2 | + | description: | |||
| 3 | + | globs: | |||
| 4 | + | --- | |||
| 5 | + | # Debugging | |||
| 6 | + | - Always add console.debug for client sided code | |||
| 7 | + | - Always add either console.log or follow the logger implementation logic is there is one for server/backend code⏎̸ | |||
A .cursor/rules/svelte5.mdc +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | --- | |
| 2 | + | description: | |
| 3 | + | globs: | |
| 4 | + | --- | |
| 5 | + | cursor.directory | |
| 6 | + | Generate rule | |
| 7 | + | MCP Servers | |
| 8 | + | Learn | |
| 9 | + | ||
| 10 | + | You are an expert in Svelte 5, SvelteKit, TypeScript, and modern web development. | |
| 11 | + | ||
| 12 | + | Key Principles | |
| 13 | + | - Write concise, technical code with accurate Svelte 5 and SvelteKit examples. | |
| 14 | + | - Leverage SvelteKit's server-side rendering (SSR) and static site generation (SSG) capabilities. | |
| 15 | + | - Prioritize performance optimization and minimal JavaScript for optimal user experience. | |
| 16 | + | - Use descriptive variable names and follow Svelte and SvelteKit conventions. | |
| 17 | + | - Organize files using SvelteKit's file-based routing system. | |
| 18 | + | ||
| 19 | + | Code Style and Structure | |
| 20 | + | - Write concise, technical TypeScript or JavaScript code with accurate examples. | |
| 21 | + | - Use functional and declarative programming patterns; avoid unnecessary classes except for state machines. | |
| 22 | + | - Prefer iteration and modularization over code duplication. | |
| 23 | + | - Structure files: component logic, markup, styles, helpers, types. | |
| 24 | + | - Follow Svelte's official documentation for setup and configuration: https://svelte.dev/docs | |
| 25 | + | ||
| 26 | + | Naming Conventions | |
| 27 | + | - Use lowercase with hyphens for component files (e.g., `components/auth-form.svelte`). | |
| 28 | + | - Use PascalCase for component names in imports and usage. | |
| 29 | + | - Use camelCase for variables, functions, and props. | |
| 30 | + | ||
| 31 | + | TypeScript Usage | |
| 32 | + | - Use TypeScript for all code; prefer interfaces over types. | |
| 33 | + | - Avoid enums; use const objects instead. | |
| 34 | + | - Use functional components with TypeScript interfaces for props. | |
| 35 | + | - Enable strict mode in TypeScript for better type safety. | |
| 36 | + | ||
| 37 | + | Svelte Runes | |
| 38 | + | - `$state`: Declare reactive state | |
| 39 | + | ```typescript | |
| 40 | + | let count = $state(0); | |
| 41 | + | ``` | |
| 42 | + | - `$derived`: Compute derived values | |
| 43 | + | ```typescript | |
| 44 | + | let doubled = $derived(count * 2); | |
| 45 | + | ``` | |
| 46 | + | - `$effect`: Manage side effects and lifecycle | |
| 47 | + | ```typescript | |
| 48 | + | $effect(() => { | |
| 49 | + | console.log(`Count is now ${count}`); | |
| 50 | + | }); | |
| 51 | + | ``` | |
| 52 | + | - `$props`: Declare component props | |
| 53 | + | ```typescript | |
| 54 | + | let { optionalProp = 42, requiredProp } = $props(); | |
| 55 | + | ``` | |
| 56 | + | - `$bindable`: Create two-way bindable props | |
| 57 | + | ```typescript | |
| 58 | + | let { bindableProp = $bindable() } = $props(); | |
| 59 | + | ``` | |
| 60 | + | - `$inspect`: Debug reactive state (development only) | |
| 61 | + | ```typescript | |
| 62 | + | $inspect(count); | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | UI and Styling | |
| 66 | + | - Use Tailwind CSS for utility-first styling approach. | |
| 67 | + | - Leverage Shadcn components for pre-built, customizable UI elements. | |
| 68 | + | - Import Shadcn components from `$lib/components/ui`. | |
| 69 | + | - Organize Tailwind classes using the `cn()` utility from `$lib/utils`. | |
| 70 | + | - Use Svelte's built-in transition and animation features. | |
| 71 | + | ||
| 72 | + | Shadcn Color Conventions | |
| 73 | + | - Use `background` and `foreground` convention for colors. | |
| 74 | + | - Define CSS variables without color space function: | |
| 75 | + | ```css | |
| 76 | + | --primary: 222.2 47.4% 11.2%; | |
| 77 | + | --primary-foreground: 210 40% 98%; | |
| 78 | + | ``` | |
| 79 | + | - Usage example: | |
| 80 | + | ```svelte | |
| 81 | + | <div class="bg-primary text-primary-foreground">Hello</div> | |
| 82 | + | ``` | |
| 83 | + | - Key color variables: | |
| 84 | + | - `--background`, `--foreground`: Default body colors | |
| 85 | + | - `--muted`, `--muted-foreground`: Muted backgrounds | |
| 86 | + | - `--card`, `--card-foreground`: Card backgrounds | |
| 87 | + | - `--popover`, `--popover-foreground`: Popover backgrounds | |
| 88 | + | - `--border`: Default border color | |
| 89 | + | - `--input`: Input border color | |
| 90 | + | - `--primary`, `--primary-foreground`: Primary button colors | |
| 91 | + | - `--secondary`, `--secondary-foreground`: Secondary button colors | |
| 92 | + | - `--accent`, `--accent-foreground`: Accent colors | |
| 93 | + | - `--destructive`, `--destructive-foreground`: Destructive action colors | |
| 94 | + | - `--ring`: Focus ring color | |
| 95 | + | - `--radius`: Border radius for components | |
| 96 | + | ||
| 97 | + | SvelteKit Project Structure | |
| 98 | + | - Use the recommended SvelteKit project structure: | |
| 99 | + | ``` | |
| 100 | + | - src/ | |
| 101 | + | - lib/ | |
| 102 | + | - routes/ | |
| 103 | + | - app.html | |
| 104 | + | - static/ | |
| 105 | + | - svelte.config.js | |
| 106 | + | - vite.config.js | |
| 107 | + | ``` | |
| 108 | + | ||
| 109 | + | Component Development | |
| 110 | + | - Create .svelte files for Svelte components. | |
| 111 | + | - Use .svelte.ts files for component logic and state machines. | |
| 112 | + | - Implement proper component composition and reusability. | |
| 113 | + | - Use Svelte's props for data passing. | |
| 114 | + | - Leverage Svelte's reactive declarations for local state management. | |
| 115 | + | ||
| 116 | + | State Management | |
| 117 | + | - Use classes for complex state management (state machines): | |
| 118 | + | ```typescript | |
| 119 | + | // counter.svelte.ts | |
| 120 | + | class Counter { | |
| 121 | + | count = $state(0); | |
| 122 | + | incrementor = $state(1); | |
| 123 | + | ||
| 124 | + | increment() { | |
| 125 | + | this.count += this.incrementor; | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | resetCount() { | |
| 129 | + | this.count = 0; | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | resetIncrementor() { | |
| 133 | + | this.incrementor = 1; | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | export const counter = new Counter(); | |
| 138 | + | ``` | |
| 139 | + | - Use in components: | |
| 140 | + | ```svelte | |
| 141 | + | <script lang="ts"> | |
| 142 | + | import { counter } from './counter.svelte.ts'; | |
| 143 | + | </script> | |
| 144 | + | ||
| 145 | + | <button on:click={() => counter.increment()}> | |
| 146 | + | Count: {counter.count} | |
| 147 | + | </button> | |
| 148 | + | ``` | |
| 149 | + | ||
| 150 | + | Routing and Pages | |
| 151 | + | - Utilize SvelteKit's file-based routing system in the src/routes/ directory. | |
| 152 | + | - Implement dynamic routes using [slug] syntax. | |
| 153 | + | - Use load functions for server-side data fetching and pre-rendering. | |
| 154 | + | - Implement proper error handling with +error.svelte pages. | |
| 155 | + | ||
| 156 | + | Server-Side Rendering (SSR) and Static Site Generation (SSG) | |
| 157 | + | - Leverage SvelteKit's SSR capabilities for dynamic content. | |
| 158 | + | - Implement SSG for static pages using prerender option. | |
| 159 | + | - Use the adapter-auto for automatic deployment configuration. | |
| 160 | + | ||
| 161 | + | Performance Optimization | |
| 162 | + | - Leverage Svelte's compile-time optimizations. | |
| 163 | + | - Use `{#key}` blocks to force re-rendering of components when needed. | |
| 164 | + | - Implement code splitting using dynamic imports for large applications. | |
| 165 | + | - Profile and monitor performance using browser developer tools. | |
| 166 | + | - Use `$effect.tracking()` to optimize effect dependencies. | |
| 167 | + | - Minimize use of client-side JavaScript; leverage SvelteKit's SSR and SSG. | |
| 168 | + | - Implement proper lazy loading for images and other assets. | |
| 169 | + | ||
| 170 | + | Data Fetching and API Routes | |
| 171 | + | - Use load functions for server-side data fetching. | |
| 172 | + | - Implement proper error handling for data fetching operations. | |
| 173 | + | - Create API routes in the src/routes/api/ directory. | |
| 174 | + | - Implement proper request handling and response formatting in API routes. | |
| 175 | + | - Use SvelteKit's hooks for global API middleware. | |
| 176 | + | ||
| 177 | + | SEO and Meta Tags | |
| 178 | + | - Use Svelte:head component for adding meta information. | |
| 179 | + | - Implement canonical URLs for proper SEO. | |
| 180 | + | - Create reusable SEO components for consistent meta tag management. | |
| 181 | + | ||
| 182 | + | Forms and Actions | |
| 183 | + | - Utilize SvelteKit's form actions for server-side form handling. | |
| 184 | + | - Implement proper client-side form validation using Svelte's reactive declarations. | |
| 185 | + | - Use progressive enhancement for JavaScript-optional form submissions. | |
| 186 | + | ||
| 187 | + | Internationalization (i18n) with Paraglide.js | |
| 188 | + | - Use Paraglide.js for internationalization: https://inlang.com/m/gerre34r/library-inlang-paraglideJs | |
| 189 | + | - Install Paraglide.js: `npm install @inlang/paraglide-js` | |
| 190 | + | - Set up language files in the `languages` directory. | |
| 191 | + | - Use the `t` function to translate strings: | |
| 192 | + | ```svelte | |
| 193 | + | <script> | |
| 194 | + | import { t } from '@inlang/paraglide-js'; | |
| 195 | + | </script> | |
| 196 | + | ||
| 197 | + | <h1>{t('welcome_message')}</h1> | |
| 198 | + | ``` | |
| 199 | + | - Support multiple languages and RTL layouts. | |
| 200 | + | - Ensure text scaling and font adjustments for accessibility. | |
| 201 | + | ||
| 202 | + | Accessibility | |
| 203 | + | - Ensure proper semantic HTML structure in Svelte components. | |
| 204 | + | - Implement ARIA attributes where necessary. | |
| 205 | + | - Ensure keyboard navigation support for interactive elements. | |
| 206 | + | - Use Svelte's bind:this for managing focus programmatically. | |
| 207 | + | ||
| 208 | + | Key Conventions | |
| 209 | + | 1. Embrace Svelte's simplicity and avoid over-engineering solutions. | |
| 210 | + | 2. Use SvelteKit for full-stack applications with SSR and API routes. | |
| 211 | + | 3. Prioritize Web Vitals (LCP, FID, CLS) for performance optimization. | |
| 212 | + | 4. Use environment variables for configuration management. | |
| 213 | + | 5. Follow Svelte's best practices for component composition and state management. | |
| 214 | + | 6. Ensure cross-browser compatibility by testing on multiple platforms. | |
| 215 | + | 7. Keep your Svelte and SvelteKit versions up to date. | |
| 216 | + | ||
| 217 | + | Documentation | |
| 218 | + | - Svelte 5 Runes: https://svelte-5-preview.vercel.app/docs/runes | |
| 219 | + | - Svelte Documentation: https://svelte.dev/docs | |
| 220 | + | - SvelteKit Documentation: https://kit.svelte.dev/docs | |
| 221 | + | - Paraglide.js Documentation: https://inlang.com/m/gerre34r/library-inlang-paraglideJs/usage | |
| 222 | + | ||
| 223 | + | Refer to Svelte, SvelteKit, and Paraglide.js documentation for detailed information on components, internationalization, and best practices. | |
| 224 | + | MMBytes | |
| 225 | + | MMBytes | |
| 226 | + | Made by Midday | |
| 227 | + | ||
| 228 | + | An all-in-one tool for freelancers to monitor financial health, time-track, and send invoices. ↗ | |
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | --- | |||
| 2 | + | description: | |||
| 3 | + | globs: | |||
| 4 | + | --- | |||
| 5 | + | cursor.directory | |||
| 6 | + | Generate rule | |||
| 7 | + | MCP Servers | |||
| 8 | + | Learn | |||
| 9 | + | ||||
| 10 | + | You are an expert in Svelte 5, SvelteKit, TypeScript, and modern web development. | |||
| 11 | + | ||||
| 12 | + | Key Principles | |||
| 13 | + | - Write concise, technical code with accurate Svelte 5 and SvelteKit examples. | |||
| 14 | + | - Leverage SvelteKit's server-side rendering (SSR) and static site generation (SSG) capabilities. | |||
| 15 | + | - Prioritize performance optimization and minimal JavaScript for optimal user experience. | |||
| 16 | + | - Use descriptive variable names and follow Svelte and SvelteKit conventions. | |||
| 17 | + | - Organize files using SvelteKit's file-based routing system. | |||
| 18 | + | ||||
| 19 | + | Code Style and Structure | |||
| 20 | + | - Write concise, technical TypeScript or JavaScript code with accurate examples. | |||
| 21 | + | - Use functional and declarative programming patterns; avoid unnecessary classes except for state machines. | |||
| 22 | + | - Prefer iteration and modularization over code duplication. | |||
| 23 | + | - Structure files: component logic, markup, styles, helpers, types. | |||
| 24 | + | - Follow Svelte's official documentation for setup and configuration: https://svelte.dev/docs | |||
| 25 | + | ||||
| 26 | + | Naming Conventions | |||
| 27 | + | - Use lowercase with hyphens for component files (e.g., `components/auth-form.svelte`). | |||
| 28 | + | - Use PascalCase for component names in imports and usage. | |||
| 29 | + | - Use camelCase for variables, functions, and props. | |||
| 30 | + | ||||
| 31 | + | TypeScript Usage | |||
| 32 | + | - Use TypeScript for all code; prefer interfaces over types. | |||
| 33 | + | - Avoid enums; use const objects instead. | |||
| 34 | + | - Use functional components with TypeScript interfaces for props. | |||
| 35 | + | - Enable strict mode in TypeScript for better type safety. | |||
| 36 | + | ||||
| 37 | + | Svelte Runes | |||
| 38 | + | - `$state`: Declare reactive state | |||
| 39 | + | ```typescript | |||
| 40 | + | let count = $state(0); | |||
| 41 | + | ``` | |||
| 42 | + | - `$derived`: Compute derived values | |||
| 43 | + | ```typescript | |||
| 44 | + | let doubled = $derived(count * 2); | |||
| 45 | + | ``` | |||
| 46 | + | - `$effect`: Manage side effects and lifecycle | |||
| 47 | + | ```typescript | |||
| 48 | + | $effect(() => { | |||
| 49 | + | console.log(`Count is now ${count}`); | |||
| 50 | + | }); | |||
| 51 | + | ``` | |||
| 52 | + | - `$props`: Declare component props | |||
| 53 | + | ```typescript | |||
| 54 | + | let { optionalProp = 42, requiredProp } = $props(); | |||
| 55 | + | ``` | |||
| 56 | + | - `$bindable`: Create two-way bindable props | |||
| 57 | + | ```typescript | |||
| 58 | + | let { bindableProp = $bindable() } = $props(); | |||
| 59 | + | ``` | |||
| 60 | + | - `$inspect`: Debug reactive state (development only) | |||
| 61 | + | ```typescript | |||
| 62 | + | $inspect(count); | |||
| 63 | + | ``` | |||
| 64 | + | ||||
| 65 | + | UI and Styling | |||
| 66 | + | - Use Tailwind CSS for utility-first styling approach. | |||
| 67 | + | - Leverage Shadcn components for pre-built, customizable UI elements. | |||
| 68 | + | - Import Shadcn components from `$lib/components/ui`. | |||
| 69 | + | - Organize Tailwind classes using the `cn()` utility from `$lib/utils`. | |||
| 70 | + | - Use Svelte's built-in transition and animation features. | |||
| 71 | + | ||||
| 72 | + | Shadcn Color Conventions | |||
| 73 | + | - Use `background` and `foreground` convention for colors. | |||
| 74 | + | - Define CSS variables without color space function: | |||
| 75 | + | ```css | |||
| 76 | + | --primary: 222.2 47.4% 11.2%; | |||
| 77 | + | --primary-foreground: 210 40% 98%; | |||
| 78 | + | ``` | |||
| 79 | + | - Usage example: | |||
| 80 | + | ```svelte | |||
| 81 | + | <div class="bg-primary text-primary-foreground">Hello</div> | |||
| 82 | + | ``` | |||
| 83 | + | - Key color variables: | |||
| 84 | + | - `--background`, `--foreground`: Default body colors | |||
| 85 | + | - `--muted`, `--muted-foreground`: Muted backgrounds | |||
| 86 | + | - `--card`, `--card-foreground`: Card backgrounds | |||
| 87 | + | - `--popover`, `--popover-foreground`: Popover backgrounds | |||
| 88 | + | - `--border`: Default border color | |||
| 89 | + | - `--input`: Input border color | |||
| 90 | + | - `--primary`, `--primary-foreground`: Primary button colors | |||
| 91 | + | - `--secondary`, `--secondary-foreground`: Secondary button colors | |||
| 92 | + | - `--accent`, `--accent-foreground`: Accent colors | |||
| 93 | + | - `--destructive`, `--destructive-foreground`: Destructive action colors | |||
| 94 | + | - `--ring`: Focus ring color | |||
| 95 | + | - `--radius`: Border radius for components | |||
| 96 | + | ||||
| 97 | + | SvelteKit Project Structure | |||
| 98 | + | - Use the recommended SvelteKit project structure: | |||
| 99 | + | ``` | |||
| 100 | + | - src/ | |||
| 101 | + | - lib/ | |||
| 102 | + | - routes/ | |||
| 103 | + | - app.html | |||
| 104 | + | - static/ | |||
| 105 | + | - svelte.config.js | |||
| 106 | + | - vite.config.js | |||
| 107 | + | ``` | |||
| 108 | + | ||||
| 109 | + | Component Development | |||
| 110 | + | - Create .svelte files for Svelte components. | |||
| 111 | + | - Use .svelte.ts files for component logic and state machines. | |||
| 112 | + | - Implement proper component composition and reusability. | |||
| 113 | + | - Use Svelte's props for data passing. | |||
| 114 | + | - Leverage Svelte's reactive declarations for local state management. | |||
| 115 | + | ||||
| 116 | + | State Management | |||
| 117 | + | - Use classes for complex state management (state machines): | |||
| 118 | + | ```typescript | |||
| 119 | + | // counter.svelte.ts | |||
| 120 | + | class Counter { | |||
| 121 | + | count = $state(0); | |||
| 122 | + | incrementor = $state(1); | |||
| 123 | + | ||||
| 124 | + | increment() { | |||
| 125 | + | this.count += this.incrementor; | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | resetCount() { | |||
| 129 | + | this.count = 0; | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | resetIncrementor() { | |||
| 133 | + | this.incrementor = 1; | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | export const counter = new Counter(); | |||
| 138 | + | ``` | |||
| 139 | + | - Use in components: | |||
| 140 | + | ```svelte | |||
| 141 | + | <script lang="ts"> | |||
| 142 | + | import { counter } from './counter.svelte.ts'; | |||
| 143 | + | </script> | |||
| 144 | + | ||||
| 145 | + | <button on:click={() => counter.increment()}> | |||
| 146 | + | Count: {counter.count} | |||
| 147 | + | </button> | |||
| 148 | + | ``` | |||
| 149 | + | ||||
| 150 | + | Routing and Pages | |||
| 151 | + | - Utilize SvelteKit's file-based routing system in the src/routes/ directory. | |||
| 152 | + | - Implement dynamic routes using [slug] syntax. | |||
| 153 | + | - Use load functions for server-side data fetching and pre-rendering. | |||
| 154 | + | - Implement proper error handling with +error.svelte pages. | |||
| 155 | + | ||||
| 156 | + | Server-Side Rendering (SSR) and Static Site Generation (SSG) | |||
| 157 | + | - Leverage SvelteKit's SSR capabilities for dynamic content. | |||
| 158 | + | - Implement SSG for static pages using prerender option. | |||
| 159 | + | - Use the adapter-auto for automatic deployment configuration. | |||
| 160 | + | ||||
| 161 | + | Performance Optimization | |||
| 162 | + | - Leverage Svelte's compile-time optimizations. | |||
| 163 | + | - Use `{#key}` blocks to force re-rendering of components when needed. | |||
| 164 | + | - Implement code splitting using dynamic imports for large applications. | |||
| 165 | + | - Profile and monitor performance using browser developer tools. | |||
| 166 | + | - Use `$effect.tracking()` to optimize effect dependencies. | |||
| 167 | + | - Minimize use of client-side JavaScript; leverage SvelteKit's SSR and SSG. | |||
| 168 | + | - Implement proper lazy loading for images and other assets. | |||
| 169 | + | ||||
| 170 | + | Data Fetching and API Routes | |||
| 171 | + | - Use load functions for server-side data fetching. | |||
| 172 | + | - Implement proper error handling for data fetching operations. | |||
| 173 | + | - Create API routes in the src/routes/api/ directory. | |||
| 174 | + | - Implement proper request handling and response formatting in API routes. | |||
| 175 | + | - Use SvelteKit's hooks for global API middleware. | |||
| 176 | + | ||||
| 177 | + | SEO and Meta Tags | |||
| 178 | + | - Use Svelte:head component for adding meta information. | |||
| 179 | + | - Implement canonical URLs for proper SEO. | |||
| 180 | + | - Create reusable SEO components for consistent meta tag management. | |||
| 181 | + | ||||
| 182 | + | Forms and Actions | |||
| 183 | + | - Utilize SvelteKit's form actions for server-side form handling. | |||
| 184 | + | - Implement proper client-side form validation using Svelte's reactive declarations. | |||
| 185 | + | - Use progressive enhancement for JavaScript-optional form submissions. | |||
| 186 | + | ||||
| 187 | + | Internationalization (i18n) with Paraglide.js | |||
| 188 | + | - Use Paraglide.js for internationalization: https://inlang.com/m/gerre34r/library-inlang-paraglideJs | |||
| 189 | + | - Install Paraglide.js: `npm install @inlang/paraglide-js` | |||
| 190 | + | - Set up language files in the `languages` directory. | |||
| 191 | + | - Use the `t` function to translate strings: | |||
| 192 | + | ```svelte | |||
| 193 | + | <script> | |||
| 194 | + | import { t } from '@inlang/paraglide-js'; | |||
| 195 | + | </script> | |||
| 196 | + | ||||
| 197 | + | <h1>{t('welcome_message')}</h1> | |||
| 198 | + | ``` | |||
| 199 | + | - Support multiple languages and RTL layouts. | |||
| 200 | + | - Ensure text scaling and font adjustments for accessibility. | |||
| 201 | + | ||||
| 202 | + | Accessibility | |||
| 203 | + | - Ensure proper semantic HTML structure in Svelte components. | |||
| 204 | + | - Implement ARIA attributes where necessary. | |||
| 205 | + | - Ensure keyboard navigation support for interactive elements. | |||
| 206 | + | - Use Svelte's bind:this for managing focus programmatically. | |||
| 207 | + | ||||
| 208 | + | Key Conventions | |||
| 209 | + | 1. Embrace Svelte's simplicity and avoid over-engineering solutions. | |||
| 210 | + | 2. Use SvelteKit for full-stack applications with SSR and API routes. | |||
| 211 | + | 3. Prioritize Web Vitals (LCP, FID, CLS) for performance optimization. | |||
| 212 | + | 4. Use environment variables for configuration management. | |||
| 213 | + | 5. Follow Svelte's best practices for component composition and state management. | |||
| 214 | + | 6. Ensure cross-browser compatibility by testing on multiple platforms. | |||
| 215 | + | 7. Keep your Svelte and SvelteKit versions up to date. | |||
| 216 | + | ||||
| 217 | + | Documentation | |||
| 218 | + | - Svelte 5 Runes: https://svelte-5-preview.vercel.app/docs/runes | |||
| 219 | + | - Svelte Documentation: https://svelte.dev/docs | |||
| 220 | + | - SvelteKit Documentation: https://kit.svelte.dev/docs | |||
| 221 | + | - Paraglide.js Documentation: https://inlang.com/m/gerre34r/library-inlang-paraglideJs/usage | |||
| 222 | + | ||||
| 223 | + | Refer to Svelte, SvelteKit, and Paraglide.js documentation for detailed information on components, internationalization, and best practices. | |||
| 224 | + | MMBytes | |||
| 225 | + | MMBytes | |||
| 226 | + | Made by Midday | |||
| 227 | + | ||||
| 228 | + | An all-in-one tool for freelancers to monitor financial health, time-track, and send invoices. ↗ | |||
M src/hooks.server.ts +52 -3
| @@ -4,6 +4,8 @@ import { handleRateLimit } from "$lib/api/rate-limiter"; | |||
| 4 | 4 | import { users } from "$lib/db/schema"; | |
| 5 | 5 | import { createLogger } from "$lib/utils/logger.server"; | |
| 6 | 6 | import { ensureDefaultChatRoom } from "$lib/utils/chat.server"; | |
| 7 | + | import { validateSessionToken } from "$lib/api/session.server"; | |
| 8 | + | import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie"; | |
| 7 | 9 | ||
| 8 | 10 | const log = createLogger('hooks-server'); | |
| 9 | 11 | ||
| @@ -35,11 +37,58 @@ Promise.resolve() | |||
| 35 | 37 | export const handle: Handle = async ({ event, resolve }) => { | |
| 36 | 38 | log.debug("Handling request", { path: event.url.pathname }); | |
| 37 | 39 | ||
| 38 | - | // Identify public chat and room requests | |
| 40 | + | // Get session token from cookies | |
| 41 | + | const token = event.cookies.get("session") ?? null; | |
| 42 | + | ||
| 43 | + | // Validate session token if token exists | |
| 44 | + | if (token) { | |
| 45 | + | const { session, user } = await validateSessionToken(token); | |
| 46 | + | if (session) { | |
| 47 | + | log.debug("Valid session found", { user: user?.nickname }); | |
| 48 | + | setSessionTokenCookie(event, token, session.expiresAt); | |
| 49 | + | event.locals.session = session; | |
| 50 | + | event.locals.user = user; | |
| 51 | + | } else { | |
| 52 | + | log.debug("Invalid session token"); | |
| 53 | + | deleteSessionTokenCookie(event); | |
| 54 | + | event.locals.session = null; | |
| 55 | + | event.locals.user = null; | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Identify public chat and room requests if there is no valid session | |
| 39 | 60 | const isPublicChatRequest = event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET'; | |
| 40 | - | const isPublicRoomRequest = event.url.pathname.startsWith('/api/rooms') && event.request.method === 'GET'; | |
| 61 | + | const isPublicRoomRequest = event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET'; | |
| 62 | + | ||
| 63 | + | if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) { | |
| 64 | + | log.debug("Public request accessed", { path: event.url.pathname }); | |
| 65 | + | const response = await resolve(event); | |
| 66 | + | return response; | |
| 67 | + | } | |
| 41 | 68 | ||
| 42 | - | // Skip rate limiting for public endpoints | |
| 69 | + | // Public routes that don't require authentication | |
| 70 | + | const publicRoutes = [ | |
| 71 | + | "/", | |
| 72 | + | "/api/session/login", | |
| 73 | + | "/api/session/validate", | |
| 74 | + | "/api/register", | |
| 75 | + | "/login", | |
| 76 | + | "/register" | |
| 77 | + | ]; | |
| 78 | + | ||
| 79 | + | // Handle public routes | |
| 80 | + | if (publicRoutes.includes(event.url.pathname)) { | |
| 81 | + | log.debug("Public route accessed", { path: event.url.pathname }); | |
| 82 | + | const response = await resolve(event); | |
| 83 | + | return response; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Skip rate limiting for SSE endpoint when authenticated | |
| 87 | + | if (event.url.pathname.startsWith('/api/sse')) { | |
| 88 | + | return await resolve(event); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Apply rate limiting for non-public endpoints | |
| 43 | 92 | if (!isPublicChatRequest && !isPublicRoomRequest) { | |
| 44 | 93 | const rateLimitResult = await handleRateLimit(event); | |
| 45 | 94 | if (rateLimitResult?.status === 429) { | |
| @@ -4,6 +4,8 @@ import { handleRateLimit } from "$lib/api/rate-limiter"; | |||||
| 4 | import { users } from "$lib/db/schema"; | 4 | import { users } from "$lib/db/schema"; | ||
| 5 | import { createLogger } from "$lib/utils/logger.server"; | 5 | import { createLogger } from "$lib/utils/logger.server"; | ||
| 6 | import { ensureDefaultChatRoom } from "$lib/utils/chat.server"; | 6 | import { ensureDefaultChatRoom } from "$lib/utils/chat.server"; | ||
| 7 | + | import { validateSessionToken } from "$lib/api/session.server"; | |||
| 8 | + | import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie"; | |||
| 7 | 9 | ||||
| 8 | const log = createLogger('hooks-server'); | 10 | const log = createLogger('hooks-server'); | ||
| 9 | 11 | ||||
| @@ -35,11 +37,58 @@ Promise.resolve() | |||||
| 35 | export const handle: Handle = async ({ event, resolve }) => { | 37 | export const handle: Handle = async ({ event, resolve }) => { | ||
| 36 | log.debug("Handling request", { path: event.url.pathname }); | 38 | log.debug("Handling request", { path: event.url.pathname }); | ||
| 37 | 39 | ||||
| 38 | - | // Identify public chat and room requests | 40 | + | // Get session token from cookies |
| 41 | + | const token = event.cookies.get("session") ?? null; | |||
| 42 | + | ||||
| 43 | + | // Validate session token if token exists | |||
| 44 | + | if (token) { | |||
| 45 | + | const { session, user } = await validateSessionToken(token); | |||
| 46 | + | if (session) { | |||
| 47 | + | log.debug("Valid session found", { user: user?.nickname }); | |||
| 48 | + | setSessionTokenCookie(event, token, session.expiresAt); | |||
| 49 | + | event.locals.session = session; | |||
| 50 | + | event.locals.user = user; | |||
| 51 | + | } else { | |||
| 52 | + | log.debug("Invalid session token"); | |||
| 53 | + | deleteSessionTokenCookie(event); | |||
| 54 | + | event.locals.session = null; | |||
| 55 | + | event.locals.user = null; | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Identify public chat and room requests if there is no valid session | |||
| 39 | const isPublicChatRequest = event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET'; | 60 | const isPublicChatRequest = event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET'; | ||
| 40 | - | const isPublicRoomRequest = event.url.pathname.startsWith('/api/rooms') && event.request.method === 'GET'; | 61 | + | const isPublicRoomRequest = event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET'; |
| 62 | + | ||||
| 63 | + | if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) { | |||
| 64 | + | log.debug("Public request accessed", { path: event.url.pathname }); | |||
| 65 | + | const response = await resolve(event); | |||
| 66 | + | return response; | |||
| 67 | + | } | |||
| 41 | 68 | ||||
| 42 | - | // Skip rate limiting for public endpoints | 69 | + | // Public routes that don't require authentication |
| 70 | + | const publicRoutes = [ | |||
| 71 | + | "/", | |||
| 72 | + | "/api/session/login", | |||
| 73 | + | "/api/session/validate", | |||
| 74 | + | "/api/register", | |||
| 75 | + | "/login", | |||
| 76 | + | "/register" | |||
| 77 | + | ]; | |||
| 78 | + | ||||
| 79 | + | // Handle public routes | |||
| 80 | + | if (publicRoutes.includes(event.url.pathname)) { | |||
| 81 | + | log.debug("Public route accessed", { path: event.url.pathname }); | |||
| 82 | + | const response = await resolve(event); | |||
| 83 | + | return response; | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Skip rate limiting for SSE endpoint when authenticated | |||
| 87 | + | if (event.url.pathname.startsWith('/api/sse')) { | |||
| 88 | + | return await resolve(event); | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Apply rate limiting for non-public endpoints | |||
| 43 | if (!isPublicChatRequest && !isPublicRoomRequest) { | 92 | if (!isPublicChatRequest && !isPublicRoomRequest) { | ||
| 44 | const rateLimitResult = await handleRateLimit(event); | 93 | const rateLimitResult = await handleRateLimit(event); | ||
| 45 | if (rateLimitResult?.status === 429) { | 94 | if (rateLimitResult?.status === 429) { | ||
M src/lib/components/chat-room.svelte +58 -15
| @@ -8,6 +8,7 @@ import { resizable } from '$lib/actions/resizable'; | |||
| 8 | 8 | import { maximizable } from '$lib/actions/maximizable'; | |
| 9 | 9 | import LoadingButton from './ui/button-loading.svelte'; | |
| 10 | 10 | import Tooltip from './ui/tooltip.svelte'; | |
| 11 | + | import LoadingDots from './ui/loading-dots.svelte'; | |
| 11 | 12 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | |
| 12 | 13 | ||
| 13 | 14 | // Initialize with default values for SSR | |
| @@ -19,6 +20,7 @@ let isMobile = $state(false); | |||
| 19 | 20 | let currentMessage = $state(''); | |
| 20 | 21 | let showUserList = $state(false); | |
| 21 | 22 | let isMaximized = $state(false); | |
| 23 | + | let isInitialLoading = $state(true); | |
| 22 | 24 | ||
| 23 | 25 | // Rate limiting state | |
| 24 | 26 | let cooldownEndTime = $state<number | null>(null); | |
| @@ -299,7 +301,16 @@ async function handleScroll(event: Event) { | |||
| 299 | 301 | // Check if we've scrolled near the top (within 100px) and not already loading | |
| 300 | 302 | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) { | |
| 301 | 303 | isLoadingMore = true; | |
| 302 | - | console.debug('Loading more messages...', { oldestMessageTimestamp }); | |
| 304 | + | ||
| 305 | + | // Store the scroll height and position before loading | |
| 306 | + | const scrollHeight = chatArea.scrollHeight; | |
| 307 | + | const currentScrollTop = chatArea.scrollTop; | |
| 308 | + | ||
| 309 | + | console.debug('Loading more messages...', { | |
| 310 | + | oldestMessageTimestamp, | |
| 311 | + | scrollHeight, | |
| 312 | + | currentScrollTop | |
| 313 | + | }); | |
| 303 | 314 | ||
| 304 | 315 | try { | |
| 305 | 316 | const response = await fetch(`/api/chat/messages?${new URLSearchParams({ | |
| @@ -314,31 +325,50 @@ async function handleScroll(event: Event) { | |||
| 314 | 325 | if (data.messages.length === 0) { | |
| 315 | 326 | hasMoreMessages = false; | |
| 316 | 327 | } else { | |
| 317 | - | // Preserve scroll position | |
| 318 | - | const oldHeight = chatArea.scrollHeight; | |
| 328 | + | // Update messages through chat state | |
| 329 | + | chatState.prependMessages(data.messages, data.hasMore); | |
| 319 | 330 | ||
| 320 | - | // Update messages | |
| 321 | - | chatState.prependMessages(data.messages); | |
| 322 | - | ||
| 323 | - | // After the DOM updates, adjust scroll position | |
| 324 | - | setTimeout(() => { | |
| 325 | - | const newHeight = chatArea.scrollHeight; | |
| 326 | - | chatArea.scrollTop = newHeight - oldHeight; | |
| 327 | - | }, 0); | |
| 331 | + | // Use requestAnimationFrame to ensure DOM has updated | |
| 332 | + | requestAnimationFrame(() => { | |
| 333 | + | // Calculate how much the content height has increased | |
| 334 | + | const newScrollHeight = chatArea.scrollHeight; | |
| 335 | + | const heightDifference = newScrollHeight - scrollHeight; | |
| 336 | + | ||
| 337 | + | // Adjust scroll position to maintain relative position | |
| 338 | + | chatArea.scrollTop = currentScrollTop + heightDifference; | |
| 339 | + | ||
| 340 | + | console.debug('Scroll position adjusted:', { | |
| 341 | + | heightDifference, | |
| 342 | + | newScrollTop: chatArea.scrollTop, | |
| 343 | + | newScrollHeight | |
| 344 | + | }); | |
| 345 | + | }); | |
| 328 | 346 | ||
| 329 | 347 | // Update oldest timestamp | |
| 330 | 348 | const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp)); | |
| 331 | 349 | oldestMessageTimestamp = newOldest; | |
| 350 | + | ||
| 351 | + | // Update hasMoreMessages from response | |
| 352 | + | hasMoreMessages = data.hasMore; | |
| 332 | 353 | } | |
| 333 | 354 | } | |
| 334 | 355 | } | |
| 335 | 356 | } catch (error) { | |
| 336 | 357 | console.error('Error loading more messages:', error); | |
| 358 | + | hasMoreMessages = false; | |
| 337 | 359 | } finally { | |
| 338 | 360 | isLoadingMore = false; | |
| 339 | 361 | } | |
| 340 | 362 | } | |
| 341 | 363 | } | |
| 364 | + | ||
| 365 | + | // Add effect to track initial loading state | |
| 366 | + | $effect(() => { | |
| 367 | + | const stateMessages = chatState.getMessages(); | |
| 368 | + | if (stateMessages.length > 0 && isInitialLoading) { | |
| 369 | + | isInitialLoading = false; | |
| 370 | + | } | |
| 371 | + | }); | |
| 342 | 372 | </script> | |
| 343 | 373 | ||
| 344 | 374 | {#if showChatRoom} | |
| @@ -415,10 +445,14 @@ async function handleScroll(event: Event) { | |||
| 415 | 445 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | |
| 416 | 446 | onscroll={(e) => handleScroll(e)} | |
| 417 | 447 | > | |
| 418 | - | {#if isLoadingMore} | |
| 419 | - | <div class="loading-messages"> | |
| 420 | - | Loading more messages... | |
| 421 | - | </div> | |
| 448 | + | {#if isInitialLoading} | |
| 449 | + | <div class="initial-loading"> | |
| 450 | + | <LoadingDots text="Chargement des messages" /> | |
| 451 | + | </div> | |
| 452 | + | {:else if isLoadingMore} | |
| 453 | + | <div class="loading-messages"> | |
| 454 | + | <LoadingDots text="Chargement" /> | |
| 455 | + | </div> | |
| 422 | 456 | {/if} | |
| 423 | 457 | {#each visibleMessages as message (message.id)} | |
| 424 | 458 | <div class="message {message.type} text"> | |
| @@ -760,6 +794,15 @@ async function handleScroll(event: Event) { | |||
| 760 | 794 | transition: width 0.1s linear; | |
| 761 | 795 | } | |
| 762 | 796 | ||
| 797 | + | .initial-loading { | |
| 798 | + | display: flex; | |
| 799 | + | justify-content: center; | |
| 800 | + | align-items: center; | |
| 801 | + | height: 100%; | |
| 802 | + | font-size: 1.1rem; | |
| 803 | + | color: #666; | |
| 804 | + | } | |
| 805 | + | ||
| 763 | 806 | .loading-messages { | |
| 764 | 807 | text-align: center; | |
| 765 | 808 | padding: 1rem; | |
| @@ -8,6 +8,7 @@ import { resizable } from '$lib/actions/resizable'; | |||||
| 8 | import { maximizable } from '$lib/actions/maximizable'; | 8 | import { maximizable } from '$lib/actions/maximizable'; | ||
| 9 | import LoadingButton from './ui/button-loading.svelte'; | 9 | import LoadingButton from './ui/button-loading.svelte'; | ||
| 10 | import Tooltip from './ui/tooltip.svelte'; | 10 | import Tooltip from './ui/tooltip.svelte'; | ||
| 11 | + | import LoadingDots from './ui/loading-dots.svelte'; | |||
| 11 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | 12 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | ||
| 12 | 13 | ||||
| 13 | // Initialize with default values for SSR | 14 | // Initialize with default values for SSR | ||
| @@ -19,6 +20,7 @@ let isMobile = $state(false); | |||||
| 19 | let currentMessage = $state(''); | 20 | let currentMessage = $state(''); | ||
| 20 | let showUserList = $state(false); | 21 | let showUserList = $state(false); | ||
| 21 | let isMaximized = $state(false); | 22 | let isMaximized = $state(false); | ||
| 23 | + | let isInitialLoading = $state(true); | |||
| 22 | 24 | ||||
| 23 | // Rate limiting state | 25 | // Rate limiting state | ||
| 24 | let cooldownEndTime = $state<number | null>(null); | 26 | let cooldownEndTime = $state<number | null>(null); | ||
| @@ -299,7 +301,16 @@ async function handleScroll(event: Event) { | |||||
| 299 | // Check if we've scrolled near the top (within 100px) and not already loading | 301 | // Check if we've scrolled near the top (within 100px) and not already loading | ||
| 300 | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) { | 302 | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) { | ||
| 301 | isLoadingMore = true; | 303 | isLoadingMore = true; | ||
| 302 | - | console.debug('Loading more messages...', { oldestMessageTimestamp }); | 304 | + | |
| 305 | + | // Store the scroll height and position before loading | |||
| 306 | + | const scrollHeight = chatArea.scrollHeight; | |||
| 307 | + | const currentScrollTop = chatArea.scrollTop; | |||
| 308 | + | ||||
| 309 | + | console.debug('Loading more messages...', { | |||
| 310 | + | oldestMessageTimestamp, | |||
| 311 | + | scrollHeight, | |||
| 312 | + | currentScrollTop | |||
| 313 | + | }); | |||
| 303 | 314 | ||||
| 304 | try { | 315 | try { | ||
| 305 | const response = await fetch(`/api/chat/messages?${new URLSearchParams({ | 316 | const response = await fetch(`/api/chat/messages?${new URLSearchParams({ | ||
| @@ -314,31 +325,50 @@ async function handleScroll(event: Event) { | |||||
| 314 | if (data.messages.length === 0) { | 325 | if (data.messages.length === 0) { | ||
| 315 | hasMoreMessages = false; | 326 | hasMoreMessages = false; | ||
| 316 | } else { | 327 | } else { | ||
| 317 | - | // Preserve scroll position | 328 | + | // Update messages through chat state |
| 318 | - | const oldHeight = chatArea.scrollHeight; | 329 | + | chatState.prependMessages(data.messages, data.hasMore); |
| 319 | 330 | ||||
| 320 | - | // Update messages | 331 | + | // Use requestAnimationFrame to ensure DOM has updated |
| 321 | - | chatState.prependMessages(data.messages); | 332 | + | requestAnimationFrame(() => { |
| 322 | - | 333 | + | // Calculate how much the content height has increased | |
| 323 | - | // After the DOM updates, adjust scroll position | 334 | + | const newScrollHeight = chatArea.scrollHeight; |
| 324 | - | setTimeout(() => { | 335 | + | const heightDifference = newScrollHeight - scrollHeight; |
| 325 | - | const newHeight = chatArea.scrollHeight; | 336 | + | |
| 326 | - | chatArea.scrollTop = newHeight - oldHeight; | 337 | + | // Adjust scroll position to maintain relative position |
| 327 | - | }, 0); | 338 | + | chatArea.scrollTop = currentScrollTop + heightDifference; |
| 339 | + | ||||
| 340 | + | console.debug('Scroll position adjusted:', { | |||
| 341 | + | heightDifference, | |||
| 342 | + | newScrollTop: chatArea.scrollTop, | |||
| 343 | + | newScrollHeight | |||
| 344 | + | }); | |||
| 345 | + | }); | |||
| 328 | 346 | ||||
| 329 | // Update oldest timestamp | 347 | // Update oldest timestamp | ||
| 330 | const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp)); | 348 | const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp)); | ||
| 331 | oldestMessageTimestamp = newOldest; | 349 | oldestMessageTimestamp = newOldest; | ||
| 350 | + | ||||
| 351 | + | // Update hasMoreMessages from response | |||
| 352 | + | hasMoreMessages = data.hasMore; | |||
| 332 | } | 353 | } | ||
| 333 | } | 354 | } | ||
| 334 | } | 355 | } | ||
| 335 | } catch (error) { | 356 | } catch (error) { | ||
| 336 | console.error('Error loading more messages:', error); | 357 | console.error('Error loading more messages:', error); | ||
| 358 | + | hasMoreMessages = false; | |||
| 337 | } finally { | 359 | } finally { | ||
| 338 | isLoadingMore = false; | 360 | isLoadingMore = false; | ||
| 339 | } | 361 | } | ||
| 340 | } | 362 | } | ||
| 341 | } | 363 | } | ||
| 364 | + | ||||
| 365 | + | // Add effect to track initial loading state | |||
| 366 | + | $effect(() => { | |||
| 367 | + | const stateMessages = chatState.getMessages(); | |||
| 368 | + | if (stateMessages.length > 0 && isInitialLoading) { | |||
| 369 | + | isInitialLoading = false; | |||
| 370 | + | } | |||
| 371 | + | }); | |||
| 342 | </script> | 372 | </script> | ||
| 343 | 373 | ||||
| 344 | {#if showChatRoom} | 374 | {#if showChatRoom} | ||
| @@ -415,10 +445,14 @@ async function handleScroll(event: Event) { | |||||
| 415 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | 445 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | ||
| 416 | onscroll={(e) => handleScroll(e)} | 446 | onscroll={(e) => handleScroll(e)} | ||
| 417 | > | 447 | > | ||
| 418 | - | {#if isLoadingMore} | 448 | + | {#if isInitialLoading} |
| 419 | - | <div class="loading-messages"> | 449 | + | <div class="initial-loading"> |
| 420 | - | Loading more messages... | 450 | + | <LoadingDots text="Chargement des messages" /> |
| 421 | - | </div> | 451 | + | </div> |
| 452 | + | {:else if isLoadingMore} | |||
| 453 | + | <div class="loading-messages"> | |||
| 454 | + | <LoadingDots text="Chargement" /> | |||
| 455 | + | </div> | |||
| 422 | {/if} | 456 | {/if} | ||
| 423 | {#each visibleMessages as message (message.id)} | 457 | {#each visibleMessages as message (message.id)} | ||
| 424 | <div class="message {message.type} text"> | 458 | <div class="message {message.type} text"> | ||
| @@ -760,6 +794,15 @@ async function handleScroll(event: Event) { | |||||
| 760 | transition: width 0.1s linear; | 794 | transition: width 0.1s linear; | ||
| 761 | } | 795 | } | ||
| 762 | 796 | ||||
| 797 | + | .initial-loading { | |||
| 798 | + | display: flex; | |||
| 799 | + | justify-content: center; | |||
| 800 | + | align-items: center; | |||
| 801 | + | height: 100%; | |||
| 802 | + | font-size: 1.1rem; | |||
| 803 | + | color: #666; | |||
| 804 | + | } | |||
| 805 | + | ||||
| 763 | .loading-messages { | 806 | .loading-messages { | ||
| 764 | text-align: center; | 807 | text-align: center; | ||
| 765 | padding: 1rem; | 808 | padding: 1rem; | ||
A src/lib/components/ui/loading-dots.svelte +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | <!-- A reusable loading dots component with animated dots --> | |
| 2 | + | <script lang="ts"> | |
| 3 | + | let { text = 'Loading', color = 'inherit' } = $props(); | |
| 4 | + | </script> | |
| 5 | + | ||
| 6 | + | <div class="loading-text" style="color: {color}"> | |
| 7 | + | {text}<span class="dots"><span>.</span><span>.</span><span>.</span></span> | |
| 8 | + | </div> | |
| 9 | + | ||
| 10 | + | <style> | |
| 11 | + | .loading-text { | |
| 12 | + | display: inline-flex; | |
| 13 | + | align-items: center; | |
| 14 | + | font-size: inherit; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | .dots { | |
| 18 | + | display: inline-flex; | |
| 19 | + | margin-left: 2px; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | .dots span { | |
| 23 | + | animation: dots 1.5s infinite; | |
| 24 | + | opacity: 0; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | .dots span:nth-child(1) { | |
| 28 | + | animation-delay: 0s; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | .dots span:nth-child(2) { | |
| 32 | + | animation-delay: 0.5s; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | .dots span:nth-child(3) { | |
| 36 | + | animation-delay: 1s; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | @keyframes dots { | |
| 40 | + | 0% { | |
| 41 | + | opacity: 0; | |
| 42 | + | } | |
| 43 | + | 50% { | |
| 44 | + | opacity: 1; | |
| 45 | + | } | |
| 46 | + | 100% { | |
| 47 | + | opacity: 0; | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | </style> ⏎̸ | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | <!-- A reusable loading dots component with animated dots --> | |||
| 2 | + | <script lang="ts"> | |||
| 3 | + | let { text = 'Loading', color = 'inherit' } = $props(); | |||
| 4 | + | </script> | |||
| 5 | + | ||||
| 6 | + | <div class="loading-text" style="color: {color}"> | |||
| 7 | + | {text}<span class="dots"><span>.</span><span>.</span><span>.</span></span> | |||
| 8 | + | </div> | |||
| 9 | + | ||||
| 10 | + | <style> | |||
| 11 | + | .loading-text { | |||
| 12 | + | display: inline-flex; | |||
| 13 | + | align-items: center; | |||
| 14 | + | font-size: inherit; | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | .dots { | |||
| 18 | + | display: inline-flex; | |||
| 19 | + | margin-left: 2px; | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | .dots span { | |||
| 23 | + | animation: dots 1.5s infinite; | |||
| 24 | + | opacity: 0; | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | .dots span:nth-child(1) { | |||
| 28 | + | animation-delay: 0s; | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | .dots span:nth-child(2) { | |||
| 32 | + | animation-delay: 0.5s; | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | .dots span:nth-child(3) { | |||
| 36 | + | animation-delay: 1s; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | @keyframes dots { | |||
| 40 | + | 0% { | |||
| 41 | + | opacity: 0; | |||
| 42 | + | } | |||
| 43 | + | 50% { | |||
| 44 | + | opacity: 1; | |||
| 45 | + | } | |||
| 46 | + | 100% { | |||
| 47 | + | opacity: 0; | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | </style> ⏎̸ | |||
M src/lib/states/chat.svelte.ts +49 -9
| @@ -7,7 +7,8 @@ import type { | |||
| 7 | 7 | import { invalidate } from '$app/navigation'; | |
| 8 | 8 | import { env } from '$env/dynamic/public'; | |
| 9 | 9 | ||
| 10 | - | const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID; | |
| 10 | + | // Use the public environment variable with a fallback | |
| 11 | + | const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 11 | 12 | ||
| 12 | 13 | class ChatState { | |
| 13 | 14 | private users = $state<User[]>([]); | |
| @@ -18,7 +19,7 @@ class ChatState { | |||
| 18 | 19 | private maxReconnectAttempts = 5; | |
| 19 | 20 | private reconnectDelay = 1000; // Start with 1 second | |
| 20 | 21 | private isInitializing = $state(false); | |
| 21 | - | private currentRoomId = $state<string>(); | |
| 22 | + | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | |
| 22 | 23 | private userCache = $state<Record<string, User>>({}); | |
| 23 | 24 | private sseError = $state<string | null>(null); | |
| 24 | 25 | private sseRetryAfter = $state<number | null>(null); | |
| @@ -33,6 +34,7 @@ class ChatState { | |||
| 33 | 34 | private sseHandlersRegistered = false; | |
| 34 | 35 | private lastBuddyListUpdate = 0; | |
| 35 | 36 | private lastBuddyListHash = ''; | |
| 37 | + | private hasMoreMessages = $state(false); | |
| 36 | 38 | ||
| 37 | 39 | public async reinitialize() { | |
| 38 | 40 | if (this.isInitializing) { | |
| @@ -48,6 +50,9 @@ class ChatState { | |||
| 48 | 50 | this.reconnectAttempts = 0; | |
| 49 | 51 | this.reconnectDelay = 1000; | |
| 50 | 52 | this.messages = []; | |
| 53 | + | // Ensure currentRoomId is set | |
| 54 | + | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | |
| 55 | + | ||
| 51 | 56 | if (this.currentUser) { | |
| 52 | 57 | console.debug('Waiting for session cookie update before setting up SSE...'); | |
| 53 | 58 | await new Promise(resolve => setTimeout(resolve, 1000)); | |
| @@ -102,6 +107,8 @@ class ChatState { | |||
| 102 | 107 | } | |
| 103 | 108 | } | |
| 104 | 109 | ||
| 110 | + | // Clear existing messages before setting new user | |
| 111 | + | this.messages = []; | |
| 105 | 112 | this.currentUser = user; | |
| 106 | 113 | ||
| 107 | 114 | if (user) { | |
| @@ -128,7 +135,11 @@ class ChatState { | |||
| 128 | 135 | private async initializeRoomUsers() { | |
| 129 | 136 | console.debug('Initializing room users for room:', this.currentRoomId); | |
| 130 | 137 | try { | |
| 131 | - | const response = await fetch(`/api/rooms/${this.currentRoomId}`, { | |
| 138 | + | const isPublic = !this.currentUser; | |
| 139 | + | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | |
| 140 | + | console.debug('Fetching room users from:', url); | |
| 141 | + | ||
| 142 | + | const response = await fetch(url, { | |
| 132 | 143 | credentials: 'include' | |
| 133 | 144 | }); | |
| 134 | 145 | ||
| @@ -305,7 +316,7 @@ class ChatState { | |||
| 305 | 316 | return this._memoizedEnrichedMessages; | |
| 306 | 317 | } | |
| 307 | 318 | ||
| 308 | - | public prependMessages(newMessages: Message[]) { | |
| 319 | + | public prependMessages(newMessages: Message[], hasMore?: boolean) { | |
| 309 | 320 | console.debug('Prepending messages:', { count: newMessages.length }); | |
| 310 | 321 | // Get unique user IDs from new messages that aren't in the cache | |
| 311 | 322 | const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId)); | |
| @@ -335,9 +346,15 @@ class ChatState { | |||
| 335 | 346 | })); | |
| 336 | 347 | } | |
| 337 | 348 | ||
| 338 | - | // Prepend new messages while maintaining uniqueness | |
| 339 | - | const uniqueNewMessages = newMessages.filter(msg => !this.messages.some(m => m.id === msg.id)); | |
| 349 | + | // Ensure incoming messages are sorted in ascending order before prepending | |
| 350 | + | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 351 | + | const uniqueNewMessages = sortedNewMessages.filter(msg => !this.messages.some(m => m.id === msg.id)); | |
| 340 | 352 | this.messages = [...uniqueNewMessages, ...this.messages]; | |
| 353 | + | ||
| 354 | + | // Update hasMore if provided | |
| 355 | + | if (hasMore !== undefined) { | |
| 356 | + | this.hasMoreMessages = hasMore; | |
| 357 | + | } | |
| 341 | 358 | } | |
| 342 | 359 | ||
| 343 | 360 | // Add method to update user cache for public access | |
| @@ -395,13 +412,21 @@ class ChatState { | |||
| 395 | 412 | updateMessages(messages: Message[]) { | |
| 396 | 413 | // Deduplicate the messages by their id in case duplicates exist | |
| 397 | 414 | const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values()); | |
| 398 | - | this.messages = uniqueMessages; | |
| 415 | + | // Sort messages in ascending order (oldest first, newest at bottom) | |
| 416 | + | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | |
| 399 | 417 | } | |
| 400 | 418 | ||
| 401 | 419 | // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE. | |
| 402 | 420 | async initializeMessages() { | |
| 403 | 421 | try { | |
| 404 | - | const response = await fetch('/api/chat/messages', { | |
| 422 | + | // Build the URL with appropriate parameters | |
| 423 | + | const params = new URLSearchParams(); | |
| 424 | + | if (!this.currentUser) { | |
| 425 | + | params.append('public', 'true'); | |
| 426 | + | } | |
| 427 | + | params.append('roomId', this.currentRoomId); | |
| 428 | + | ||
| 429 | + | const response = await fetch(`/api/chat/messages?${params}`, { | |
| 405 | 430 | credentials: 'include' | |
| 406 | 431 | }); | |
| 407 | 432 | if (!response.ok) throw new Error('Failed to fetch messages'); | |
| @@ -439,10 +464,23 @@ class ChatState { | |||
| 439 | 464 | })); | |
| 440 | 465 | } | |
| 441 | 466 | ||
| 442 | - | this.messages = data.messages; | |
| 467 | + | // Sort messages in ascending order (oldest first, most recent at the bottom) | |
| 468 | + | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 469 | + | ||
| 470 | + | // Update hasMore based on response | |
| 471 | + | if ('hasMore' in data) { | |
| 472 | + | this.hasMoreMessages = data.hasMore; | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | console.debug('Messages initialized:', { | |
| 476 | + | count: data.messages.length, | |
| 477 | + | isAuthenticated: !!this.currentUser, | |
| 478 | + | hasMore: this.hasMoreMessages | |
| 479 | + | }); | |
| 443 | 480 | } catch (error) { | |
| 444 | 481 | console.debug('Error fetching initial messages:', error); | |
| 445 | 482 | this.messages = []; | |
| 483 | + | this.hasMoreMessages = false; | |
| 446 | 484 | } | |
| 447 | 485 | } | |
| 448 | 486 | ||
| @@ -592,6 +630,8 @@ class ChatState { | |||
| 592 | 630 | console.debug('Received chat message via SSE:', messageData); | |
| 593 | 631 | // Deduplicate and update messages array with the new message | |
| 594 | 632 | this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values()); | |
| 633 | + | // Sort messages so the newest are at the bottom | |
| 634 | + | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |
| 595 | 635 | // Ensure the sender's data is available | |
| 596 | 636 | await this.ensureUserData(messageData.senderId); | |
| 597 | 637 | } catch (error) { | |
| @@ -7,7 +7,8 @@ import type { | |||||
| 7 | import { invalidate } from '$app/navigation'; | 7 | import { invalidate } from '$app/navigation'; | ||
| 8 | import { env } from '$env/dynamic/public'; | 8 | import { env } from '$env/dynamic/public'; | ||
| 9 | 9 | ||||
| 10 | - | const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID; | 10 | + | // Use the public environment variable with a fallback |
| 11 | + | const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |||
| 11 | 12 | ||||
| 12 | class ChatState { | 13 | class ChatState { | ||
| 13 | private users = $state<User[]>([]); | 14 | private users = $state<User[]>([]); | ||
| @@ -18,7 +19,7 @@ class ChatState { | |||||
| 18 | private maxReconnectAttempts = 5; | 19 | private maxReconnectAttempts = 5; | ||
| 19 | private reconnectDelay = 1000; // Start with 1 second | 20 | private reconnectDelay = 1000; // Start with 1 second | ||
| 20 | private isInitializing = $state(false); | 21 | private isInitializing = $state(false); | ||
| 21 | - | private currentRoomId = $state<string>(); | 22 | + | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); |
| 22 | private userCache = $state<Record<string, User>>({}); | 23 | private userCache = $state<Record<string, User>>({}); | ||
| 23 | private sseError = $state<string | null>(null); | 24 | private sseError = $state<string | null>(null); | ||
| 24 | private sseRetryAfter = $state<number | null>(null); | 25 | private sseRetryAfter = $state<number | null>(null); | ||
| @@ -33,6 +34,7 @@ class ChatState { | |||||
| 33 | private sseHandlersRegistered = false; | 34 | private sseHandlersRegistered = false; | ||
| 34 | private lastBuddyListUpdate = 0; | 35 | private lastBuddyListUpdate = 0; | ||
| 35 | private lastBuddyListHash = ''; | 36 | private lastBuddyListHash = ''; | ||
| 37 | + | private hasMoreMessages = $state(false); | |||
| 36 | 38 | ||||
| 37 | public async reinitialize() { | 39 | public async reinitialize() { | ||
| 38 | if (this.isInitializing) { | 40 | if (this.isInitializing) { | ||
| @@ -48,6 +50,9 @@ class ChatState { | |||||
| 48 | this.reconnectAttempts = 0; | 50 | this.reconnectAttempts = 0; | ||
| 49 | this.reconnectDelay = 1000; | 51 | this.reconnectDelay = 1000; | ||
| 50 | this.messages = []; | 52 | this.messages = []; | ||
| 53 | + | // Ensure currentRoomId is set | |||
| 54 | + | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | |||
| 55 | + | ||||
| 51 | if (this.currentUser) { | 56 | if (this.currentUser) { | ||
| 52 | console.debug('Waiting for session cookie update before setting up SSE...'); | 57 | console.debug('Waiting for session cookie update before setting up SSE...'); | ||
| 53 | await new Promise(resolve => setTimeout(resolve, 1000)); | 58 | await new Promise(resolve => setTimeout(resolve, 1000)); | ||
| @@ -102,6 +107,8 @@ class ChatState { | |||||
| 102 | } | 107 | } | ||
| 103 | } | 108 | } | ||
| 104 | 109 | ||||
| 110 | + | // Clear existing messages before setting new user | |||
| 111 | + | this.messages = []; | |||
| 105 | this.currentUser = user; | 112 | this.currentUser = user; | ||
| 106 | 113 | ||||
| 107 | if (user) { | 114 | if (user) { | ||
| @@ -128,7 +135,11 @@ class ChatState { | |||||
| 128 | private async initializeRoomUsers() { | 135 | private async initializeRoomUsers() { | ||
| 129 | console.debug('Initializing room users for room:', this.currentRoomId); | 136 | console.debug('Initializing room users for room:', this.currentRoomId); | ||
| 130 | try { | 137 | try { | ||
| 131 | - | const response = await fetch(`/api/rooms/${this.currentRoomId}`, { | 138 | + | const isPublic = !this.currentUser; |
| 139 | + | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | |||
| 140 | + | console.debug('Fetching room users from:', url); | |||
| 141 | + | ||||
| 142 | + | const response = await fetch(url, { | |||
| 132 | credentials: 'include' | 143 | credentials: 'include' | ||
| 133 | }); | 144 | }); | ||
| 134 | 145 | ||||
| @@ -305,7 +316,7 @@ class ChatState { | |||||
| 305 | return this._memoizedEnrichedMessages; | 316 | return this._memoizedEnrichedMessages; | ||
| 306 | } | 317 | } | ||
| 307 | 318 | ||||
| 308 | - | public prependMessages(newMessages: Message[]) { | 319 | + | public prependMessages(newMessages: Message[], hasMore?: boolean) { |
| 309 | console.debug('Prepending messages:', { count: newMessages.length }); | 320 | console.debug('Prepending messages:', { count: newMessages.length }); | ||
| 310 | // Get unique user IDs from new messages that aren't in the cache | 321 | // Get unique user IDs from new messages that aren't in the cache | ||
| 311 | const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId)); | 322 | const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId)); | ||
| @@ -335,9 +346,15 @@ class ChatState { | |||||
| 335 | })); | 346 | })); | ||
| 336 | } | 347 | } | ||
| 337 | 348 | ||||
| 338 | - | // Prepend new messages while maintaining uniqueness | 349 | + | // Ensure incoming messages are sorted in ascending order before prepending |
| 339 | - | const uniqueNewMessages = newMessages.filter(msg => !this.messages.some(m => m.id === msg.id)); | 350 | + | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); |
| 351 | + | const uniqueNewMessages = sortedNewMessages.filter(msg => !this.messages.some(m => m.id === msg.id)); | |||
| 340 | this.messages = [...uniqueNewMessages, ...this.messages]; | 352 | this.messages = [...uniqueNewMessages, ...this.messages]; | ||
| 353 | + | ||||
| 354 | + | // Update hasMore if provided | |||
| 355 | + | if (hasMore !== undefined) { | |||
| 356 | + | this.hasMoreMessages = hasMore; | |||
| 357 | + | } | |||
| 341 | } | 358 | } | ||
| 342 | 359 | ||||
| 343 | // Add method to update user cache for public access | 360 | // Add method to update user cache for public access | ||
| @@ -395,13 +412,21 @@ class ChatState { | |||||
| 395 | updateMessages(messages: Message[]) { | 412 | updateMessages(messages: Message[]) { | ||
| 396 | // Deduplicate the messages by their id in case duplicates exist | 413 | // Deduplicate the messages by their id in case duplicates exist | ||
| 397 | const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values()); | 414 | const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values()); | ||
| 398 | - | this.messages = uniqueMessages; | 415 | + | // Sort messages in ascending order (oldest first, newest at bottom) |
| 416 | + | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | |||
| 399 | } | 417 | } | ||
| 400 | 418 | ||||
| 401 | // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE. | 419 | // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE. | ||
| 402 | async initializeMessages() { | 420 | async initializeMessages() { | ||
| 403 | try { | 421 | try { | ||
| 404 | - | const response = await fetch('/api/chat/messages', { | 422 | + | // Build the URL with appropriate parameters |
| 423 | + | const params = new URLSearchParams(); | |||
| 424 | + | if (!this.currentUser) { | |||
| 425 | + | params.append('public', 'true'); | |||
| 426 | + | } | |||
| 427 | + | params.append('roomId', this.currentRoomId); | |||
| 428 | + | ||||
| 429 | + | const response = await fetch(`/api/chat/messages?${params}`, { | |||
| 405 | credentials: 'include' | 430 | credentials: 'include' | ||
| 406 | }); | 431 | }); | ||
| 407 | if (!response.ok) throw new Error('Failed to fetch messages'); | 432 | if (!response.ok) throw new Error('Failed to fetch messages'); | ||
| @@ -439,10 +464,23 @@ class ChatState { | |||||
| 439 | })); | 464 | })); | ||
| 440 | } | 465 | } | ||
| 441 | 466 | ||||
| 442 | - | this.messages = data.messages; | 467 | + | // Sort messages in ascending order (oldest first, most recent at the bottom) |
| 468 | + | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | |||
| 469 | + | ||||
| 470 | + | // Update hasMore based on response | |||
| 471 | + | if ('hasMore' in data) { | |||
| 472 | + | this.hasMoreMessages = data.hasMore; | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | console.debug('Messages initialized:', { | |||
| 476 | + | count: data.messages.length, | |||
| 477 | + | isAuthenticated: !!this.currentUser, | |||
| 478 | + | hasMore: this.hasMoreMessages | |||
| 479 | + | }); | |||
| 443 | } catch (error) { | 480 | } catch (error) { | ||
| 444 | console.debug('Error fetching initial messages:', error); | 481 | console.debug('Error fetching initial messages:', error); | ||
| 445 | this.messages = []; | 482 | this.messages = []; | ||
| 483 | + | this.hasMoreMessages = false; | |||
| 446 | } | 484 | } | ||
| 447 | } | 485 | } | ||
| 448 | 486 | ||||
| @@ -592,6 +630,8 @@ class ChatState { | |||||
| 592 | console.debug('Received chat message via SSE:', messageData); | 630 | console.debug('Received chat message via SSE:', messageData); | ||
| 593 | // Deduplicate and update messages array with the new message | 631 | // Deduplicate and update messages array with the new message | ||
| 594 | this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values()); | 632 | this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values()); | ||
| 633 | + | // Sort messages so the newest are at the bottom | |||
| 634 | + | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |||
| 595 | // Ensure the sender's data is available | 635 | // Ensure the sender's data is available | ||
| 596 | await this.ensureUserData(messageData.senderId); | 636 | await this.ensureUserData(messageData.senderId); | ||
| 597 | } catch (error) { | 637 | } catch (error) { | ||
M src/lib/types/payloads.ts +1 -0
| @@ -63,6 +63,7 @@ export type SendMessageResponse = SendMessageResponseSuccess | SendMessageRespon | |||
| 63 | 63 | export interface GetMessagesResponseSuccess { | |
| 64 | 64 | success: true; | |
| 65 | 65 | messages: Message[]; | |
| 66 | + | hasMore: boolean; | |
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | export interface GetMessagesResponseError { | |
| @@ -63,6 +63,7 @@ export type SendMessageResponse = SendMessageResponseSuccess | SendMessageRespon | |||||
| 63 | export interface GetMessagesResponseSuccess { | 63 | export interface GetMessagesResponseSuccess { | ||
| 64 | success: true; | 64 | success: true; | ||
| 65 | messages: Message[]; | 65 | messages: Message[]; | ||
| 66 | + | hasMore: boolean; | |||
| 66 | } | 67 | } | ||
| 67 | 68 | ||||
| 68 | export interface GetMessagesResponseError { | 69 | export interface GetMessagesResponseError { | ||
M src/routes/api/chat/messages/+server.ts +14 -3
| @@ -55,9 +55,16 @@ export async function GET({ request, locals }) { | |||
| 55 | 55 | const url = new URL(request.url); | |
| 56 | 56 | const beforeTimestamp = url.searchParams.get('before'); | |
| 57 | 57 | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | |
| 58 | + | const isPublic = url.searchParams.get('public') === 'true'; | |
| 58 | 59 | ||
| 59 | 60 | try { | |
| 60 | - | log.debug('Fetching messages from database', { beforeTimestamp, roomId, isAuthenticated: !!locals.session }); | |
| 61 | + | log.debug('Fetching messages from database', { | |
| 62 | + | beforeTimestamp, | |
| 63 | + | roomId, | |
| 64 | + | isAuthenticated: !!locals.session, | |
| 65 | + | isPublic | |
| 66 | + | }); | |
| 67 | + | ||
| 61 | 68 | let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>; | |
| 62 | 69 | ||
| 63 | 70 | if (beforeTimestamp) { | |
| @@ -68,7 +75,10 @@ export async function GET({ request, locals }) { | |||
| 68 | 75 | } | |
| 69 | 76 | ||
| 70 | 77 | // Fetch messages with a limit based on authentication status | |
| 71 | - | const fetchLimit = locals.session ? 100 : 10; | |
| 78 | + | // Public requests are always limited to 10 messages | |
| 79 | + | // Authenticated users get 100 messages per request | |
| 80 | + | const fetchLimit = (isPublic || !locals.session) ? 10 : 100; | |
| 81 | + | ||
| 72 | 82 | const fetchedMessages = await db.select() | |
| 73 | 83 | .from(messages) | |
| 74 | 84 | .where(conditions) | |
| @@ -77,7 +87,8 @@ export async function GET({ request, locals }) { | |||
| 77 | 87 | ||
| 78 | 88 | const response: GetMessagesResponse = { | |
| 79 | 89 | success: true, | |
| 80 | - | messages: fetchedMessages | |
| 90 | + | messages: fetchedMessages, | |
| 91 | + | hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages | |
| 81 | 92 | }; | |
| 82 | 93 | ||
| 83 | 94 | return new Response(JSON.stringify(response), { | |
| @@ -55,9 +55,16 @@ export async function GET({ request, locals }) { | |||||
| 55 | const url = new URL(request.url); | 55 | const url = new URL(request.url); | ||
| 56 | const beforeTimestamp = url.searchParams.get('before'); | 56 | const beforeTimestamp = url.searchParams.get('before'); | ||
| 57 | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | 57 | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | ||
| 58 | + | const isPublic = url.searchParams.get('public') === 'true'; | |||
| 58 | 59 | ||||
| 59 | try { | 60 | try { | ||
| 60 | - | log.debug('Fetching messages from database', { beforeTimestamp, roomId, isAuthenticated: !!locals.session }); | 61 | + | log.debug('Fetching messages from database', { |
| 62 | + | beforeTimestamp, | |||
| 63 | + | roomId, | |||
| 64 | + | isAuthenticated: !!locals.session, | |||
| 65 | + | isPublic | |||
| 66 | + | }); | |||
| 67 | + | ||||
| 61 | let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>; | 68 | let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>; | ||
| 62 | 69 | ||||
| 63 | if (beforeTimestamp) { | 70 | if (beforeTimestamp) { | ||
| @@ -68,7 +75,10 @@ export async function GET({ request, locals }) { | |||||
| 68 | } | 75 | } | ||
| 69 | 76 | ||||
| 70 | // Fetch messages with a limit based on authentication status | 77 | // Fetch messages with a limit based on authentication status | ||
| 71 | - | const fetchLimit = locals.session ? 100 : 10; | 78 | + | // Public requests are always limited to 10 messages |
| 79 | + | // Authenticated users get 100 messages per request | |||
| 80 | + | const fetchLimit = (isPublic || !locals.session) ? 10 : 100; | |||
| 81 | + | ||||
| 72 | const fetchedMessages = await db.select() | 82 | const fetchedMessages = await db.select() | ||
| 73 | .from(messages) | 83 | .from(messages) | ||
| 74 | .where(conditions) | 84 | .where(conditions) | ||
| @@ -77,7 +87,8 @@ export async function GET({ request, locals }) { | |||||
| 77 | 87 | ||||
| 78 | const response: GetMessagesResponse = { | 88 | const response: GetMessagesResponse = { | ||
| 79 | success: true, | 89 | success: true, | ||
| 80 | - | messages: fetchedMessages | 90 | + | messages: fetchedMessages, |
| 91 | + | hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages | |||
| 81 | }; | 92 | }; | ||
| 82 | 93 | ||||
| 83 | return new Response(JSON.stringify(response), { | 94 | return new Response(JSON.stringify(response), { | ||
M src/routes/api/rooms/[roomId]/+server.ts +1 -8
| @@ -2,7 +2,6 @@ import { desc, eq } from 'drizzle-orm'; | |||
| 2 | 2 | import db from '$lib/db/db.server'; | |
| 3 | 3 | import { messages, users } from '$lib/db/schema'; | |
| 4 | 4 | import type { PublicRoomResponse } from '$lib/types/payloads'; | |
| 5 | - | import { error } from '@sveltejs/kit'; | |
| 6 | 5 | import { createSafeUser } from '$lib/types/chat'; | |
| 7 | 6 | import { createLogger } from '$lib/utils/logger.server'; | |
| 8 | 7 | ||
| @@ -10,14 +9,8 @@ const log = createLogger('rooms-server'); | |||
| 10 | 9 | ||
| 11 | 10 | export async function GET({ params, url, locals }): Promise<Response> { | |
| 12 | 11 | const { roomId } = params; | |
| 13 | - | const isPublic = url.searchParams.get('public') === 'true'; | |
| 12 | + | const isPublic = !locals.user || url.searchParams.get('public') === 'true'; | |
| 14 | 13 | ||
| 15 | - | // Check authentication for non-public requests | |
| 16 | - | if (!isPublic && !locals.user) { | |
| 17 | - | log.warn('Authentication required'); | |
| 18 | - | throw error(401, 'Authentication required'); | |
| 19 | - | } | |
| 20 | - | ||
| 21 | 14 | try { | |
| 22 | 15 | log.debug('Fetching room data', { | |
| 23 | 16 | roomId, | |
| @@ -2,7 +2,6 @@ import { desc, eq } from 'drizzle-orm'; | |||||
| 2 | import db from '$lib/db/db.server'; | 2 | import db from '$lib/db/db.server'; | ||
| 3 | import { messages, users } from '$lib/db/schema'; | 3 | import { messages, users } from '$lib/db/schema'; | ||
| 4 | import type { PublicRoomResponse } from '$lib/types/payloads'; | 4 | import type { PublicRoomResponse } from '$lib/types/payloads'; | ||
| 5 | - | import { error } from '@sveltejs/kit'; | |||
| 6 | import { createSafeUser } from '$lib/types/chat'; | 5 | import { createSafeUser } from '$lib/types/chat'; | ||
| 7 | import { createLogger } from '$lib/utils/logger.server'; | 6 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 8 | 7 | ||||
| @@ -10,14 +9,8 @@ const log = createLogger('rooms-server'); | |||||
| 10 | 9 | ||||
| 11 | export async function GET({ params, url, locals }): Promise<Response> { | 10 | export async function GET({ params, url, locals }): Promise<Response> { | ||
| 12 | const { roomId } = params; | 11 | const { roomId } = params; | ||
| 13 | - | const isPublic = url.searchParams.get('public') === 'true'; | 12 | + | const isPublic = !locals.user || url.searchParams.get('public') === 'true'; |
| 14 | 13 | ||||
| 15 | - | // Check authentication for non-public requests | |||
| 16 | - | if (!isPublic && !locals.user) { | |||
| 17 | - | log.warn('Authentication required'); | |||
| 18 | - | throw error(401, 'Authentication required'); | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | try { | 14 | try { | ||
| 22 | log.debug('Fetching room data', { | 15 | log.debug('Fetching room data', { | ||
| 23 | roomId, | 16 | roomId, | ||