feat: chat detail frame

This commit is contained in:
Shibo Lyu 2024-08-30 16:58:03 +08:00
parent 78339cd0b9
commit 793217a2a0
10 changed files with 176 additions and 51 deletions

View file

@ -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>

View file

@ -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
View 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
View 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
View file

@ -0,0 +1 @@
export * from './chat';

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View file

@ -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)',