diff options
-rw-r--r-- | FrontEnd/src/app/App.tsx | 4 | ||||
-rw-r--r-- | FrontEnd/src/app/http/search.ts | 48 | ||||
-rw-r--r-- | FrontEnd/src/app/index.sass | 1 | ||||
-rw-r--r-- | FrontEnd/src/app/views/search/index.tsx | 117 | ||||
-rw-r--r-- | FrontEnd/src/app/views/search/search.sass | 13 |
5 files changed, 183 insertions, 0 deletions
diff --git a/FrontEnd/src/app/App.tsx b/FrontEnd/src/app/App.tsx index 7bd92bf7..0a7513e4 100644 --- a/FrontEnd/src/app/App.tsx +++ b/FrontEnd/src/app/App.tsx @@ -9,6 +9,7 @@ import Settings from "./views/settings"; import About from "./views/about"; import User from "./views/user"; import TimelinePage from "./views/timeline"; +import Search from "./views/search"; import AlertHost from "./views/common/alert/AlertHost"; import { dataStorage } from "./services/common"; @@ -59,6 +60,9 @@ const App: React.FC = () => { <Route path="/users/:username"> <User /> </Route> + <Route path="/search"> + <Search /> + </Route> {user && user.hasAdministrationPermission && ( <Route path="/admin"> <LazyAdmin user={user} /> diff --git a/FrontEnd/src/app/http/search.ts b/FrontEnd/src/app/http/search.ts new file mode 100644 index 00000000..2da9295e --- /dev/null +++ b/FrontEnd/src/app/http/search.ts @@ -0,0 +1,48 @@ +import { + apiBaseUrl, + axios, + convertToNetworkError, + extractResponseData, +} from "./common"; +import { + HttpTimelineInfo, + processRawTimelineInfo, + RawHttpTimelineInfo, +} from "./timeline"; +import { HttpUser } from "./user"; + +export interface IHttpSearchClient { + searchTimelines(query: string): Promise<HttpTimelineInfo[]>; + searchUsers(query: string): Promise<HttpUser[]>; +} + +export class HttpSearchClient implements IHttpSearchClient { + searchTimelines(query: string): Promise<HttpTimelineInfo[]> { + return axios + .get<RawHttpTimelineInfo[]>(`${apiBaseUrl}/search/timelines?q=${query}`) + .then(extractResponseData) + .then((ts) => ts.map(processRawTimelineInfo)) + .catch(convertToNetworkError); + } + + searchUsers(query: string): Promise<HttpUser[]> { + return axios + .get<HttpUser[]>(`${apiBaseUrl}/search/users?q=${query}`) + .then(extractResponseData) + .catch(convertToNetworkError); + } +} + +let client: IHttpSearchClient = new HttpSearchClient(); + +export function getHttpSearchClient(): IHttpSearchClient { + return client; +} + +export function setHttpSearchClient( + newClient: IHttpSearchClient +): IHttpSearchClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass index 85c2bcdc..137cc7ff 100644 --- a/FrontEnd/src/app/index.sass +++ b/FrontEnd/src/app/index.sass @@ -10,6 +10,7 @@ @import './views/timeline-common/timeline-common'
@import './views/timeline/timeline'
@import './views/user/user'
+@import './views/search/search'
@import './views/admin/admin'
diff --git a/FrontEnd/src/app/views/search/index.tsx b/FrontEnd/src/app/views/search/index.tsx new file mode 100644 index 00000000..3cd3da58 --- /dev/null +++ b/FrontEnd/src/app/views/search/index.tsx @@ -0,0 +1,117 @@ +import { TimelineInfo } from "@/services/timeline"; +import React from "react"; +import { Container, Row } from "react-bootstrap"; +import { useHistory, useLocation } from "react-router"; +import { Link } from "react-router-dom"; + +import { getHttpSearchClient } from "@/http/search"; + +import SearchInput from "../common/SearchInput"; +import { HttpNetworkError } from "@/http/common"; +import { useAvatar } from "@/services/user"; +import BlobImage from "../common/BlobImage"; + +const TimelineSearchResultItemView: React.FC<{ timeline: TimelineInfo }> = ({ + timeline, +}) => { + const link = timeline.name.startsWith("@") + ? `users/${timeline.owner.username}` + : `timelines/${timeline.name}`; + + const avatar = useAvatar(timeline.owner.username); + + return ( + <div className="timeline-search-result-item my-2 p-3"> + <h4> + <Link to={link} className="mb-2 text-primary"> + {timeline.title} + <small className="ml-3 text-secondary">{timeline.name}</small> + </Link> + </h4> + <div> + <BlobImage + blob={avatar} + className="timeline-search-result-item-avatar mr-2" + /> + {timeline.owner.nickname} + <small className="ml-3 text-secondary"> + @{timeline.owner.username} + </small> + </div> + </div> + ); +}; + +const SearchPage: React.FC = () => { + const history = useHistory(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const queryParam = searchParams.get("q"); + + const [searchText, setSearchText] = React.useState<string>(""); + const [state, setState] = React.useState< + TimelineInfo[] | "init" | "loading" | "network-error" | "error" + >("init"); + + React.useEffect(() => { + if (queryParam != null && queryParam.length > 0) { + setSearchText(queryParam); + setState("loading"); + void getHttpSearchClient() + .searchTimelines(queryParam) + .then( + (ts) => { + setState(ts); + }, + (e) => { + if (e instanceof HttpNetworkError) { + setState("network-error"); + } else { + setState("error"); + } + } + ); + } + }, [queryParam]); + + return ( + <Container className="my-3"> + <Row className="justify-content-center"> + <SearchInput + className="col-12 col-sm-9 col-md-6" + value={searchText} + onChange={setSearchText} + loading={state === "loading"} + onButtonClick={() => { + if (searchText.length > 0) { + history.push(`/search?q=${searchText}`); + } + }} + /> + </Row> + {(() => { + switch (state) { + case "init": { + return "Input something and search!"; + } + case "loading": { + return "Loading!"; + } + case "network-error": { + return "Network error!"; + } + case "error": { + return "Unknown error!"; + } + default: { + return state.map((t) => ( + <TimelineSearchResultItemView key={t.name} timeline={t} /> + )); + } + } + })()} + </Container> + ); +}; + +export default SearchPage; diff --git a/FrontEnd/src/app/views/search/search.sass b/FrontEnd/src/app/views/search/search.sass new file mode 100644 index 00000000..83f297fe --- /dev/null +++ b/FrontEnd/src/app/views/search/search.sass @@ -0,0 +1,13 @@ +.timeline-search-result-item
+ @extend .rounded
+ border: 1px solid
+ border-color: $gray-200
+ background: $gray-100
+ transition: all 0.3s
+ &:hover
+ border-color: $primary
+
+.timeline-search-result-item-avatar
+ width: 2em
+ height: 2em
+ border-radius: 50%
|