mirror of
				https://github.com/Blah-IM/Weblah.git
				synced 2025-10-31 01:51:37 +00:00 
			
		
		
		
	feat: chat detail frame
This commit is contained in:
		
							parent
							
								
									78339cd0b9
								
							
						
					
					
						commit
						793217a2a0
					
				
					 10 changed files with 176 additions and 51 deletions
				
			
		|  | @ -1,8 +1,11 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import { browser } from '$app/environment'; | 	import { browser } from '$app/environment'; | ||||||
| 	import type { Delta } from 'typewriter-editor'; | 	import type { Delta } from 'typewriter-editor'; | ||||||
|  | 	import InputFrame from '$lib/components/InputFrame.svelte'; | ||||||
|  | 	import { tw } from '$lib/tw'; | ||||||
| 
 | 
 | ||||||
| 	export let delta: Delta; | 	export let delta: Delta | null = null; | ||||||
|  | 	export let placeholder: string = ''; | ||||||
| 
 | 
 | ||||||
| 	let className = ''; | 	let className = ''; | ||||||
| 	export { className as class }; | 	export { className as class }; | ||||||
|  | @ -14,6 +17,14 @@ | ||||||
| 	}; | 	}; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| {#await loadClientComponent() then Input} | <InputFrame class={tw('overflow-y-auto', className)}> | ||||||
| 	<svelte:component this={Input} bind:delta class={className}><slot /></svelte:component> | 	{#await loadClientComponent()} | ||||||
|  | 		<div class="rich-text opacity-50"> | ||||||
|  | 			<p>{placeholder}</p> | ||||||
|  | 		</div> | ||||||
|  | 	{:then Input} | ||||||
|  | 		<svelte:component this={Input} bind:delta class={className} {placeholder}> | ||||||
|  | 			<slot /> | ||||||
|  | 		</svelte:component> | ||||||
| 	{/await} | 	{/await} | ||||||
|  | </InputFrame> | ||||||
|  |  | ||||||
|  | @ -1,12 +1,8 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import InputFrame from '$lib/components/InputFrame.svelte'; |  | ||||||
| 	import { tw } from '$lib/tw'; |  | ||||||
| 	import { Delta, Editor, asRoot } from 'typewriter-editor'; | 	import { Delta, Editor, asRoot } from 'typewriter-editor'; | ||||||
| 
 | 
 | ||||||
| 	let className = ''; | 	export let delta: Delta = new Delta(); | ||||||
| 	export { className as class }; | 	export let placeholder: string = ''; | ||||||
| 
 |  | ||||||
| 	export let delta: Delta; |  | ||||||
| 
 | 
 | ||||||
| 	const editor = new Editor(); | 	const editor = new Editor(); | ||||||
| 	delta = editor.getDelta(); | 	delta = editor.getDelta(); | ||||||
|  | @ -17,8 +13,13 @@ | ||||||
| 	$: editor.setDelta(delta); | 	$: editor.setDelta(delta); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <InputFrame class={tw('overflow-y-auto', className)}> | <div | ||||||
| 	<div class="rich-text w-full outline-none" use:asRoot={editor}> | 	class="rich-text relative w-full outline-none before:absolute before:hidden before:leading-tight before:opacity-50 before:content-[attr(data-weblah-placeholder)] data-[weblah-is-empty]:before:block" | ||||||
|  | 	use:asRoot={editor} | ||||||
|  | 	data-weblah-is-empty={!delta || (delta.ops.length === 1 && delta.ops[0].insert === '\n') | ||||||
|  | 		? 'true' | ||||||
|  | 		: undefined} | ||||||
|  | 	data-weblah-placeholder={placeholder} | ||||||
|  | > | ||||||
| 	<slot /> | 	<slot /> | ||||||
| </div> | </div> | ||||||
| </InputFrame> |  | ||||||
|  |  | ||||||
							
								
								
									
										45
									
								
								src/lib/formatters.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/lib/formatters.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | ||||||
|  | const unreadCountFormatter = new Intl.NumberFormat('default', { | ||||||
|  | 	notation: 'compact', | ||||||
|  | 	compactDisplay: 'short' | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export function formatUnreadCount(count: number) { | ||||||
|  | 	return unreadCountFormatter.format(count); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const sameDayFormatter = new Intl.DateTimeFormat('default', { | ||||||
|  | 	hour: '2-digit', | ||||||
|  | 	minute: '2-digit' | ||||||
|  | }); | ||||||
|  | const sameYearFormatter = new Intl.DateTimeFormat('default', { | ||||||
|  | 	month: 'short', | ||||||
|  | 	day: 'numeric' | ||||||
|  | }); | ||||||
|  | const otherYearFormatter = new Intl.DateTimeFormat('default', { | ||||||
|  | 	year: 'numeric', | ||||||
|  | 	month: 'short', | ||||||
|  | 	day: 'numeric' | ||||||
|  | }); | ||||||
|  | const fullDateTimeFormatter = new Intl.DateTimeFormat('default', { | ||||||
|  | 	year: 'numeric', | ||||||
|  | 	month: 'short', | ||||||
|  | 	day: 'numeric', | ||||||
|  | 	hour: '2-digit', | ||||||
|  | 	minute: '2-digit', | ||||||
|  | 	second: '2-digit' | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const formatMessageDate = (date: Date, full: boolean = false) => { | ||||||
|  | 	if (full) return fullDateTimeFormatter.format(date); | ||||||
|  | 
 | ||||||
|  | 	const now = new Date(); | ||||||
|  | 	if (date.getFullYear() === now.getFullYear()) { | ||||||
|  | 		if (date.getMonth() === now.getMonth() && date.getDate() === now.getDate()) { | ||||||
|  | 			return sameDayFormatter.format(date); | ||||||
|  | 		} else { | ||||||
|  | 			return sameYearFormatter.format(date); | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		return otherYearFormatter.format(date); | ||||||
|  | 	} | ||||||
|  | }; | ||||||
							
								
								
									
										6
									
								
								src/lib/types/chat.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/lib/types/chat.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | ||||||
|  | export type Chat = { | ||||||
|  | 	id: string; | ||||||
|  | 	name: string; | ||||||
|  | 	profilePictureUrl?: string; | ||||||
|  | 	type: 'group' | 'peer' | 'channel'; | ||||||
|  | }; | ||||||
							
								
								
									
										1
									
								
								src/lib/types/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/lib/types/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | export * from './chat'; | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|  | 	import { formatMessageDate, formatUnreadCount } from '$lib/formatters'; | ||||||
| 	import { AvatarBeam } from 'svelte-boring-avatars'; | 	import { AvatarBeam } from 'svelte-boring-avatars'; | ||||||
| 
 | 
 | ||||||
| 	export let chat: { | 	export let chat: { | ||||||
|  | @ -7,38 +8,6 @@ | ||||||
| 		lastMessage: { sender: { id: string; name: string }; content: string; date: Date }; | 		lastMessage: { sender: { id: string; name: string }; content: string; date: Date }; | ||||||
| 		unreadCount?: number; | 		unreadCount?: number; | ||||||
| 	}; | 	}; | ||||||
| 
 |  | ||||||
| 	const sameDayFormatter = new Intl.DateTimeFormat('default', { |  | ||||||
| 		hour: '2-digit', |  | ||||||
| 		minute: '2-digit' |  | ||||||
| 	}); |  | ||||||
| 	const sameYearFormatter = new Intl.DateTimeFormat('default', { |  | ||||||
| 		month: 'short', |  | ||||||
| 		day: 'numeric' |  | ||||||
| 	}); |  | ||||||
| 	const otherYearFormatter = new Intl.DateTimeFormat('default', { |  | ||||||
| 		year: 'numeric', |  | ||||||
| 		month: 'short', |  | ||||||
| 		day: 'numeric' |  | ||||||
| 	}); |  | ||||||
| 
 |  | ||||||
| 	const formatDate = (date: Date) => { |  | ||||||
| 		const now = new Date(); |  | ||||||
| 		if (date.getFullYear() === now.getFullYear()) { |  | ||||||
| 			if (date.getMonth() === now.getMonth() && date.getDate() === now.getDate()) { |  | ||||||
| 				return sameDayFormatter.format(date); |  | ||||||
| 			} else { |  | ||||||
| 				return sameYearFormatter.format(date); |  | ||||||
| 			} |  | ||||||
| 		} else { |  | ||||||
| 			return otherYearFormatter.format(date); |  | ||||||
| 		} |  | ||||||
| 	}; |  | ||||||
| 
 |  | ||||||
| 	const unreadCountFormatter = new Intl.NumberFormat('default', { |  | ||||||
| 		notation: 'compact', |  | ||||||
| 		compactDisplay: 'short' |  | ||||||
| 	}); |  | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <li | <li | ||||||
|  | @ -51,7 +20,13 @@ | ||||||
| 		<div class="relative min-w-0 flex-1"> | 		<div class="relative min-w-0 flex-1"> | ||||||
| 			<div class="flex items-center gap-1"> | 			<div class="flex items-center gap-1"> | ||||||
| 				<h3 class="flex-1 truncate text-sm font-semibold">{chat.name}</h3> | 				<h3 class="flex-1 truncate text-sm font-semibold">{chat.name}</h3> | ||||||
| 				<time class="truncate text-xs text-sf-tertiary">{formatDate(chat.lastMessage.date)}</time> | 				<time | ||||||
|  | 					class="truncate text-xs text-sf-tertiary" | ||||||
|  | 					datetime={chat.lastMessage.date.toISOString()} | ||||||
|  | 					title={formatMessageDate(chat.lastMessage.date, true)} | ||||||
|  | 				> | ||||||
|  | 					{formatMessageDate(chat.lastMessage.date)} | ||||||
|  | 				</time> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div class="flex items-end gap-1"> | 			<div class="flex items-end gap-1"> | ||||||
| 				<p class="line-clamp-2 h-[2.5em] text-sm leading-tight text-sf-secondary"> | 				<p class="line-clamp-2 h-[2.5em] text-sm leading-tight text-sf-secondary"> | ||||||
|  | @ -64,7 +39,7 @@ | ||||||
| 					<span | 					<span | ||||||
| 						class="whitespace-nowrap rounded-full bg-slate-400 px-1.5 py-0.5 text-xs text-slate-50 dark:bg-slate-500 dark:text-slate-950" | 						class="whitespace-nowrap rounded-full bg-slate-400 px-1.5 py-0.5 text-xs text-slate-50 dark:bg-slate-500 dark:text-slate-950" | ||||||
| 					> | 					> | ||||||
| 						{unreadCountFormatter.format(chat.unreadCount)} | 						{formatUnreadCount(chat.unreadCount)} | ||||||
| 					</span> | 					</span> | ||||||
| 				{/if} | 				{/if} | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
|  | @ -1,6 +1,15 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import { page } from '$app/stores'; | 	import { page } from '$app/stores'; | ||||||
| 	import Button from '$lib/components/Button.svelte'; | 	import BgPattern from '$lib/components/BgPattern.svelte'; | ||||||
|  | 	import ChatHeader from './ChatHeader.svelte'; | ||||||
|  | 	import ChatInput from './ChatInput.svelte'; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <p><Button href="/">Close</Button> History Page for {$page.params.chatId}</p> | <div class="flex h-full w-full flex-col justify-stretch"> | ||||||
|  | 	<ChatHeader | ||||||
|  | 		chat={{ id: 'blah', name: 'Blah IM Interest Group', type: 'group' }} | ||||||
|  | 		outsideUnreadCount={263723} | ||||||
|  | 	/> | ||||||
|  | 	<BgPattern class="flex-1" pattern="charlieBrown"></BgPattern> | ||||||
|  | 	<ChatInput /> | ||||||
|  | </div> | ||||||
|  |  | ||||||
							
								
								
									
										32
									
								
								src/routes/(app)/chats/[chatId]/ChatHeader.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/routes/(app)/chats/[chatId]/ChatHeader.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  | 	import Button from '$lib/components/Button.svelte'; | ||||||
|  | 	import { formatUnreadCount } from '$lib/formatters'; | ||||||
|  | 	import type { Chat } from '$lib/types'; | ||||||
|  | 	import { AvatarBeam } from 'svelte-boring-avatars'; | ||||||
|  | 
 | ||||||
|  | 	export let chat: Chat; | ||||||
|  | 	export let outsideUnreadCount = 0; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <div class="flex w-full gap-2 border-b border-ss-secondary bg-sb-primary p-2 shadow-sm"> | ||||||
|  | 	<Button href="/" class="rounded-full sm:hidden"> | ||||||
|  | 		<svg | ||||||
|  | 			xmlns="http://www.w3.org/2000/svg" | ||||||
|  | 			fill="none" | ||||||
|  | 			viewBox="0 0 24 24" | ||||||
|  | 			stroke-width="1.5" | ||||||
|  | 			stroke="currentColor" | ||||||
|  | 			class="-me-0.5 -ms-1 size-5" | ||||||
|  | 		> | ||||||
|  | 			<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" /> | ||||||
|  | 		</svg> | ||||||
|  | 		{#if outsideUnreadCount} | ||||||
|  | 			<span class="text-xs text-sf-tertiary">{formatUnreadCount(outsideUnreadCount)}</span> | ||||||
|  | 		{/if} | ||||||
|  | 		<span class="sr-only">Back</span> | ||||||
|  | 	</Button> | ||||||
|  | 	<div class="flex flex-1 flex-col justify-center text-center"> | ||||||
|  | 		<h3 class="truncate text-sm font-semibold">{chat.name}</h3> | ||||||
|  | 	</div> | ||||||
|  | 	<AvatarBeam size={30} name={chat.name} /> | ||||||
|  | </div> | ||||||
							
								
								
									
										43
									
								
								src/routes/(app)/chats/[chatId]/ChatInput.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/routes/(app)/chats/[chatId]/ChatInput.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  | 	import Button from '$lib/components/Button.svelte'; | ||||||
|  | 	import RichTextInput from '$lib/components/RichTextInput.svelte'; | ||||||
|  | 	import type { Delta } from 'typewriter-editor'; | ||||||
|  | 
 | ||||||
|  | 	let delta: Delta | null = null; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <div class="flex items-end gap-2 border-t border-ss-secondary bg-sb-primary p-2 shadow-sm"> | ||||||
|  | 	<Button class="p-1.5"> | ||||||
|  | 		<svg | ||||||
|  | 			xmlns="http://www.w3.org/2000/svg" | ||||||
|  | 			fill="none" | ||||||
|  | 			viewBox="0 0 24 24" | ||||||
|  | 			stroke-width="1.5" | ||||||
|  | 			stroke="currentColor" | ||||||
|  | 			class="size-5" | ||||||
|  | 		> | ||||||
|  | 			<path | ||||||
|  | 				stroke-linecap="round" | ||||||
|  | 				stroke-linejoin="round" | ||||||
|  | 				d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" | ||||||
|  | 			/> | ||||||
|  | 		</svg> | ||||||
|  | 		<span class="sr-only">Attach</span> | ||||||
|  | 	</Button> | ||||||
|  | 	<RichTextInput bind:delta placeholder="Message" class="max-h-40 flex-1" /> | ||||||
|  | 	<Button | ||||||
|  | 		class="before:from-accent-400 before:to-accent-500 relative p-1.5 ring-0 before:absolute before:-inset-px before:rounded-[7px] before:bg-gradient-to-b before:from-40% before:ring-1 before:ring-inset before:ring-black/10" | ||||||
|  | 	> | ||||||
|  | 		<svg | ||||||
|  | 			xmlns="http://www.w3.org/2000/svg" | ||||||
|  | 			viewBox="0 0 24 24" | ||||||
|  | 			fill="currentColor" | ||||||
|  | 			class="z-10 size-5 text-slate-50 drop-shadow-[0_-1px_0_theme(colors.black/0.2)]" | ||||||
|  | 		> | ||||||
|  | 			<path | ||||||
|  | 				d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" | ||||||
|  | 			/> | ||||||
|  | 		</svg> | ||||||
|  | 		<span class="sr-only">Send</span> | ||||||
|  | 	</Button> | ||||||
|  | </div> | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import type { Config } from 'tailwindcss'; | import type { Config } from 'tailwindcss'; | ||||||
|  | import colors from 'tailwindcss/colors'; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
| 	content: ['./src/**/*.{html,js,svelte,ts}'], | 	content: ['./src/**/*.{html,js,svelte,ts}'], | ||||||
|  | @ -14,6 +15,7 @@ export default { | ||||||
| 	theme: { | 	theme: { | ||||||
| 		extend: { | 		extend: { | ||||||
| 			colors: { | 			colors: { | ||||||
|  | 				accent: colors.blue, | ||||||
| 				// Semantic Background
 | 				// Semantic Background
 | ||||||
| 				sb: { | 				sb: { | ||||||
| 					primary: 'var(--weblah-color-sb-primary)', | 					primary: 'var(--weblah-color-sb-primary)', | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Shibo Lyu
						Shibo Lyu