feat: add loading state for Send buttonm
- commit
- 6251ded5f765f43761fad26a403d1a1ef1930cd9
- author
- Kit · · 2025-02-13 07:01 UTC
- parent
- 64c03e0
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
4 files · +105 -12
M src/lib/components/chat-room.svelte +18 -7
| @@ -6,6 +6,7 @@ import { browser } from '$app/environment'; | |||
| 6 | 6 | import { draggable } from '$lib/actions/draggable'; | |
| 7 | 7 | import { resizable } from '$lib/actions/resizable'; | |
| 8 | 8 | import { maximizable } from '$lib/actions/maximizable'; | |
| 9 | + | import LoadingButton from './ui/button-loading.svelte'; | |
| 9 | 10 | ||
| 10 | 11 | // Initialize with default values for SSR | |
| 11 | 12 | let windowWidth = $state(400); | |
| @@ -131,16 +132,20 @@ onMount(() => { | |||
| 131 | 132 | return () => window.removeEventListener('resize', handleResize); | |
| 132 | 133 | }); | |
| 133 | 134 | ||
| 135 | + | // New reactive state to track when a message is being sent | |
| 136 | + | let isSendingMessage = $state(false); | |
| 137 | + | ||
| 134 | 138 | async function handleSubmit() { | |
| 135 | 139 | if (!currentMessage.trim()) return; | |
| 136 | - | ||
| 140 | + | isSendingMessage = true; | |
| 137 | 141 | try { | |
| 138 | 142 | await chatState.sendMessage(currentMessage); | |
| 139 | 143 | currentMessage = ''; | |
| 140 | 144 | } catch (error) { | |
| 141 | 145 | console.error('Failed to send message:', error); | |
| 142 | - | // You might want to show this error to the user in a more friendly way | |
| 143 | 146 | alert('Failed to send message. Please try again.'); | |
| 147 | + | } finally { | |
| 148 | + | isSendingMessage = false; | |
| 144 | 149 | } | |
| 145 | 150 | } | |
| 146 | 151 | ||
| @@ -262,7 +267,12 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 262 | 267 | placeholder="Type a message..." | |
| 263 | 268 | disabled={!currentUser} | |
| 264 | 269 | /> | |
| 265 | - | <button onclick={handleSubmit} disabled={!currentUser}>Send</button> | |
| 270 | + | <LoadingButton | |
| 271 | + | onclick={handleSubmit} | |
| 272 | + | disabled={!currentUser} | |
| 273 | + | loading={isSendingMessage} | |
| 274 | + | text="Send" | |
| 275 | + | /> | |
| 266 | 276 | </div> | |
| 267 | 277 | </div> | |
| 268 | 278 | ||
| @@ -291,11 +301,12 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 291 | 301 | {/if} | |
| 292 | 302 | ||
| 293 | 303 | <style> | |
| 294 | - | :global(*) { | |
| 304 | + | ||
| 305 | + | .chat-area, .users-list, input { | |
| 295 | 306 | font-size: 1rem; | |
| 296 | - | font-family: 'Microsoft Sans Serif', 'Segoe UI', Tahoma, sans-serif; | |
| 307 | + | font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif; | |
| 297 | 308 | } | |
| 298 | - | ||
| 309 | + | ||
| 299 | 310 | .title-bar { | |
| 300 | 311 | height: 2rem; | |
| 301 | 312 | position: relative; | |
| @@ -389,7 +400,7 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 389 | 400 | } | |
| 390 | 401 | ||
| 391 | 402 | .input-container input:disabled, | |
| 392 | - | .input-container button:disabled { | |
| 403 | + | .input-container { | |
| 393 | 404 | opacity: 0.7; | |
| 394 | 405 | background-color: rgba(128, 128, 128, 0.1); | |
| 395 | 406 | cursor: not-allowed; | |
| @@ -6,6 +6,7 @@ import { browser } from '$app/environment'; | |||||
| 6 | import { draggable } from '$lib/actions/draggable'; | 6 | import { draggable } from '$lib/actions/draggable'; | ||
| 7 | import { resizable } from '$lib/actions/resizable'; | 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 | 10 | ||||
| 10 | // Initialize with default values for SSR | 11 | // Initialize with default values for SSR | ||
| 11 | let windowWidth = $state(400); | 12 | let windowWidth = $state(400); | ||
| @@ -131,16 +132,20 @@ onMount(() => { | |||||
| 131 | return () => window.removeEventListener('resize', handleResize); | 132 | return () => window.removeEventListener('resize', handleResize); | ||
| 132 | }); | 133 | }); | ||
| 133 | 134 | ||||
| 135 | + | // New reactive state to track when a message is being sent | |||
| 136 | + | let isSendingMessage = $state(false); | |||
| 137 | + | ||||
| 134 | async function handleSubmit() { | 138 | async function handleSubmit() { | ||
| 135 | if (!currentMessage.trim()) return; | 139 | if (!currentMessage.trim()) return; | ||
| 136 | - | 140 | + | isSendingMessage = true; | |
| 137 | try { | 141 | try { | ||
| 138 | await chatState.sendMessage(currentMessage); | 142 | await chatState.sendMessage(currentMessage); | ||
| 139 | currentMessage = ''; | 143 | currentMessage = ''; | ||
| 140 | } catch (error) { | 144 | } catch (error) { | ||
| 141 | console.error('Failed to send message:', error); | 145 | console.error('Failed to send message:', error); | ||
| 142 | - | // You might want to show this error to the user in a more friendly way | |||
| 143 | alert('Failed to send message. Please try again.'); | 146 | alert('Failed to send message. Please try again.'); | ||
| 147 | + | } finally { | |||
| 148 | + | isSendingMessage = false; | |||
| 144 | } | 149 | } | ||
| 145 | } | 150 | } | ||
| 146 | 151 | ||||
| @@ -262,7 +267,12 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 262 | placeholder="Type a message..." | 267 | placeholder="Type a message..." | ||
| 263 | disabled={!currentUser} | 268 | disabled={!currentUser} | ||
| 264 | /> | 269 | /> | ||
| 265 | - | <button onclick={handleSubmit} disabled={!currentUser}>Send</button> | 270 | + | <LoadingButton |
| 271 | + | onclick={handleSubmit} | |||
| 272 | + | disabled={!currentUser} | |||
| 273 | + | loading={isSendingMessage} | |||
| 274 | + | text="Send" | |||
| 275 | + | /> | |||
| 266 | </div> | 276 | </div> | ||
| 267 | </div> | 277 | </div> | ||
| 268 | 278 | ||||
| @@ -291,11 +301,12 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 291 | {/if} | 301 | {/if} | ||
| 292 | 302 | ||||
| 293 | <style> | 303 | <style> | ||
| 294 | - | :global(*) { | 304 | + | |
| 305 | + | .chat-area, .users-list, input { | |||
| 295 | font-size: 1rem; | 306 | font-size: 1rem; | ||
| 296 | - | font-family: 'Microsoft Sans Serif', 'Segoe UI', Tahoma, sans-serif; | 307 | + | font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif; |
| 297 | } | 308 | } | ||
| 298 | - | 309 | + | ||
| 299 | .title-bar { | 310 | .title-bar { | ||
| 300 | height: 2rem; | 311 | height: 2rem; | ||
| 301 | position: relative; | 312 | position: relative; | ||
| @@ -389,7 +400,7 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 389 | } | 400 | } | ||
| 390 | 401 | ||||
| 391 | .input-container input:disabled, | 402 | .input-container input:disabled, | ||
| 392 | - | .input-container button:disabled { | 403 | + | .input-container { |
| 393 | opacity: 0.7; | 404 | opacity: 0.7; | ||
| 394 | background-color: rgba(128, 128, 128, 0.1); | 405 | background-color: rgba(128, 128, 128, 0.1); | ||
| 395 | cursor: not-allowed; | 406 | cursor: not-allowed; | ||
A src/lib/components/ui/button-loading.svelte +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onDestroy } from 'svelte'; | |
| 3 | + | ||
| 4 | + | // Props with defaults: | |
| 5 | + | // - text: button label when not loading (default "Send") | |
| 6 | + | // - loading: indicates if the button should show a spinner (default false) | |
| 7 | + | // - spinnerChar: the character to use for the spinner animation (default '.') | |
| 8 | + | let { onclick, disabled = $bindable(false), text, loading = $bindable(false), spinnerChar = $bindable('.') } = $props(); | |
| 9 | + | ||
| 10 | + | // Using $state for internal reactive variables. | |
| 11 | + | let animatedDots = $state(''); // will hold the animated spinner text | |
| 12 | + | let count = $state(1); | |
| 13 | + | let isDecreasing = $state(true); | |
| 14 | + | let intervalID = $state<number | null>(null); | |
| 15 | + | ||
| 16 | + | // Effect that starts/stops the spinner based on loading state. | |
| 17 | + | $effect(() => { | |
| 18 | + | if (loading) { | |
| 19 | + | console.debug("LoadingButton: Spinner started"); | |
| 20 | + | // Immediately update spinner text to show 3 dots and start decreasing | |
| 21 | + | animatedDots = spinnerChar.repeat(3); | |
| 22 | + | count = 3; | |
| 23 | + | isDecreasing = true; | |
| 24 | + | ||
| 25 | + | if (!intervalID) { | |
| 26 | + | intervalID = window.setInterval(() => { | |
| 27 | + | // Ping-pong logic: 3,2,1,2,3... | |
| 28 | + | count = count + (isDecreasing ? -1 : 1); | |
| 29 | + | if (count <= 1) { | |
| 30 | + | count = 1; | |
| 31 | + | isDecreasing = false; | |
| 32 | + | } else if (count >= 3) { | |
| 33 | + | count = 3; | |
| 34 | + | isDecreasing = true; | |
| 35 | + | } | |
| 36 | + | animatedDots = spinnerChar.repeat(count); | |
| 37 | + | console.debug("LoadingButton: Animated dots updated to", animatedDots); | |
| 38 | + | }, 500); | |
| 39 | + | } | |
| 40 | + | } else { | |
| 41 | + | console.debug("LoadingButton: Spinner stopped"); | |
| 42 | + | if (intervalID) { | |
| 43 | + | clearInterval(intervalID); | |
| 44 | + | intervalID = null; | |
| 45 | + | } | |
| 46 | + | // When not loading, display the default text. | |
| 47 | + | animatedDots = text; | |
| 48 | + | } | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | onDestroy(() => { | |
| 52 | + | if (intervalID) { | |
| 53 | + | console.debug("LoadingButton: onDestroy clearing interval"); | |
| 54 | + | clearInterval(intervalID); | |
| 55 | + | } | |
| 56 | + | }); | |
| 57 | + | </script> | |
| 58 | + | ||
| 59 | + | <button onclick={onclick} disabled={disabled}> | |
| 60 | + | {#if loading} | |
| 61 | + | <span>{animatedDots}</span> | |
| 62 | + | {:else} | |
| 63 | + | <span>{text}</span> | |
| 64 | + | {/if} | |
| 65 | + | </button> | |
| 66 | + | ||
| 67 | + | <style> | |
| 68 | + | button { | |
| 69 | + | padding: 0.5rem 1rem; | |
| 70 | + | font-size: 1rem; | |
| 71 | + | cursor: pointer; | |
| 72 | + | } | |
| 73 | + | button:disabled { | |
| 74 | + | cursor: not-allowed; | |
| 75 | + | opacity: 0.7; | |
| 76 | + | } | |
| 77 | + | </style> ⏎̸ | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onDestroy } from 'svelte'; | |||
| 3 | + | ||||
| 4 | + | // Props with defaults: | |||
| 5 | + | // - text: button label when not loading (default "Send") | |||
| 6 | + | // - loading: indicates if the button should show a spinner (default false) | |||
| 7 | + | // - spinnerChar: the character to use for the spinner animation (default '.') | |||
| 8 | + | let { onclick, disabled = $bindable(false), text, loading = $bindable(false), spinnerChar = $bindable('.') } = $props(); | |||
| 9 | + | ||||
| 10 | + | // Using $state for internal reactive variables. | |||
| 11 | + | let animatedDots = $state(''); // will hold the animated spinner text | |||
| 12 | + | let count = $state(1); | |||
| 13 | + | let isDecreasing = $state(true); | |||
| 14 | + | let intervalID = $state<number | null>(null); | |||
| 15 | + | ||||
| 16 | + | // Effect that starts/stops the spinner based on loading state. | |||
| 17 | + | $effect(() => { | |||
| 18 | + | if (loading) { | |||
| 19 | + | console.debug("LoadingButton: Spinner started"); | |||
| 20 | + | // Immediately update spinner text to show 3 dots and start decreasing | |||
| 21 | + | animatedDots = spinnerChar.repeat(3); | |||
| 22 | + | count = 3; | |||
| 23 | + | isDecreasing = true; | |||
| 24 | + | ||||
| 25 | + | if (!intervalID) { | |||
| 26 | + | intervalID = window.setInterval(() => { | |||
| 27 | + | // Ping-pong logic: 3,2,1,2,3... | |||
| 28 | + | count = count + (isDecreasing ? -1 : 1); | |||
| 29 | + | if (count <= 1) { | |||
| 30 | + | count = 1; | |||
| 31 | + | isDecreasing = false; | |||
| 32 | + | } else if (count >= 3) { | |||
| 33 | + | count = 3; | |||
| 34 | + | isDecreasing = true; | |||
| 35 | + | } | |||
| 36 | + | animatedDots = spinnerChar.repeat(count); | |||
| 37 | + | console.debug("LoadingButton: Animated dots updated to", animatedDots); | |||
| 38 | + | }, 500); | |||
| 39 | + | } | |||
| 40 | + | } else { | |||
| 41 | + | console.debug("LoadingButton: Spinner stopped"); | |||
| 42 | + | if (intervalID) { | |||
| 43 | + | clearInterval(intervalID); | |||
| 44 | + | intervalID = null; | |||
| 45 | + | } | |||
| 46 | + | // When not loading, display the default text. | |||
| 47 | + | animatedDots = text; | |||
| 48 | + | } | |||
| 49 | + | }); | |||
| 50 | + | ||||
| 51 | + | onDestroy(() => { | |||
| 52 | + | if (intervalID) { | |||
| 53 | + | console.debug("LoadingButton: onDestroy clearing interval"); | |||
| 54 | + | clearInterval(intervalID); | |||
| 55 | + | } | |||
| 56 | + | }); | |||
| 57 | + | </script> | |||
| 58 | + | ||||
| 59 | + | <button onclick={onclick} disabled={disabled}> | |||
| 60 | + | {#if loading} | |||
| 61 | + | <span>{animatedDots}</span> | |||
| 62 | + | {:else} | |||
| 63 | + | <span>{text}</span> | |||
| 64 | + | {/if} | |||
| 65 | + | </button> | |||
| 66 | + | ||||
| 67 | + | <style> | |||
| 68 | + | button { | |||
| 69 | + | padding: 0.5rem 1rem; | |||
| 70 | + | font-size: 1rem; | |||
| 71 | + | cursor: pointer; | |||
| 72 | + | } | |||
| 73 | + | button:disabled { | |||
| 74 | + | cursor: not-allowed; | |||
| 75 | + | opacity: 0.7; | |||
| 76 | + | } | |||
| 77 | + | </style> ⏎̸ | |||
M src/lib/types/chat.ts +0 -5
| @@ -1,8 +1,3 @@ | |||
| 1 | - | /** | |
| 2 | - | * TypeScript interfaces for an AOL Instant Messenger–like project. | |
| 3 | - | * Each interface represents a table structure in your database. | |
| 4 | - | */ | |
| 5 | - | ||
| 6 | 1 | // Users table: holds authentication and profile details | |
| 7 | 2 | export interface User { | |
| 8 | 3 | id: string; | |
| @@ -1,8 +1,3 @@ | |||||
| 1 | - | /** | |||
| 2 | - | * TypeScript interfaces for an AOL Instant Messenger–like project. | |||
| 3 | - | * Each interface represents a table structure in your database. | |||
| 4 | - | */ | |||
| 5 | - | ||||
| 6 | // Users table: holds authentication and profile details | 1 | // Users table: holds authentication and profile details | ||
| 7 | export interface User { | 2 | export interface User { | ||
| 8 | id: string; | 3 | id: string; | ||
M vite.config.ts +10 -0
| @@ -6,5 +6,15 @@ export default defineConfig({ | |||
| 6 | 6 | ||
| 7 | 7 | test: { | |
| 8 | 8 | include: ['src/**/*.{test,spec}.{js,ts}'] | |
| 9 | + | }, | |
| 10 | + | build: { | |
| 11 | + | minify: 'terser', | |
| 12 | + | terserOptions: { | |
| 13 | + | compress: { | |
| 14 | + | pure_funcs: [ | |
| 15 | + | 'console.debug', | |
| 16 | + | ] | |
| 17 | + | } | |
| 18 | + | }, | |
| 9 | 19 | } | |
| 10 | 20 | }); | |
| @@ -6,5 +6,15 @@ export default defineConfig({ | |||||
| 6 | 6 | ||||
| 7 | test: { | 7 | test: { | ||
| 8 | include: ['src/**/*.{test,spec}.{js,ts}'] | 8 | include: ['src/**/*.{test,spec}.{js,ts}'] | ||
| 9 | + | }, | |||
| 10 | + | build: { | |||
| 11 | + | minify: 'terser', | |||
| 12 | + | terserOptions: { | |||
| 13 | + | compress: { | |||
| 14 | + | pure_funcs: [ | |||
| 15 | + | 'console.debug', | |||
| 16 | + | ] | |||
| 17 | + | } | |||
| 18 | + | }, | |||
| 9 | } | 19 | } | ||
| 10 | }); | 20 | }); | ||