feat: improve task ui

This commit is contained in:
0xzio
2025-05-14 20:01:34 +08:00
parent 08b1bca630
commit 7bacbaed58
23 changed files with 253 additions and 96 deletions

View File

@@ -6,7 +6,6 @@ on:
# - "v*"
branches:
- desktop
- develop
- release
workflow_dispatch:

View File

@@ -22,7 +22,7 @@ export function MobileAddCreationButton({ className }: Props) {
size="icon"
className={cn('size-12 shadow-xl', className)}
onClick={async () => {
const creation = await addCreation(CreationType.PAGE)
const creation = await addCreation({ type: CreationType.PAGE })
store.set(creationIdAtom, creation.id)
}}
>

View File

@@ -18,7 +18,7 @@ export function AddCreationButton({ className }: Props) {
size="icon"
className={cn('hover:bg-foreground/10 size-8 rounded-full', className)}
onClick={() => {
addCreation(CreationType.PAGE)
addCreation({ type: CreationType.PAGE })
}}
>
<PlusIcon size={20} className="" />

View File

@@ -22,13 +22,13 @@ export function PanelLayout({ children }: { children: ReactNode }) {
// useSiteTags()
return (
<SidebarProvider className="">
<AppSidebar className="z-2" />
<AppSidebar className="z-3" />
<SidebarInset className="z-2 relative">
<PanelList />
</SidebarInset>
<div
className="z-1 fixed left-[10%] top-[0px] h-[80vh] w-[100vw] opacity-30 dark:opacity-80"
className="z-1 fixed left-[10%] top-[0px] h-[80vh] w-[100vw] opacity-30 dark:opacity-0"
style={{
filter: 'blur(150px) saturate(150%)',
transform: 'translateZ(0)',

View File

@@ -58,35 +58,36 @@ export function PanelWidget({ panel, index }: Props) {
</div>
<ClosePanelButton panel={panel} />
</PanelHeaderWrapper>
<div
ref={ref}
className="flex-1 overflow-y-auto overflow-x-hidden px-4 pt-8"
>
{widget.type === WidgetType.AI_CHAT && (
<PanelChat panel={panel} index={index} />
)}
{mold?.type === CreationType.NOTE && (
{mold?.type === CreationType.NOTE && (
<div
ref={ref}
className="flex-1 overflow-y-auto overflow-x-hidden px-4 pt-8"
>
<NoteList
panel={panel}
index={index}
mold={mold}
columnCount={columns}
/>
)}
</div>
)}
{mold?.type === CreationType.TASK && (
<TasksList panel={panel} index={index} mold={mold} />
)}
{widget.type === WidgetType.AI_CHAT && (
<PanelChat panel={panel} index={index} />
)}
{mold?.type === CreationType.BOOKMARK && (
<BookmarkList panel={panel} index={index} mold={mold} />
)}
{mold?.type === CreationType.TASK && (
<TasksList panel={panel} index={index} mold={mold} />
)}
{mold?.type === CreationType.ARTICLE && (
<ArticleList panel={panel} index={index} mold={mold} />
)}
</div>
{mold?.type === CreationType.BOOKMARK && (
<BookmarkList panel={panel} index={index} mold={mold} />
)}
{mold?.type === CreationType.ARTICLE && (
<ArticleList panel={panel} index={index} mold={mold} />
)}
</>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import type React from 'react'
import {
memo,
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type Dispatch,
type SetStateAction,
} from 'react'
import type { UseChatHelpers } from '@ai-sdk/react'
import type { Attachment, UIMessage } from 'ai'
import cx from 'classnames'
import equal from 'fast-deep-equal'
import { CogIcon, ExpandIcon, SendHorizonalIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useAddCreation } from '@penx/hooks/useAddCreation'
import { CreationType } from '@penx/types'
import { Button } from '@penx/uikit/button'
import { Textarea } from '@penx/uikit/textarea'
import { cn } from '@penx/utils'
export function TaskInput() {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [focused, setFocused] = useState(false)
const [input, setInput] = useState('')
const addCreation = useAddCreation()
useEffect(() => {
if (textareaRef.current) {
// adjustHeight()
}
}, [])
const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`
}
}
const resetHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = '98px'
}
}
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value)
adjustHeight()
}
const submitForm = useCallback(() => {
const title = input.split('\n').join(',')
addCreation({
type: CreationType.TASK,
title,
isAddPanel: false,
})
setInput('')
}, [input])
return (
<div
className={cn(
'bg-linear-to-r relative flex w-full flex-col gap-4 rounded-xl from-indigo-500 via-purple-500 to-pink-500 p-0.5',
focused && 'shadow-2xl',
)}
>
<Textarea
ref={textareaRef}
placeholder="What's on your mind?"
value={input}
onChange={handleInput}
onFocus={() => {
setFocused(true)
}}
onBlur={() => {
setFocused(false)
}}
className={cx(
'bg-background max-h-[calc(75dvh)] min-h-[24px] resize-none overflow-hidden rounded-xl pb-8 !text-base shadow-md focus-visible:ring-0',
)}
// rows={2}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
submitForm()
}
}}
/>
<div className="text-foreground/60 absolute bottom-2 flex w-fit flex-row items-center justify-start gap-0.5 py-0 pl-2"></div>
<div className="absolute bottom-0 right-0 flex w-fit flex-row justify-end gap-1 p-2">
<SendButton input={input} submitForm={submitForm} />
</div>
</div>
)
}
function PureSendButton({
submitForm,
input,
}: {
submitForm: () => void
input: string
}) {
return (
<Button
size="sm"
className="h-7"
onClick={(event) => {
event.preventDefault()
submitForm()
}}
>
<SendHorizonalIcon size={16} />
</Button>
)
}
const SendButton = memo(PureSendButton, (prevProps, nextProps) => {
if (prevProps.input !== nextProps.input) return false
return true
})

View File

@@ -5,6 +5,7 @@ import { Creation } from '@penx/domain'
import { updateCreation } from '@penx/hooks/useCreation'
import { useCreations } from '@penx/hooks/useCreations'
import { store } from '@penx/store'
import { PanelType } from '@penx/types'
import { Checkbox } from '@penx/uikit/checkbox'
import { cn } from '@penx/utils'
@@ -14,11 +15,22 @@ interface PostItemProps {
export function TaskItem({ creation: creation }: PostItemProps) {
return (
<div className={cn('flex break-inside-avoid flex-col rounded-2xl')}>
<div
className={cn(
'hover:text-brand text-foreground flex cursor-pointer break-inside-avoid flex-col rounded-md py-1 text-lg transition-all hover:font-bold',
)}
onClick={() => {
store.panels.updateMainPanel({
type: PanelType.CREATION,
creationId: creation.id,
})
}}
>
<div className="flex items-center gap-2">
<Checkbox
className="size-5"
checked={creation.checked}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(v) => {
updateCreation({
id: creation.id,
@@ -27,21 +39,11 @@ export function TaskItem({ creation: creation }: PostItemProps) {
}}
/>
<TextareaAutosize
className="dark:placeholder-text-600 placeholder:text-foreground/40 w-full resize-none border-none bg-transparent px-0 text-xl font-bold focus:outline-none focus:ring-0"
placeholder="Title"
defaultValue={creation.title || ''}
autoFocus
onChange={(e) => {
const title = e.target.value
updateCreation({ id: creation.id, title })
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
/>
<div
className={cn(creation.checked && 'text-foreground/60 line-through')}
>
{creation.title || ''}
</div>
</div>
</div>
)

View File

@@ -3,8 +3,11 @@
import { Trans } from '@lingui/react'
import { useQuery } from '@tanstack/react-query'
import { Mold } from '@penx/domain'
import { useCreations } from '@penx/hooks/useCreations'
import { useMolds } from '@penx/hooks/useMolds'
import { api } from '@penx/trpc-client'
import { Panel } from '@penx/types'
import { TaskInput } from './TaskInput'
import { TaskItem } from './TaskItem'
interface PostListProps {
@@ -14,28 +17,36 @@ interface PostListProps {
}
export function TasksList({ mold }: PostListProps) {
const { data: creations = [], isLoading } = useQuery({
queryKey: ['creations', mold.id],
queryFn: async () => {
return api.creation.listCreationsByMold.query({ moldId: mold.id })
},
})
const { creations } = useCreations()
const tasks = creations.filter((item) => item.moldId === mold.id)
if (isLoading) return <div className="text-foreground/60">Loading...</div>
const todoTasks = tasks
.filter((item) => !item.checked)
.sort((a, b) => new Date(b.createdAt).getTime() - a.createdAt.getTime())
if (!creations.length) {
return (
<div className="text-foreground/60">
<Trans id="No creations yet."></Trans>
</div>
)
}
const completedTasks = tasks
.filter((item) => item.checked)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
return (
<div className="flex w-full flex-col gap-2">
{creations.map((post) => {
return <TaskItem key={post.id} creation={post as any} />
})}
<div className="flex flex-1 flex-col">
{!tasks.length && (
<div className="text-foreground/60">
<Trans id="No task yet."></Trans>
</div>
)}
<div className="flex w-full flex-1 overflow-auto px-6 pt-6">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-2">
{[...todoTasks, ...completedTasks].map((item) => {
return <TaskItem key={item.id} creation={item as any} />
})}
</div>
</div>
<div className="px-6 pb-2">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-2">
<TaskInput />
</div>
</div>
</div>
)
}

View File

@@ -20,7 +20,7 @@ import { toast } from 'sonner'
import { useLocalStorage, useWindowSize } from 'usehooks-ts'
import { useAddCreation } from '@penx/hooks/useAddCreation'
import { store } from '@penx/store'
import { PanelType } from '@penx/types'
import { CreationType, PanelType } from '@penx/types'
import { Button } from '@penx/uikit/button'
import { Checkbox } from '@penx/uikit/checkbox'
import { Textarea } from '@penx/uikit/textarea'
@@ -65,7 +65,10 @@ export function QuickInput({ onCancel }: { onCancel?: () => void }) {
type: 'p',
children: [{ text: line }],
}))
addCreation('NOTE', JSON.stringify(slateValue))
addCreation({
type: CreationType.NOTE,
content: JSON.stringify(slateValue),
})
setInput('')
}, [input])

View File

@@ -12,7 +12,7 @@ export const QuickSearchTrigger = ({}: Props) => {
return (
<div className="flex-1">
<div
className="bg-background shadow-xs flex cursor-pointer items-center justify-between rounded-lg px-2 py-2 transition-all hover:shadow-md"
className="bg-background shadow-xs dark:bg-foreground/8 flex cursor-pointer items-center justify-between rounded-lg px-2 py-2 transition-all hover:shadow-md"
onClick={() => {
setOpen(true)
}}

View File

@@ -2,9 +2,9 @@
import React from 'react'
import { PlusIcon } from 'lucide-react'
import { useMolds } from '@penx/hooks/useMolds'
import { Area } from '@penx/db/client'
import { useAddCreation } from '@penx/hooks/useAddCreation'
import { useMolds } from '@penx/hooks/useMolds'
import { Widget } from '@penx/types'
import { Button } from '@penx/uikit/button'
@@ -25,7 +25,7 @@ export function AddChatButton({ widget }: Props) {
e.stopPropagation()
e.preventDefault()
const mold = molds.find((mold) => mold.id === widget.moldId)!
addCreation(mold.type)
addCreation({ type: mold.type })
}}
>
<PlusIcon className="text-muted-foreground pointer-events-none size-4 transition-transform duration-200" />

View File

@@ -31,7 +31,7 @@ export function AddCreationButton({ area, widget }: Props) {
e.stopPropagation()
e.preventDefault()
const mold = molds.find((mold) => mold.id === widget.moldId)!
addCreation(mold.type)
addCreation({ type: mold.type })
}}
>
<PlusIcon

View File

@@ -95,11 +95,7 @@ export const WidgetItem = forwardRef<HTMLDivElement, Props>(
return
}
if (widget.type === WidgetType.AI_CHAT) {
store.panels.openWidgetPanel(widget)
return
}
store.panels.openWidgetPanel(widget)
setVisible(!visible)
}}
>
@@ -132,7 +128,8 @@ export const WidgetItem = forwardRef<HTMLDivElement, Props>(
isDragging && 'bg-foreground/6 opacity-50',
isDragging && 'z-[1000000]',
dragOverlay && 'shadow',
!isMobileApp && 'shadow-2xs rounded-md bg-white dark:bg-[#181818]',
!isMobileApp &&
'shadow-2xs dark:bg-foreground/8 rounded-md bg-white',
)}
{...rest}
>

View File

@@ -56,10 +56,10 @@ export class Area {
}
get createdAt() {
return this.raw.createdAt
return new Date(this.raw.createdAt)
}
get updatedAt() {
return this.raw.updatedAt
return new Date(this.raw.updatedAt)
}
}

View File

@@ -73,10 +73,10 @@ export class Creation {
}
get createdAt() {
return this.raw.createdAt
return new Date(this.raw.createdAt)
}
get updatedAt() {
return this.raw.updatedAt
return new Date(this.raw.updatedAt)
}
}

View File

@@ -24,10 +24,10 @@ export class CreationTag {
}
get createdAt() {
return this.raw.createdAt
return new Date(this.raw.createdAt)
}
get updatedAt() {
return this.raw.updatedAt
return new Date(this.raw.updatedAt)
}
}

View File

@@ -28,10 +28,10 @@ export class Mold {
}
get createdAt() {
return this.raw.createdAt
return new Date(this.raw.createdAt)
}
get updatedAt() {
return this.raw.updatedAt
return new Date(this.raw.updatedAt)
}
}

View File

@@ -43,11 +43,11 @@ export class Node {
}
get createdAt() {
return this.raw.createdAt
return new Date(this.raw.createdAt)
}
get updatedAt() {
return this.raw.updatedAt
return new Date(this.raw.updatedAt)
}
toHash(): string {

View File

@@ -45,10 +45,10 @@ export class Site {
}
get createdAt() {
return this.raw.createdAt
return new Date(this.raw.createdAt)
}
get updatedAt() {
return this.raw.updatedAt
return new Date(this.raw.updatedAt)
}
}

View File

@@ -28,10 +28,10 @@ export class Tag {
}
get createdAt() {
return this.raw.createdAt
return new Date(this.raw.createdAt)
}
get updatedAt() {
return this.raw.updatedAt
return new Date(this.raw.updatedAt)
}
}

View File

@@ -13,12 +13,20 @@ import { CreationStatus, GateType, PanelType } from '@penx/types'
import { uniqueId } from '@penx/unique-id'
import { useMySite } from './useMySite'
export type Input = {
type: string
isAddPanel?: boolean
content?: string
title?: string
}
export function useAddCreation() {
const { molds } = useMolds()
const { site } = useMySite()
return async (type: string, content?: string) => {
const mold = molds.find((mold) => mold.type === type)
return async (input: Input) => {
const { isAddPanel = true } = input
const mold = molds.find((mold) => mold.type === input.type)
const area = store.area.get()
if (!mold) throw new Error('Invalid mold type')
@@ -27,10 +35,10 @@ export function useAddCreation() {
const addCreationInput: AddCreationInput = {
slug: uniqueId(),
siteId: site.id,
title: '',
title: input.title || '',
description: '',
image: '',
content: content || JSON.stringify(editorDefaultValue),
content: input.content || JSON.stringify(editorDefaultValue),
type: mold.type,
moldId: mold.id,
areaId: area.id,
@@ -69,14 +77,16 @@ export function useAddCreation() {
updateCreationState(newCreation)
if (isMobileApp && !content) {
if (isMobileApp && !input.content) {
appEmitter.emit('ROUTE_TO_CREATION', newCreation)
} else {
store.panels.addPanel({
id: uniqueId(),
type: PanelType.CREATION,
creationId: newCreation.id,
})
if (isAddPanel) {
store.panels.addPanel({
id: uniqueId(),
type: PanelType.CREATION,
creationId: newCreation.id,
})
}
}
return newCreation
}

View File

@@ -125,7 +125,9 @@ const debouncedSaveCreation = debounce(persistCreation, 300, {
export async function updateCreation(input: UpdateCreationInput) {
const { id, ...props } = input
const creation = getCreation(input.id)
const creation = getCreation(input.id) || (await localDB.getNode(id))
console.log('=======input:', input, 'creation:', creation)
const newCreation: ICreationNode = {
...creation,
props: { ...creation.props, ...props },

View File

@@ -60,7 +60,7 @@ export class PanelsStore {
let panels = this.get()
let index = panels.findIndex((p) => p.type !== PanelType.WIDGET)
if (index < 0) index = 0
if (index < 0) index = panels.length - 1
if (panel.type === PanelType.CREATION) {
panels = produce(panels, (draft) => {