mirror of
https://github.com/h3poteto/whalebird-desktop
synced 2024-12-23 15:37:59 +01:00
Merge pull request #4984 from h3poteto/iss-4792
refs #4792 Implement search
This commit is contained in:
commit
420ad46087
@ -183,6 +183,9 @@
|
||||
"submit": "Submit"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search timeline"
|
||||
"placeholder": "Search timeline",
|
||||
"statuses": "Statuses",
|
||||
"accounts": "Accounts",
|
||||
"hashtags": "Hashtags"
|
||||
}
|
||||
}
|
||||
|
@ -239,6 +239,7 @@ export default function Compose(props: Props) {
|
||||
color="blue-gray"
|
||||
containerProps={{ className: 'mb-2' }}
|
||||
value={spoiler}
|
||||
className="placeholder:opacity-100"
|
||||
onChange={ev => setSpoiler(ev.target.value)}
|
||||
placeholder={formatMessage({ id: 'compose.spoiler.placeholder' })}
|
||||
/>
|
||||
|
@ -256,7 +256,12 @@ export default function Profile(props: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<Tabs value="timeline">
|
||||
<TabsHeader>
|
||||
<TabsHeader
|
||||
indicatorProps={{
|
||||
className:
|
||||
'bg-blue-gray-50 dark:bg-blue-gray-900 border-b-2 border-blue-400 dark:border-blue-600 shadow-none rounded-none'
|
||||
}}
|
||||
>
|
||||
<Tab value="timeline">
|
||||
<FormattedMessage id="profile.timeline" />
|
||||
</Tab>
|
||||
|
132
renderer/components/timelines/Search.tsx
Normal file
132
renderer/components/timelines/Search.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { Input, Tab, TabPanel, Tabs, TabsBody, TabsHeader } from '@material-tailwind/react'
|
||||
import { Entity, MegalodonInterface } from 'megalodon'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { FaMagnifyingGlass } from 'react-icons/fa6'
|
||||
import { FormattedMessage, useIntl } from 'react-intl'
|
||||
import Statuses from './search/Statuses'
|
||||
import { Account } from '@/db'
|
||||
import Accounts from './search/Accounts'
|
||||
import Hashtags from './search/Hashtags'
|
||||
import Detail from '../detail/Detail'
|
||||
|
||||
type Props = {
|
||||
client: MegalodonInterface
|
||||
account: Account
|
||||
openMedia: (media: Array<Entity.Attachment>, index: number) => void
|
||||
}
|
||||
|
||||
export default function Search(props: Props) {
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useIntl()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('statuses')
|
||||
const [results, setResults] = useState<Entity.Results>({
|
||||
accounts: [],
|
||||
hashtags: [],
|
||||
statuses: []
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.q) {
|
||||
setQuery(router.query.q as string)
|
||||
search(router.query.q as string)
|
||||
}
|
||||
}, [router.query.q])
|
||||
|
||||
const search = (query: string) => {
|
||||
setLoading(true)
|
||||
setResults({
|
||||
accounts: [],
|
||||
hashtags: [],
|
||||
statuses: []
|
||||
})
|
||||
props.client
|
||||
.search(query)
|
||||
.then(res => {
|
||||
setResults(res.data)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const submit = (ev: FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault()
|
||||
const word = ((ev.target as HTMLFormElement).elements[0] as HTMLInputElement).value
|
||||
if (word.length > 0) {
|
||||
router.push({
|
||||
query: Object.assign({}, router.query, {
|
||||
timeline: 'search',
|
||||
q: word
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const timelineClass = () => {
|
||||
if (router.query.detail) {
|
||||
return 'timeline-with-drawer'
|
||||
}
|
||||
return 'timeline'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex timeline-wrapper">
|
||||
<section className={`h-full ${timelineClass()}`}>
|
||||
<div className="w-full theme-text-primary p-2 flex justify-center" style={{ height: '56px' }}>
|
||||
<form onSubmit={ev => submit(ev)}>
|
||||
<Input
|
||||
type="text"
|
||||
color="blue-gray"
|
||||
placeholder={formatMessage({ id: 'timeline.search' })}
|
||||
className="placeholder:opacity-100"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
icon={<FaMagnifyingGlass />}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<Tabs value={activeTab} style={{ height: 'calc(100% - 56px)' }}>
|
||||
<TabsHeader
|
||||
indicatorProps={{
|
||||
className: 'bg-blue-gray-50 dark:bg-blue-gray-900 border-b-2 border-blue-400 dark:border-blue-600 shadow-none rounded-none'
|
||||
}}
|
||||
>
|
||||
<Tab value="statuses" onClick={() => setActiveTab('statuses')}>
|
||||
<FormattedMessage id="search.statuses" />
|
||||
</Tab>
|
||||
<Tab value="accounts" onClick={() => setActiveTab('accounts')}>
|
||||
<FormattedMessage id="search.accounts" />
|
||||
</Tab>
|
||||
<Tab value="hashtags" onClick={() => setActiveTab('hashtags')}>
|
||||
<FormattedMessage id="search.hashtags" />
|
||||
</Tab>
|
||||
</TabsHeader>
|
||||
<TabsBody style={{ height: 'calc(100% - 35px)' }}>
|
||||
<TabPanel value="statuses" className="h-full p-0">
|
||||
<Statuses
|
||||
statuses={results.statuses}
|
||||
client={props.client}
|
||||
account={props.account}
|
||||
openMedia={props.openMedia}
|
||||
loading={loading}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value="accounts">
|
||||
<Accounts users={results.accounts} loading={loading} />
|
||||
</TabPanel>
|
||||
<TabPanel value="hashtags">
|
||||
<Hashtags hashtags={results.hashtags} loading={loading} />
|
||||
</TabPanel>
|
||||
</TabsBody>
|
||||
</Tabs>
|
||||
</section>
|
||||
<Detail client={props.client} account={props.account} className="detail" openMedia={props.openMedia} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Account } from '@/db'
|
||||
import { Entity, MegalodonInterface, WebSocketInterface } from 'megalodon'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef, FormEvent } from 'react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import Status from './status/Status'
|
||||
import { FormattedMessage, useIntl } from 'react-intl'
|
||||
@ -244,6 +244,19 @@ export default function Timeline(props: Props) {
|
||||
})
|
||||
}
|
||||
|
||||
const search = (ev: FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault()
|
||||
const word = ((ev.target as HTMLFormElement).elements[0] as HTMLInputElement).value
|
||||
if (word.length > 0) {
|
||||
router.push({
|
||||
query: Object.assign({}, router.query, {
|
||||
timeline: 'search',
|
||||
q: word
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex timeline-wrapper">
|
||||
<section className={`h-full ${timelineClass()}`}>
|
||||
@ -252,14 +265,13 @@ export default function Timeline(props: Props) {
|
||||
{props.timeline.match(/list_(\d+)/) ? <>{list && list.title}</> : <FormattedMessage id={`timeline.${props.timeline}`} />}
|
||||
</div>
|
||||
<div className="w-64 text-xs">
|
||||
<form>
|
||||
<form onSubmit={ev => search(ev)}>
|
||||
<Input
|
||||
type="text"
|
||||
label={formatMessage({ id: 'timeline.search' })}
|
||||
disabled
|
||||
color="blue-gray"
|
||||
placeholder={formatMessage({ id: 'timeline.search' })}
|
||||
containerProps={{ className: 'h-7' }}
|
||||
className="!py-1 !px-2 !text-xs"
|
||||
labelProps={{ className: '!leading-10' }}
|
||||
className="!py-1 !px-2 !text-xs placeholder:opacity-100"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
63
renderer/components/timelines/search/Accounts.tsx
Normal file
63
renderer/components/timelines/search/Accounts.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Avatar, Spinner } from '@material-tailwind/react'
|
||||
import { Entity } from 'megalodon'
|
||||
import emojify from '@/utils/emojify'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
type Props = {
|
||||
users: Array<Entity.Account>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export default function Accounts(props: Props) {
|
||||
const router = useRouter()
|
||||
|
||||
const openUser = (id: string) => {
|
||||
router.push({ query: { id: router.query.id, timeline: router.query.timeline, user_id: id, detail: true } })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-hidden h-full w-full">
|
||||
{props.users.length > 0 ? (
|
||||
<>
|
||||
{props.users.map((user, index) => (
|
||||
<User key={index} user={user} openUser={openUser} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{props.loading && (
|
||||
<div className="py-4">
|
||||
<Spinner className="m-auto" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type UserProps = {
|
||||
user: Entity.Account
|
||||
openUser: (id: string) => void
|
||||
}
|
||||
|
||||
function User(props: UserProps) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 mr-2 py-1">
|
||||
<div className="flex" onClick={() => props.openUser(props.user.id)}>
|
||||
<div className="p2 cursor-pointer" style={{ width: '56px' }}>
|
||||
<Avatar src={props.user.avatar} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className="text-gray-800 dark:text-gray-200"
|
||||
dangerouslySetInnerHTML={{ __html: emojify(props.user.display_name, props.user.emojis) }}
|
||||
/>
|
||||
<p className="text-gray-500">@{props.user.acct}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
60
renderer/components/timelines/search/Hashtags.tsx
Normal file
60
renderer/components/timelines/search/Hashtags.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Spinner } from '@material-tailwind/react'
|
||||
import { Entity } from 'megalodon'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FaHashtag } from 'react-icons/fa6'
|
||||
|
||||
type Props = {
|
||||
hashtags: Array<Entity.Tag>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export default function Hashtags(props: Props) {
|
||||
const router = useRouter()
|
||||
|
||||
const openTag = (tag: string) => {
|
||||
router.push({ query: { id: router.query.id, timeline: router.query.timeline, tag: tag, detail: true } })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-hidden h-full w-full">
|
||||
{props.hashtags.length > 0 ? (
|
||||
<>
|
||||
{props.hashtags.map(hashtag => (
|
||||
<Hashtag key={hashtag.name} hashtag={hashtag} openTag={openTag} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{props.loading && (
|
||||
<div className="py-4">
|
||||
<Spinner className="m-auto" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type HashtagProps = {
|
||||
hashtag: Entity.Tag
|
||||
openTag: (tag: string) => void
|
||||
}
|
||||
|
||||
function Hashtag(props: HashtagProps) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 mr-2 py-1">
|
||||
<div className="flex">
|
||||
<div
|
||||
className="p2 cursor-pointer text-gray-800 dark:text-gray-200 flex items-center gap-1"
|
||||
onClick={() => props.openTag(props.hashtag.name)}
|
||||
>
|
||||
<FaHashtag />
|
||||
{props.hashtag.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
48
renderer/components/timelines/search/Statuses.tsx
Normal file
48
renderer/components/timelines/search/Statuses.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { Entity, MegalodonInterface } from 'megalodon'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import Status from '../status/Status'
|
||||
import { Account } from '@/db'
|
||||
import { Spinner } from '@material-tailwind/react'
|
||||
|
||||
type Props = {
|
||||
client: MegalodonInterface
|
||||
account: Account
|
||||
statuses: Array<Entity.Status>
|
||||
loading: boolean
|
||||
openMedia: (media: Array<Entity.Attachment>, index: number) => void
|
||||
}
|
||||
|
||||
export default function Statuses(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-hidden h-full w-full">
|
||||
{props.statuses.length > 0 ? (
|
||||
<Virtuoso
|
||||
style={{ height: '100%' }}
|
||||
data={props.statuses}
|
||||
className="timeline-scrollable"
|
||||
itemContent={(index, status) => (
|
||||
<Status
|
||||
key={index}
|
||||
client={props.client}
|
||||
account={props.account}
|
||||
status={status}
|
||||
openMedia={props.openMedia}
|
||||
onRefresh={() => {}}
|
||||
filters={[]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{props.loading && (
|
||||
<div className="py-4">
|
||||
<Spinner className="m-auto" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -340,12 +340,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
},
|
||||
tabsHeader: {
|
||||
defaultProps: {
|
||||
className: ''
|
||||
},
|
||||
styles: {
|
||||
base: {
|
||||
bg: 'bg-blue-gray-50 dark:bg-blue-gray-800'
|
||||
}
|
||||
className: 'rounded-none border-b border-blue-gray-50 dark:border-blue-gray-900 bg-transparent p-0'
|
||||
}
|
||||
},
|
||||
tab: {
|
||||
@ -360,9 +355,6 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
initial: {
|
||||
color: 'text-blue-gray-900 dark:text-blue-gray-200'
|
||||
}
|
||||
},
|
||||
indicator: {
|
||||
bg: 'bg-white dark:bg-gray-700'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import { useEffect, useReducer, useState } from 'react'
|
||||
import { Account, db } from '@/db'
|
||||
import generator, { Entity, MegalodonInterface } from 'megalodon'
|
||||
import Notifications from '@/components/timelines/Notifications'
|
||||
import Search from '@/components/timelines/Search'
|
||||
import Media from '@/components/Media'
|
||||
import Report from '@/components/report/Report'
|
||||
|
||||
@ -69,14 +70,26 @@ export default function Page() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Timeline
|
||||
timeline={router.query.timeline as string}
|
||||
account={account}
|
||||
client={client}
|
||||
openMedia={(media: Array<Entity.Attachment>, index: number) =>
|
||||
dispatch({ target: 'media', value: true, object: media, index: index })
|
||||
}
|
||||
/>
|
||||
<>
|
||||
{(router.query.timeline as string) === 'search' ? (
|
||||
<Search
|
||||
client={client}
|
||||
account={account}
|
||||
openMedia={(media: Array<Entity.Attachment>, index: number) =>
|
||||
dispatch({ target: 'media', value: true, object: media, index: index })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Timeline
|
||||
timeline={router.query.timeline as string}
|
||||
account={account}
|
||||
client={client}
|
||||
openMedia={(media: Array<Entity.Attachment>, index: number) =>
|
||||
dispatch({ target: 'media', value: true, object: media, index: index })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Media
|
||||
open={modalState.media.opened}
|
||||
|
Loading…
Reference in New Issue
Block a user