Components.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. /**
  2. * standalone components that can be used from wherever
  3. */
  4. import * as DateFns from "date-fns/fp";
  5. import { ArticlePreview, Comment, User } from "./Db";
  6. import { Url, url } from "./Routes";
  7. const formatDate = DateFns.format("MMMM do");
  8. export function FormErrors({ errors }: { errors: string[] }) {
  9. return (
  10. <ul class="error-messages">
  11. {errors.map((error) => (
  12. <li>{error}</li>
  13. ))}
  14. </ul>
  15. );
  16. }
  17. export function Avatar({ user }: { user: User }) {
  18. return (
  19. <Link url={["GET /profile", { id: user.id }]}>
  20. <img src={user.avatar ?? undefined} class="comment-author-img" />
  21. </Link>
  22. );
  23. }
  24. export function Link(props: JSX.Element & { url: Url }) {
  25. return (
  26. <a href={url(props.url)} hx-target="main#app-root">
  27. {props.children}
  28. </a>
  29. );
  30. }
  31. export function ButtonThatIsActuallyALink({ children }: JSX.Element) {
  32. return (
  33. <button hx-target="main#app-root" hx-push-url="true">
  34. {children}
  35. </button>
  36. );
  37. }
  38. export function Shell({
  39. children,
  40. user,
  41. currentUrl,
  42. }: JSX.Element & { user: User | undefined; currentUrl: string }) {
  43. const htmxVersion = "1.9.4";
  44. const NavLink = (props: JSX.Element & { url: Url }) => (
  45. <Link
  46. class={`nav-link ${currentUrl !== url(props.url) ? "" : "active"}`}
  47. url={props.url}
  48. >
  49. {props.children}
  50. </Link>
  51. );
  52. return (
  53. <html _="on load set global page to location.pathname + location.search then send navigation to <a[hx-target='main#app-root']/>">
  54. <head>
  55. <base href="/" />
  56. <title>Conduit</title>
  57. <link
  58. href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
  59. rel="stylesheet"
  60. type="text/css"
  61. />
  62. <link
  63. href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
  64. rel="stylesheet"
  65. type="text/css"
  66. />
  67. <link rel="stylesheet" href="//demo.productionready.io/main.css" />
  68. <script
  69. src={`https://unpkg.com/htmx.org@${htmxVersion}`}
  70. integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
  71. crossOrigin="anonymous"
  72. defer
  73. ></script>
  74. <script src="https://unpkg.com/hyperscript.org@0.9.11" defer></script>
  75. </head>
  76. <body
  77. hx-boost="true"
  78. class="hx-indicator"
  79. _="
  80. on htmx:beforeRequest add .htmx-request to <.htmx-indicator/> in me end
  81. on htmx:afterRequest remove .htmx-request from <.htmx-indicator/> in me end"
  82. >
  83. <div
  84. class="htmx-indicator"
  85. style={{
  86. position: "fixed",
  87. zIndex: "999",
  88. top: "0",
  89. left: "0",
  90. height: "100vh",
  91. width: "100vw",
  92. pointerEvents: "none",
  93. backdropFilter: "blur(2px)",
  94. background: "rgba(255, 255, 255, 0.5)",
  95. }}
  96. ></div>
  97. <nav class="navbar navbar-light">
  98. <div class="container">
  99. <Link class="navbar-brand" url={["GET /"]}>
  100. conduit
  101. </Link>
  102. <ul class="nav navbar-nav pull-xs-right">
  103. <li class="nav-item">
  104. <NavLink url={["GET /"]}>Home</NavLink>
  105. </li>
  106. {user == null ? (
  107. <>
  108. <li class="nav-item">
  109. <NavLink url={["GET /login"]}>Sign in</NavLink>
  110. </li>
  111. <li class="nav-item">
  112. <NavLink url={["GET /register"]}>Sign up</NavLink>
  113. </li>
  114. </>
  115. ) : (
  116. <>
  117. <li class="nav-item">
  118. <NavLink url={["GET /article/editor", {}]}>
  119. New Article
  120. </NavLink>
  121. </li>
  122. <li class="nav-item">
  123. <NavLink url={["GET /profile/settings"]}>Settings</NavLink>
  124. </li>
  125. </>
  126. )}
  127. </ul>
  128. </div>
  129. </nav>
  130. <main
  131. id="app-root"
  132. style={{
  133. ...({
  134. "view-transition-name": "card",
  135. } as unknown as CSSStyleDeclaration),
  136. }}
  137. >
  138. {children}
  139. </main>
  140. <footer>
  141. <div class="container">
  142. <Link url={["GET /"]} class="logo-font">
  143. conduit
  144. </Link>
  145. <span class="attribution">
  146. An interactive learning project from{" "}
  147. <a href="https://thinkster.io">Thinkster</a>. Code &amp; design
  148. licensed under MIT.
  149. </span>
  150. </div>
  151. </footer>
  152. </body>
  153. </html>
  154. );
  155. }
  156. export function ArticlePreview({ article }: { article: ArticlePreview }) {
  157. return (
  158. <div class="article-preview">
  159. <div class="article-meta">
  160. <Avatar user={article.author} />
  161. <div class="info">
  162. <Link
  163. url={["GET /profile", { id: article.author.id }]}
  164. class="author"
  165. >
  166. {article.author.username}
  167. </Link>
  168. <span class="date">{formatDate(new Date(article.createdAt))}</span>
  169. </div>
  170. <button
  171. hx-post={url(["POST /favorite", { id: article.id }])}
  172. hx-target="find .counter"
  173. class="btn btn-outline-primary btn-sm pull-xs-right"
  174. >
  175. <i class="ion-heart"></i>{" "}
  176. <span class="counter">{article.favoritedBy.length}</span>
  177. </button>
  178. </div>
  179. <Link url={["GET /article", { id: article.id }]} class="preview-link">
  180. <h1>{article.title}</h1>
  181. <p>{article.description}</p>
  182. <span>Read more...</span>
  183. <ul class="tag-list">
  184. {article.tags.map((tag) => (
  185. <li class="tag-default tag-pill tag-outline">{tag.name}</li>
  186. ))}
  187. </ul>
  188. </Link>
  189. </div>
  190. );
  191. }
  192. export function Comment({
  193. comment,
  194. currentUser,
  195. }: {
  196. comment: Comment & { author: User };
  197. currentUser: User | undefined;
  198. }) {
  199. return (
  200. <div class="card">
  201. <div class="card-block">
  202. <p class="card-text">{comment.content}</p>
  203. </div>
  204. <div class="card-footer">
  205. <Avatar user={comment.author} />
  206. &nbsp;
  207. <Link
  208. url={["GET /profile", { id: comment.author.id }]}
  209. class="comment-author"
  210. >
  211. {comment.author.username}
  212. </Link>
  213. <span class="date-posted">
  214. {formatDate(new Date(comment.createdAt))}
  215. </span>
  216. {currentUser?.id !== comment.author.id ? undefined : (
  217. <span class="mod-options">
  218. <i class="ion-trash-a"></i>
  219. </span>
  220. )}
  221. </div>
  222. </div>
  223. );
  224. }
  225. export function Feed({
  226. articles,
  227. pagination: { page, size },
  228. getPaginationUrl,
  229. }: {
  230. articles: ArticlePreview[];
  231. pagination: { page: number; size: number };
  232. getPaginationUrl: (index: number) => Url;
  233. }) {
  234. return (
  235. <article>
  236. {articles.map((article) => (
  237. <ArticlePreview article={article} />
  238. ))}
  239. <ul class="pagination">
  240. {Array.from({
  241. length: Math.ceil(articles.length / size),
  242. }).map((_, index) => (
  243. <li class={`page-item ${index === page ? "active" : ""}`}>
  244. <a
  245. hx-get={url(getPaginationUrl(index))}
  246. hx-target="closest article"
  247. hx-swap="outerHTML"
  248. class={`page-link ${index !== page ? "" : "active"}`}
  249. >
  250. {index + 1}
  251. </a>
  252. </li>
  253. ))}
  254. </ul>
  255. </article>
  256. );
  257. }