LinksTable.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import formatDistanceToNow from "date-fns/formatDistanceToNow";
  2. import { CopyToClipboard } from "react-copy-to-clipboard";
  3. import React, { FC, useState, useEffect } from "react";
  4. import { useFormState } from "react-use-form-state";
  5. import { Flex } from "reflexbox/styled-components";
  6. import styled, { css } from "styled-components";
  7. import { ifProp } from "styled-tools";
  8. import QRCode from "qrcode.react";
  9. import Link from "next/link";
  10. import { removeProtocol, withComma, errorMessage } from "../utils";
  11. import { useStoreActions, useStoreState } from "../store";
  12. import { Link as LinkType } from "../store/links";
  13. import { Checkbox, TextInput } from "./Input";
  14. import { NavButton, Button } from "./Button";
  15. import Text, { H2, H4, Span } from "./Text";
  16. import { Col, RowCenter } from "./Layout";
  17. import { useMessage } from "../hooks";
  18. import Animation from "./Animation";
  19. import { Colors } from "../consts";
  20. import Tooltip from "./Tooltip";
  21. import Table from "./Table";
  22. import ALink from "./ALink";
  23. import Modal from "./Modal";
  24. import Icon from "./Icon";
  25. const Tr = styled(Flex).attrs({ as: "tr", px: [12, 12, 2] })``;
  26. const Th = styled(Flex)``;
  27. Th.defaultProps = { as: "th", flexBasis: 0, py: [12, 12, 3], px: [12, 12, 3] };
  28. const Td = styled(Flex)<{ withFade?: boolean }>`
  29. position: relative;
  30. white-space: nowrap;
  31. ${ifProp(
  32. "withFade",
  33. css`
  34. :after {
  35. content: "";
  36. position: absolute;
  37. right: 0;
  38. top: 0;
  39. height: 100%;
  40. width: 16px;
  41. background: linear-gradient(to left, white, white, transparent);
  42. }
  43. tr:hover &:after {
  44. background: linear-gradient(
  45. to left,
  46. ${Colors.TableRowHover},
  47. ${Colors.TableRowHover},
  48. transparent
  49. );
  50. }
  51. `
  52. )}
  53. `;
  54. Td.defaultProps = {
  55. as: "td",
  56. fontSize: [15, 16],
  57. alignItems: "center",
  58. flexBasis: 0,
  59. py: [12, 12, 3],
  60. px: [12, 12, 3]
  61. };
  62. const Action = (props: React.ComponentProps<typeof Icon>) => (
  63. <Icon
  64. as="button"
  65. py={0}
  66. px={0}
  67. mr={2}
  68. size={[23, 24]}
  69. p={["4px", "5px"]}
  70. stroke="#666"
  71. {...props}
  72. />
  73. );
  74. const ogLinkFlex = { flexGrow: [1, 3, 7], flexShrink: [1, 3, 7] };
  75. const createdFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
  76. const shortLinkFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
  77. const viewsFlex = {
  78. flexGrow: [0.5, 0.5, 1],
  79. flexShrink: [0.5, 0.5, 1],
  80. justifyContent: "flex-end"
  81. };
  82. const actionsFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
  83. interface RowProps {
  84. index: number;
  85. link: LinkType;
  86. setDeleteModal: (number) => void;
  87. }
  88. interface BanForm {
  89. host: boolean;
  90. user: boolean;
  91. userLinks: boolean;
  92. domain: boolean;
  93. }
  94. const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
  95. const isAdmin = useStoreState(s => s.auth.isAdmin);
  96. const ban = useStoreActions(s => s.links.ban);
  97. const [formState, { checkbox }] = useFormState<BanForm>();
  98. const [copied, setCopied] = useState(false);
  99. const [qrModal, setQRModal] = useState(false);
  100. const [banModal, setBanModal] = useState(false);
  101. const [banLoading, setBanLoading] = useState(false);
  102. const [banMessage, setBanMessage] = useMessage();
  103. const onCopy = () => {
  104. setCopied(true);
  105. setTimeout(() => {
  106. setCopied(false);
  107. }, 1500);
  108. };
  109. const onBan = async () => {
  110. setBanLoading(true);
  111. try {
  112. const res = await ban({ id: link.id, ...formState.values });
  113. setBanMessage(res.message, "green");
  114. setTimeout(() => {
  115. setBanModal(false);
  116. }, 2000);
  117. } catch (err) {
  118. setBanMessage(errorMessage(err));
  119. }
  120. setBanLoading(false);
  121. };
  122. return (
  123. <>
  124. <Tr key={index}>
  125. <Td {...ogLinkFlex} withFade>
  126. <ALink href={link.target}>{link.target}</ALink>
  127. </Td>
  128. <Td {...createdFlex}>{`${formatDistanceToNow(
  129. new Date(link.created_at)
  130. )} ago`}</Td>
  131. <Td {...shortLinkFlex} withFade>
  132. {copied ? (
  133. <Animation
  134. minWidth={32}
  135. offset="10px"
  136. duration="0.2s"
  137. alignItems="center"
  138. >
  139. <Icon
  140. size={[23, 24]}
  141. py={0}
  142. px={0}
  143. mr={2}
  144. p="3px"
  145. name="check"
  146. strokeWidth="3"
  147. stroke={Colors.CheckIcon}
  148. />
  149. </Animation>
  150. ) : (
  151. <Animation minWidth={32} offset="-10px" duration="0.2s">
  152. <CopyToClipboard text={link.link} onCopy={onCopy}>
  153. <Action
  154. name="copy"
  155. strokeWidth="2.5"
  156. stroke={Colors.CopyIcon}
  157. backgroundColor={Colors.CopyIconBg}
  158. />
  159. </CopyToClipboard>
  160. </Animation>
  161. )}
  162. <ALink href={link.link}>{removeProtocol(link.link)}</ALink>
  163. </Td>
  164. <Td {...viewsFlex}>{withComma(link.visit_count)}</Td>
  165. <Td {...actionsFlex} justifyContent="flex-end">
  166. {link.password && (
  167. <>
  168. <Tooltip id={`${index}-tooltip-password`}>
  169. Password protected
  170. </Tooltip>
  171. <Action
  172. as="span"
  173. data-tip
  174. data-for={`${index}-tooltip-password`}
  175. name="key"
  176. stroke={"#bbb"}
  177. strokeWidth="2.5"
  178. backgroundColor="none"
  179. />
  180. </>
  181. )}
  182. {link.banned && (
  183. <>
  184. <Tooltip id={`${index}-tooltip-banned`}>Banned</Tooltip>
  185. <Action
  186. as="span"
  187. data-tip
  188. data-for={`${index}-tooltip-banned`}
  189. name="stop"
  190. stroke="#bbb"
  191. strokeWidth="2.5"
  192. backgroundColor="none"
  193. />
  194. </>
  195. )}
  196. {link.visit_count > 0 && (
  197. <Link href={`/stats?id=${link.id}`}>
  198. <ALink title="View stats" forButton>
  199. <Action
  200. name="pieChart"
  201. stroke={Colors.PieIcon}
  202. strokeWidth="2.5"
  203. backgroundColor={Colors.PieIconBg}
  204. />
  205. </ALink>
  206. </Link>
  207. )}
  208. <Action
  209. name="qrcode"
  210. stroke="none"
  211. fill={Colors.QrCodeIcon}
  212. backgroundColor={Colors.QrCodeIconBg}
  213. onClick={() => setQRModal(true)}
  214. />
  215. {isAdmin && !link.banned && (
  216. <Action
  217. name="stop"
  218. strokeWidth="2"
  219. stroke={Colors.StopIcon}
  220. backgroundColor={Colors.StopIconBg}
  221. onClick={() => setBanModal(true)}
  222. />
  223. )}
  224. <Action
  225. mr={0}
  226. name="trash"
  227. strokeWidth="2"
  228. stroke={Colors.TrashIcon}
  229. backgroundColor={Colors.TrashIconBg}
  230. onClick={() => setDeleteModal(index)}
  231. />
  232. </Td>
  233. </Tr>
  234. <Modal
  235. id="table-qrcode-modal"
  236. minWidth="max-content"
  237. show={qrModal}
  238. closeHandler={() => setQRModal(false)}
  239. >
  240. <RowCenter width={192}>
  241. <QRCode size={192} value={link.link} />
  242. </RowCenter>
  243. </Modal>
  244. <Modal
  245. id="table-ban-modal"
  246. show={banModal}
  247. closeHandler={() => setBanModal(false)}
  248. >
  249. <>
  250. <H2 mb={24} textAlign="center" bold>
  251. Ban link?
  252. </H2>
  253. <Text mb={24} textAlign="center">
  254. Are you sure do you want to ban the link{" "}
  255. <Span bold>"{removeProtocol(link.link)}"</Span>?
  256. </Text>
  257. <RowCenter>
  258. <Checkbox {...checkbox("user")} label="User" mb={12} />
  259. <Checkbox {...checkbox("userLinks")} label="User links" mb={12} />
  260. <Checkbox {...checkbox("host")} label="Host" mb={12} />
  261. <Checkbox {...checkbox("domain")} label="Domain" mb={12} />
  262. </RowCenter>
  263. <Flex justifyContent="center" mt={4}>
  264. {banLoading ? (
  265. <>
  266. <Icon name="spinner" size={20} stroke={Colors.Spinner} />
  267. </>
  268. ) : banMessage.text ? (
  269. <Text fontSize={15} color={banMessage.color}>
  270. {banMessage.text}
  271. </Text>
  272. ) : (
  273. <>
  274. <Button color="gray" mr={3} onClick={() => setBanModal(false)}>
  275. Cancel
  276. </Button>
  277. <Button color="red" ml={3} onClick={onBan}>
  278. <Icon name="stop" stroke="white" mr={2} />
  279. Ban
  280. </Button>
  281. </>
  282. )}
  283. </Flex>
  284. </>
  285. </Modal>
  286. </>
  287. );
  288. };
  289. interface Form {
  290. all: boolean;
  291. limit: string;
  292. skip: string;
  293. search: string;
  294. }
  295. const LinksTable: FC = () => {
  296. const isAdmin = useStoreState(s => s.auth.isAdmin);
  297. const links = useStoreState(s => s.links);
  298. const { get, remove } = useStoreActions(s => s.links);
  299. const [tableMessage, setTableMessage] = useState("No links to show.");
  300. const [deleteModal, setDeleteModal] = useState(-1);
  301. const [deleteLoading, setDeleteLoading] = useState(false);
  302. const [deleteMessage, setDeleteMessage] = useMessage();
  303. const [formState, { label, checkbox, text }] = useFormState<Form>(
  304. { skip: "0", limit: "10", all: false },
  305. { withIds: true }
  306. );
  307. const options = formState.values;
  308. const linkToDelete = links.items[deleteModal];
  309. useEffect(() => {
  310. get(options).catch(err =>
  311. setTableMessage(err?.response?.data?.error || "An error occurred.")
  312. );
  313. }, [options.limit, options.skip, options.all]);
  314. const onSubmit = e => {
  315. e.preventDefault();
  316. get(options);
  317. };
  318. const onDelete = async () => {
  319. setDeleteLoading(true);
  320. try {
  321. await remove(linkToDelete.id);
  322. await get(options);
  323. setDeleteModal(-1);
  324. } catch (err) {
  325. setDeleteMessage(errorMessage(err));
  326. }
  327. setDeleteLoading(false);
  328. };
  329. const onNavChange = (nextPage: number) => () => {
  330. formState.setField("skip", (parseInt(options.skip) + nextPage).toString());
  331. };
  332. const Nav = (
  333. <Th
  334. alignItems="center"
  335. justifyContent="flex-end"
  336. flexGrow={1}
  337. flexShrink={1}
  338. >
  339. <Flex as="ul" m={0} p={0} style={{ listStyle: "none" }}>
  340. {["10", "25", "50"].map(c => (
  341. <Flex key={c} ml={[10, 12]}>
  342. <NavButton
  343. disabled={options.limit === c}
  344. onClick={() => {
  345. formState.setField("limit", c);
  346. formState.setField("skip", "0");
  347. }}
  348. >
  349. {c}
  350. </NavButton>
  351. </Flex>
  352. ))}
  353. </Flex>
  354. <Flex
  355. width="1px"
  356. height={20}
  357. mx={[3, 24]}
  358. style={{ backgroundColor: "#ccc" }}
  359. />
  360. <Flex>
  361. <NavButton
  362. onClick={onNavChange(-parseInt(options.limit))}
  363. disabled={options.skip === "0"}
  364. px={2}
  365. >
  366. <Icon name="chevronLeft" size={15} />
  367. </NavButton>
  368. <NavButton
  369. onClick={onNavChange(parseInt(options.limit))}
  370. disabled={
  371. parseInt(options.skip) + parseInt(options.limit) > links.total
  372. }
  373. ml={12}
  374. px={2}
  375. >
  376. <Icon name="chevronRight" size={15} />
  377. </NavButton>
  378. </Flex>
  379. </Th>
  380. );
  381. return (
  382. <Col width={1200} maxWidth="95%" margin="40px 0 120px" my={6}>
  383. <H2 mb={3} light>
  384. Recent shortened links.
  385. </H2>
  386. <Table scrollWidth="800px">
  387. <thead>
  388. <Tr justifyContent="space-between">
  389. <Th flexGrow={1} flexShrink={1}>
  390. <Flex as="form" onSubmit={onSubmit}>
  391. <TextInput
  392. {...text("search")}
  393. placeholder="Search..."
  394. height={[30, 32]}
  395. placeholderSize={[13, 13, 13, 13]}
  396. fontSize={[14]}
  397. pl={12}
  398. pr={12}
  399. width={[1]}
  400. br="3px"
  401. bbw="2px"
  402. />
  403. {isAdmin && (
  404. <Checkbox
  405. {...label("all")}
  406. {...checkbox("all")}
  407. label="All links"
  408. ml={3}
  409. fontSize={[14, 15]}
  410. width={[15, 16]}
  411. height={[15, 16]}
  412. />
  413. )}
  414. </Flex>
  415. </Th>
  416. {Nav}
  417. </Tr>
  418. <Tr>
  419. <Th {...ogLinkFlex}>Original URL</Th>
  420. <Th {...createdFlex}>Created</Th>
  421. <Th {...shortLinkFlex}>Short URL</Th>
  422. <Th {...viewsFlex}>Views</Th>
  423. <Th {...actionsFlex}></Th>
  424. </Tr>
  425. </thead>
  426. <tbody style={{ opacity: links.loading ? 0.4 : 1 }}>
  427. {!links.items.length ? (
  428. <Tr width={1} justifyContent="center">
  429. <Td flex="1 1 auto" justifyContent="center">
  430. <Text fontSize={18} light>
  431. {links.loading ? "Loading links..." : tableMessage}
  432. </Text>
  433. </Td>
  434. </Tr>
  435. ) : (
  436. <>
  437. {links.items.map((link, index) => (
  438. <Row
  439. setDeleteModal={setDeleteModal}
  440. index={index}
  441. link={link}
  442. />
  443. ))}
  444. </>
  445. )}
  446. </tbody>
  447. <tfoot>
  448. <Tr justifyContent="flex-end">{Nav}</Tr>
  449. </tfoot>
  450. </Table>
  451. <Modal
  452. id="delete-custom-domain"
  453. show={deleteModal > -1}
  454. closeHandler={() => setDeleteModal(-1)}
  455. >
  456. {linkToDelete && (
  457. <>
  458. <H2 mb={24} textAlign="center" bold>
  459. Delete link?
  460. </H2>
  461. <Text textAlign="center">
  462. Are you sure do you want to delete the link{" "}
  463. <Span bold>"{removeProtocol(linkToDelete.link)}"</Span>?
  464. </Text>
  465. <Flex justifyContent="center" mt={44}>
  466. {deleteLoading ? (
  467. <>
  468. <Icon name="spinner" size={20} stroke={Colors.Spinner} />
  469. </>
  470. ) : deleteMessage.text ? (
  471. <Text fontSize={15} color={deleteMessage.color}>
  472. {deleteMessage.text}
  473. </Text>
  474. ) : (
  475. <>
  476. <Button
  477. color="gray"
  478. mr={3}
  479. onClick={() => setDeleteModal(-1)}
  480. >
  481. Cancel
  482. </Button>
  483. <Button color="red" ml={3} onClick={onDelete}>
  484. <Icon name="trash" stroke="white" mr={2} />
  485. Delete
  486. </Button>
  487. </>
  488. )}
  489. </Flex>
  490. </>
  491. )}
  492. </Modal>
  493. </Col>
  494. );
  495. };
  496. export default LinksTable;