homeman-deno/islands/Dashboard.tsx

278 lines
6 KiB
TypeScript

import { Todo, User, UserWithTodos } from '@homeman/models.ts'
import { Signal, useSignal } from '@preact/signals'
import { TodoList } from '@homeman/islands/TodoList.tsx'
import { Dialog } from '@homeman/components/Dialog.tsx'
import { Label } from '@homeman/components/Label.tsx'
import { Input } from '@homeman/components/Input.tsx'
import { Avatar } from '@homeman/components/Avatar.tsx'
import { Button } from '@homeman/components/Button.tsx'
import { JSX } from 'preact'
import { confetti } from 'https://esm.sh/@tsparticles/confetti@3.0.3'
import { useEffect } from 'preact/hooks'
import { IS_BROWSER } from '$fresh/runtime.ts'
interface Props {
users: Record<string, UserWithTodos>
todos: Record<string, Todo>
unassignedTodos: Todo[]
lastUserIdUpdated: { value: string; versionstamp: string }
lastTodoIdUpdated: { value: string; versionstamp: string }
}
const unassignedUserPlaceholder: User = {
id: '',
createdAt: new Date(),
name: 'Shared',
avatarUrl: 'http://placekitten.com/512/512',
color: '888888',
}
interface UserSelectButtonProps extends JSX.HTMLAttributes<HTMLInputElement> {
user: User
}
let fireworks: HTMLAudioElement | null
if (IS_BROWSER) {
fireworks = new Audio('/fireworks.mp3')
}
function excitement() {
if (IS_BROWSER) {
const count = 200,
defaults = {
origin: { y: 0.7 },
}
if (fireworks) fireworks.play()
// deno-lint-ignore no-inner-declarations
function fire(particleRatio: number, opts: {
spread?: number
startVelocity?: number
decay?: number
scalar?: number
}) {
confetti(
Object.assign({}, defaults, opts, {
particleCount: Math.floor(count * particleRatio),
}),
)
}
fire(0.25, {
spread: 26,
startVelocity: 55,
})
fire(0.2, {
spread: 60,
})
fire(0.35, {
spread: 100,
decay: 0.91,
scalar: 0.8,
})
fire(0.1, {
spread: 120,
startVelocity: 25,
decay: 0.92,
scalar: 1.2,
})
fire(0.1, {
spread: 120,
startVelocity: 45,
})
}
}
function UserSelectButton(
{ user: { id, name, avatarUrl, color }, tabindex, ...props }:
UserSelectButtonProps,
) {
const eid = `assigneeUserId_${id}`
return (
<>
<input
aria-hidden='true'
type='radio'
class='peer sr-only'
id={eid}
name='assigneeUserId'
value={id}
{...props}
/>
<Label
for={eid}
style={`border-color: #${color};`}
tabindex={tabindex}
className='cursor-pointer peer-checked:border-t-2 peer-checked:bg-gray-500/20 hover:bg-gray-500/25 rounded p-2'
role='button'
>
<Avatar
className='mb-2'
src={avatarUrl}
/>
<span
style={`color: #${color};`}
class='font-semibold text-center'
>
{name}
</span>
</Label>
</>
)
}
export default function Dashboard(
props: Props,
) {
const todoAssignUserId: Signal<string | null> = useSignal(null)
const showAddTodoDialog = useSignal(false)
const data = useSignal(props)
const {
// todos,
users,
unassignedTodos,
// lastTodoIdUpdated,
// lastUserIdUpdated,
} = data.value
const reload = async () => {
console.log('reloading...')
const newData = await (await fetch(location.href, {
headers: { accept: 'application/json' },
})).json()
data.value = newData
console.log('new data:', newData)
}
useEffect(() => {
let es = new EventSource('/api/user')
es.addEventListener('message', async (e) => {
console.log('user event stream message:', e)
await reload()
excitement()
})
es.addEventListener('error', async (e) => {
// try and reconnect
console.log('Streaming user events error:', e)
es.close()
const backoff = 10000 + Math.random() * 5000
await new Promise((resolve) => setTimeout(resolve, backoff))
es = new EventSource('/api/user')
})
}, [])
useEffect(() => {
let es = new EventSource('/api/todo')
console.log('Streaming todo events...')
es.addEventListener('message', (e) => {
console.log('todo event from server:', e)
reload().then(excitement)
})
es.addEventListener('error', async (e) => {
// try and reconnect
console.log('Streaming todo events error:', e)
es.close()
const backoff = 10000 + Math.random() * 5000
await new Promise((resolve) => setTimeout(resolve, backoff))
es = new EventSource('/api/todo')
})
}, [])
const unassignedUser: UserWithTodos = {
...unassignedUserPlaceholder,
assignedTodos: unassignedTodos,
}
return (
<main class='flex flex-col'>
<Dialog
headerTitle='Add todo'
show={showAddTodoDialog.value}
onClose={() => showAddTodoDialog.value = false}
>
<form
class='p-4 gap-4 flex flex-col'
action='/api/todo'
method='post'
encType='multipart/form-data'
>
<Label for='description'>
Description
<Input autofocus name='description' />
</Label>
<span>
Assignee
<ul class='flex gap-2'>
<li>
<UserSelectButton
checked={todoAssignUserId.value == null}
tabindex={0}
user={unassignedUser}
/>
</li>
{Object.values(users).map(
(user) => {
return (
<li>
<UserSelectButton
checked={todoAssignUserId.value == user.id}
user={user}
/>
</li>
)
},
)}
</ul>
</span>
<footer class='flex justify-end gap-2'>
<Button
type='button'
onClick={() => showAddTodoDialog.value = false}
>
Cancel
</Button>
<Input type='submit' value='Save' />
</footer>
</form>
</Dialog>
<h1 class='p-5 border-b-2 border-stone-500/20 text-2xl'>
Todos
</h1>
<ul class='p-4 relative flex overflow-x-scroll max-w-screen'>
<li>
<TodoList
onNewButtonClicked={() => {
console.log('shared new')
showAddTodoDialog.value = true
todoAssignUserId.value = null
}}
user={unassignedUser}
/>
</li>
{Object.values(users).map((u) => (
<li>
<TodoList
onNewButtonClicked={() => {
showAddTodoDialog.value = true
todoAssignUserId.value = u.id
}}
user={u}
/>
</li>
))}
</ul>
</main>
)
}