main.rs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. #![allow(non_snake_case, unused)]
  2. use dioxus::prelude::*;
  3. // Define the Hackernews API and types
  4. use chrono::{DateTime, Utc};
  5. use serde::{Deserialize, Serialize};
  6. use std::{
  7. fmt::{Display, Formatter},
  8. num::ParseIntError,
  9. str::FromStr,
  10. };
  11. use svg_attributes::to;
  12. fn main() {
  13. #[cfg(feature = "web")]
  14. tracing_wasm::set_as_global_default();
  15. #[cfg(feature = "server")]
  16. tracing_subscriber::fmt::init();
  17. launch(|| rsx! { Router::<Route> {} });
  18. }
  19. #[derive(Clone, Routable)]
  20. enum Route {
  21. #[redirect("/", || Route::Homepage { story: PreviewState { active_story: None } })]
  22. #[route("/:story")]
  23. Homepage { story: PreviewState },
  24. }
  25. pub fn App() -> Element {
  26. rsx! {
  27. Router::<Route> {}
  28. }
  29. }
  30. #[component]
  31. fn Homepage(story: ReadOnlySignal<PreviewState>) -> Element {
  32. rsx! {
  33. head::Link { rel: "stylesheet", href: asset!("./assets/hackernews.css") }
  34. div { display: "flex", flex_direction: "row", width: "100%",
  35. div {
  36. width: "50%",
  37. SuspenseBoundary {
  38. fallback: |context: SuspenseContext| rsx! {
  39. "Loading..."
  40. },
  41. Stories {}
  42. }
  43. }
  44. div { width: "50%",
  45. SuspenseBoundary {
  46. fallback: |context: SuspenseContext| rsx! {
  47. "Loading preview..."
  48. },
  49. Preview {
  50. story
  51. }
  52. }
  53. }
  54. }
  55. }
  56. }
  57. #[component]
  58. fn Stories() -> Element {
  59. let stories: Resource<dioxus::Result<Vec<i64>>> = use_server_future(move || async move {
  60. let url = format!("{}topstories.json", BASE_API_URL);
  61. let mut stories_ids = reqwest::get(&url).await?.json::<Vec<i64>>().await?;
  62. stories_ids.truncate(30);
  63. Ok(stories_ids)
  64. })?;
  65. match stories().unwrap() {
  66. Ok(list) => rsx! {
  67. div {
  68. for story in list {
  69. ChildrenOrLoading {
  70. key: "{story}",
  71. StoryListing { story }
  72. }
  73. }
  74. }
  75. },
  76. Err(err) => rsx! {"An error occurred while fetching stories {err}"},
  77. }
  78. }
  79. #[component]
  80. fn StoryListing(story: ReadOnlySignal<i64>) -> Element {
  81. let story = use_server_future(move || get_story(story()))?;
  82. let StoryItem {
  83. title,
  84. url,
  85. by,
  86. score,
  87. time,
  88. kids,
  89. id,
  90. ..
  91. } = story().unwrap()?.item;
  92. let url = url.as_deref().unwrap_or_default();
  93. let hostname = url
  94. .trim_start_matches("https://")
  95. .trim_start_matches("http://")
  96. .trim_start_matches("www.");
  97. let score = format!("{score} {}", if score == 1 { " point" } else { " points" });
  98. let comments = format!(
  99. "{} {}",
  100. kids.len(),
  101. if kids.len() == 1 {
  102. " comment"
  103. } else {
  104. " comments"
  105. }
  106. );
  107. let time = time.format("%D %l:%M %p");
  108. rsx! {
  109. div {
  110. padding: "0.5rem",
  111. position: "relative",
  112. div { font_size: "1.5rem",
  113. Link {
  114. to: Route::Homepage { story: PreviewState { active_story: Some(id) } },
  115. "{title}"
  116. }
  117. a {
  118. color: "gray",
  119. href: "https://news.ycombinator.com/from?site={hostname}",
  120. text_decoration: "none",
  121. " ({hostname})"
  122. }
  123. }
  124. div { display: "flex", flex_direction: "row", color: "gray",
  125. div { "{score}" }
  126. div { padding_left: "0.5rem", "by {by}" }
  127. div { padding_left: "0.5rem", "{time}" }
  128. div { padding_left: "0.5rem", "{comments}" }
  129. }
  130. }
  131. }
  132. }
  133. #[derive(Clone, Debug, Default)]
  134. struct PreviewState {
  135. active_story: Option<i64>,
  136. }
  137. impl FromStr for PreviewState {
  138. type Err = ParseIntError;
  139. fn from_str(s: &str) -> Result<Self, Self::Err> {
  140. let state = i64::from_str(s)?;
  141. Ok(PreviewState {
  142. active_story: Some(state),
  143. })
  144. }
  145. }
  146. impl Display for PreviewState {
  147. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
  148. if let Some(id) = &self.active_story {
  149. write!(f, "{id}")?;
  150. }
  151. Ok(())
  152. }
  153. }
  154. #[component]
  155. fn Preview(story: ReadOnlySignal<PreviewState>) -> Element {
  156. let PreviewState {
  157. active_story: Some(id),
  158. } = story()
  159. else {
  160. return rsx! {"Hover over a story to preview it here"};
  161. };
  162. let story = use_server_future(use_reactive!(|id| get_story(id)))?;
  163. let story = story().unwrap()?;
  164. rsx! {
  165. div { padding: "0.5rem",
  166. div { font_size: "1.5rem", a { href: story.item.url, "{story.item.title}" } }
  167. if let Some(text) = &story.item.text { div { dangerous_inner_html: "{text}" } }
  168. for comment in story.item.kids.iter().copied() {
  169. ChildrenOrLoading {
  170. key: "{comment}",
  171. Comment { comment }
  172. }
  173. }
  174. }
  175. }
  176. }
  177. #[component]
  178. fn Comment(comment: i64) -> Element {
  179. let comment: Resource<dioxus::Result<CommentData>> =
  180. use_server_future(use_reactive!(|comment| async move {
  181. let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, comment);
  182. let mut comment = reqwest::get(&url).await?.json::<CommentData>().await?;
  183. Ok(comment)
  184. }))?;
  185. let CommentData {
  186. by,
  187. time,
  188. text,
  189. id,
  190. kids,
  191. ..
  192. } = comment().unwrap()?;
  193. rsx! {
  194. div { padding: "0.5rem",
  195. div { color: "gray", "by {by}" }
  196. div { dangerous_inner_html: "{text}" }
  197. for comment in kids.iter().copied() {
  198. ChildrenOrLoading {
  199. key: "{comment}",
  200. Comment { comment }
  201. }
  202. }
  203. }
  204. }
  205. }
  206. pub static BASE_API_URL: &str = "https://hacker-news.firebaseio.com/v0/";
  207. pub static ITEM_API: &str = "item/";
  208. pub static USER_API: &str = "user/";
  209. const COMMENT_DEPTH: i64 = 1;
  210. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
  211. pub struct StoryPageData {
  212. #[serde(flatten)]
  213. pub item: StoryItem,
  214. #[serde(default)]
  215. pub comments: Vec<CommentData>,
  216. }
  217. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
  218. pub struct CommentData {
  219. pub id: i64,
  220. /// there will be no by field if the comment was deleted
  221. #[serde(default)]
  222. pub by: String,
  223. #[serde(default)]
  224. pub text: String,
  225. #[serde(with = "chrono::serde::ts_seconds")]
  226. pub time: DateTime<Utc>,
  227. #[serde(default)]
  228. pub kids: Vec<i64>,
  229. pub r#type: String,
  230. }
  231. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
  232. pub struct StoryItem {
  233. pub id: i64,
  234. pub title: String,
  235. pub url: Option<String>,
  236. pub text: Option<String>,
  237. #[serde(default)]
  238. pub by: String,
  239. #[serde(default)]
  240. pub score: i64,
  241. #[serde(default)]
  242. pub descendants: i64,
  243. #[serde(with = "chrono::serde::ts_seconds")]
  244. pub time: DateTime<Utc>,
  245. #[serde(default)]
  246. pub kids: Vec<i64>,
  247. pub r#type: String,
  248. }
  249. pub async fn get_story(id: i64) -> dioxus::Result<StoryPageData> {
  250. let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, id);
  251. Ok(reqwest::get(&url).await?.json::<StoryPageData>().await?)
  252. }
  253. #[component]
  254. fn ChildrenOrLoading(children: Element) -> Element {
  255. rsx! {
  256. SuspenseBoundary {
  257. fallback: |context: SuspenseContext| {
  258. rsx! {
  259. if let Some(placeholder) = context.suspense_placeholder() {
  260. {placeholder}
  261. } else {
  262. LoadingIndicator {}
  263. }
  264. }
  265. },
  266. children
  267. }
  268. }
  269. }
  270. fn LoadingIndicator() -> Element {
  271. rsx! {
  272. div {
  273. class: "spinner",
  274. }
  275. }
  276. }