LinksTable.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  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 { Col, RowCenter } from "./Layout";
  16. import Text, { H2, Span } from "./Text";
  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 EditContent = styled(Col)`
  63. border-bottom: 1px solid ${Colors.TableHeadBorder};
  64. background-color: #fafafa;
  65. `;
  66. const Action = (props: React.ComponentProps<typeof Icon>) => (
  67. <Icon
  68. as="button"
  69. py={0}
  70. px={0}
  71. mr={2}
  72. size={[23, 24]}
  73. p={["4px", "5px"]}
  74. stroke="#666"
  75. {...props}
  76. />
  77. );
  78. const ogLinkFlex = { flexGrow: [1, 3, 7], flexShrink: [1, 3, 7] };
  79. const createdFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
  80. const shortLinkFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
  81. const viewsFlex = {
  82. flexGrow: [0.5, 0.5, 1],
  83. flexShrink: [0.5, 0.5, 1],
  84. justifyContent: "flex-end"
  85. };
  86. const actionsFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
  87. interface RowProps {
  88. index: number;
  89. link: LinkType;
  90. setDeleteModal: (number) => void;
  91. }
  92. interface BanForm {
  93. host: boolean;
  94. user: boolean;
  95. userLinks: boolean;
  96. domain: boolean;
  97. }
  98. interface EditForm {
  99. target: string;
  100. address: string;
  101. }
  102. const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
  103. const isAdmin = useStoreState(s => s.auth.isAdmin);
  104. const ban = useStoreActions(s => s.links.ban);
  105. const edit = useStoreActions(s => s.links.edit);
  106. const [banFormState, { checkbox }] = useFormState<BanForm>();
  107. const [editFormState, { text, label }] = useFormState<EditForm>(
  108. {
  109. target: link.target,
  110. address: link.address
  111. },
  112. { withIds: true }
  113. );
  114. const [copied, setCopied] = useState(false);
  115. const [showEdit, setShowEdit] = useState(false);
  116. const [qrModal, setQRModal] = useState(false);
  117. const [banModal, setBanModal] = useState(false);
  118. const [banLoading, setBanLoading] = useState(false);
  119. const [banMessage, setBanMessage] = useMessage();
  120. const [editLoading, setEditLoading] = useState(false);
  121. const [editMessage, setEditMessage] = useMessage();
  122. const onCopy = () => {
  123. setCopied(true);
  124. setTimeout(() => {
  125. setCopied(false);
  126. }, 1500);
  127. };
  128. const onBan = async () => {
  129. setBanLoading(true);
  130. try {
  131. const res = await ban({ id: link.id, ...banFormState.values });
  132. setBanMessage(res.message, "green");
  133. setTimeout(() => {
  134. setBanModal(false);
  135. }, 2000);
  136. } catch (err) {
  137. setBanMessage(errorMessage(err));
  138. }
  139. setBanLoading(false);
  140. };
  141. const onEdit = async e => {
  142. e.preventDefault();
  143. if (editLoading) return;
  144. setEditLoading(true);
  145. try {
  146. await edit({ id: link.id, ...editFormState.values });
  147. setShowEdit(false);
  148. } catch (err) {
  149. setEditMessage(errorMessage(err));
  150. }
  151. setEditLoading(false);
  152. };
  153. const toggleEdit = () => {
  154. setShowEdit(s => !s);
  155. if (showEdit) editFormState.reset();
  156. setEditMessage("");
  157. };
  158. return (
  159. <>
  160. <Tr key={link.id}>
  161. <Td {...ogLinkFlex} withFade>
  162. <ALink href={link.target}>{link.target}</ALink>
  163. </Td>
  164. <Td {...createdFlex}>{`${formatDistanceToNow(
  165. new Date(link.created_at)
  166. )} ago`}</Td>
  167. <Td {...shortLinkFlex} withFade>
  168. {copied ? (
  169. <Animation
  170. minWidth={32}
  171. offset="10px"
  172. duration="0.2s"
  173. alignItems="center"
  174. >
  175. <Icon
  176. size={[23, 24]}
  177. py={0}
  178. px={0}
  179. mr={2}
  180. p="3px"
  181. name="check"
  182. strokeWidth="3"
  183. stroke={Colors.CheckIcon}
  184. />
  185. </Animation>
  186. ) : (
  187. <Animation minWidth={32} offset="-10px" duration="0.2s">
  188. <CopyToClipboard text={link.link} onCopy={onCopy}>
  189. <Action
  190. name="copy"
  191. strokeWidth="2.5"
  192. stroke={Colors.CopyIcon}
  193. backgroundColor={Colors.CopyIconBg}
  194. />
  195. </CopyToClipboard>
  196. </Animation>
  197. )}
  198. <ALink href={link.link}>{removeProtocol(link.link)}</ALink>
  199. </Td>
  200. <Td {...viewsFlex}>{withComma(link.visit_count)}</Td>
  201. <Td {...actionsFlex} justifyContent="flex-end">
  202. {link.password && (
  203. <>
  204. <Tooltip id={`${index}-tooltip-password`}>
  205. Password protected
  206. </Tooltip>
  207. <Action
  208. as="span"
  209. data-tip
  210. data-for={`${index}-tooltip-password`}
  211. name="key"
  212. stroke={"#bbb"}
  213. strokeWidth="2.5"
  214. backgroundColor="none"
  215. />
  216. </>
  217. )}
  218. {link.banned && (
  219. <>
  220. <Tooltip id={`${index}-tooltip-banned`}>Banned</Tooltip>
  221. <Action
  222. as="span"
  223. data-tip
  224. data-for={`${index}-tooltip-banned`}
  225. name="stop"
  226. stroke="#bbb"
  227. strokeWidth="2.5"
  228. backgroundColor="none"
  229. />
  230. </>
  231. )}
  232. {link.visit_count > 0 && (
  233. <Link href={`/stats?id=${link.id}`}>
  234. <ALink title="View stats" forButton>
  235. <Action
  236. name="pieChart"
  237. stroke={Colors.PieIcon}
  238. strokeWidth="2.5"
  239. backgroundColor={Colors.PieIconBg}
  240. />
  241. </ALink>
  242. </Link>
  243. )}
  244. <Action
  245. name="qrcode"
  246. stroke="none"
  247. fill={Colors.QrCodeIcon}
  248. backgroundColor={Colors.QrCodeIconBg}
  249. onClick={() => setQRModal(true)}
  250. />
  251. <Action
  252. name="editAlt"
  253. strokeWidth="2.5"
  254. stroke={Colors.EditIcon}
  255. backgroundColor={Colors.EditIconBg}
  256. onClick={toggleEdit}
  257. />
  258. {isAdmin && !link.banned && (
  259. <Action
  260. name="stop"
  261. strokeWidth="2"
  262. stroke={Colors.StopIcon}
  263. backgroundColor={Colors.StopIconBg}
  264. onClick={() => setBanModal(true)}
  265. />
  266. )}
  267. <Action
  268. mr={0}
  269. name="trash"
  270. strokeWidth="2"
  271. stroke={Colors.TrashIcon}
  272. backgroundColor={Colors.TrashIconBg}
  273. onClick={() => setDeleteModal(index)}
  274. />
  275. </Td>
  276. </Tr>
  277. {showEdit && (
  278. <EditContent px={[3, 3, 24]} py={[3, 3, 24]}>
  279. <Col as="form" alignItems="flex-start" onSubmit={onEdit}>
  280. <Flex alignItems="flex-start">
  281. <Col alignItems="flex-start" mr={[0, 3, 3]}>
  282. <Text
  283. {...label("target")}
  284. as="label"
  285. mb={2}
  286. fontSize={[14, 15]}
  287. bold
  288. >
  289. Target:
  290. </Text>
  291. <Flex as="form">
  292. <TextInput
  293. {...text("target")}
  294. placeholder="Target..."
  295. placeholderSize={[13, 14]}
  296. fontSize={[14, 15]}
  297. height={[40, 44]}
  298. width={[1, 300, 420]}
  299. pl={[3, 24]}
  300. pr={[3, 24]}
  301. required
  302. />
  303. </Flex>
  304. </Col>
  305. <Col alignItems="flex-start">
  306. <Text
  307. {...label("address")}
  308. as="label"
  309. mb={2}
  310. fontSize={[14, 15]}
  311. bold
  312. >
  313. {link.domain || process.env.DEFAULT_DOMAIN}/
  314. </Text>
  315. <Flex as="form">
  316. <TextInput
  317. {...text("address")}
  318. placeholder="Custom address..."
  319. placeholderSize={[13, 14]}
  320. fontSize={[14, 15]}
  321. height={[40, 44]}
  322. width={[1, 210, 240]}
  323. pl={[3, 24]}
  324. pr={[3, 24]}
  325. required
  326. />
  327. </Flex>
  328. </Col>
  329. </Flex>
  330. <Button
  331. color="blue"
  332. mt={3}
  333. height={[30, 38]}
  334. disabled={editLoading}
  335. >
  336. <Icon
  337. name={editLoading ? "spinner" : "refresh"}
  338. stroke="white"
  339. mr={2}
  340. />
  341. {editLoading ? "Updating..." : "Update"}
  342. </Button>
  343. {editMessage.text && (
  344. <Text mt={3} fontSize={15} color={editMessage.color}>
  345. {editMessage.text}
  346. </Text>
  347. )}
  348. </Col>
  349. </EditContent>
  350. )}
  351. <Modal
  352. id="table-qrcode-modal"
  353. minWidth="max-content"
  354. show={qrModal}
  355. closeHandler={() => setQRModal(false)}
  356. >
  357. <RowCenter width={192}>
  358. <QRCode size={192} value={link.link} />
  359. </RowCenter>
  360. </Modal>
  361. <Modal
  362. id="table-ban-modal"
  363. show={banModal}
  364. closeHandler={() => setBanModal(false)}
  365. >
  366. <>
  367. <H2 mb={24} textAlign="center" bold>
  368. Ban link?
  369. </H2>
  370. <Text mb={24} textAlign="center">
  371. Are you sure do you want to ban the link{" "}
  372. <Span bold>"{removeProtocol(link.link)}"</Span>?
  373. </Text>
  374. <RowCenter>
  375. <Checkbox {...checkbox("user")} label="User" mb={12} />
  376. <Checkbox {...checkbox("userLinks")} label="User links" mb={12} />
  377. <Checkbox {...checkbox("host")} label="Host" mb={12} />
  378. <Checkbox {...checkbox("domain")} label="Domain" mb={12} />
  379. </RowCenter>
  380. <Flex justifyContent="center" mt={4}>
  381. {banLoading ? (
  382. <>
  383. <Icon name="spinner" size={20} stroke={Colors.Spinner} />
  384. </>
  385. ) : banMessage.text ? (
  386. <Text fontSize={15} color={banMessage.color}>
  387. {banMessage.text}
  388. </Text>
  389. ) : (
  390. <>
  391. <Button color="gray" mr={3} onClick={() => setBanModal(false)}>
  392. Cancel
  393. </Button>
  394. <Button color="red" ml={3} onClick={onBan}>
  395. <Icon name="stop" stroke="white" mr={2} />
  396. Ban
  397. </Button>
  398. </>
  399. )}
  400. </Flex>
  401. </>
  402. </Modal>
  403. </>
  404. );
  405. };
  406. interface Form {
  407. all: boolean;
  408. limit: string;
  409. skip: string;
  410. search: string;
  411. }
  412. const LinksTable: FC = () => {
  413. const isAdmin = useStoreState(s => s.auth.isAdmin);
  414. const links = useStoreState(s => s.links);
  415. const { get, remove } = useStoreActions(s => s.links);
  416. const [tableMessage, setTableMessage] = useState("No links to show.");
  417. const [deleteModal, setDeleteModal] = useState(-1);
  418. const [deleteLoading, setDeleteLoading] = useState(false);
  419. const [deleteMessage, setDeleteMessage] = useMessage();
  420. const [formState, { label, checkbox, text }] = useFormState<Form>(
  421. { skip: "0", limit: "10", all: false },
  422. { withIds: true }
  423. );
  424. const options = formState.values;
  425. const linkToDelete = links.items[deleteModal];
  426. useEffect(() => {
  427. get(options).catch(err =>
  428. setTableMessage(err?.response?.data?.error || "An error occurred.")
  429. );
  430. }, [options.limit, options.skip, options.all]);
  431. const onSubmit = e => {
  432. e.preventDefault();
  433. get(options);
  434. };
  435. const onDelete = async () => {
  436. setDeleteLoading(true);
  437. try {
  438. await remove(linkToDelete.id);
  439. await get(options);
  440. setDeleteModal(-1);
  441. } catch (err) {
  442. setDeleteMessage(errorMessage(err));
  443. }
  444. setDeleteLoading(false);
  445. };
  446. const onNavChange = (nextPage: number) => () => {
  447. formState.setField("skip", (parseInt(options.skip) + nextPage).toString());
  448. };
  449. const Nav = (
  450. <Th
  451. alignItems="center"
  452. justifyContent="flex-end"
  453. flexGrow={1}
  454. flexShrink={1}
  455. >
  456. <Flex as="ul" m={0} p={0} style={{ listStyle: "none" }}>
  457. {["10", "25", "50"].map(c => (
  458. <Flex key={c} ml={[10, 12]}>
  459. <NavButton
  460. disabled={options.limit === c}
  461. onClick={() => {
  462. formState.setField("limit", c);
  463. formState.setField("skip", "0");
  464. }}
  465. >
  466. {c}
  467. </NavButton>
  468. </Flex>
  469. ))}
  470. </Flex>
  471. <Flex
  472. width="1px"
  473. height={20}
  474. mx={[3, 24]}
  475. style={{ backgroundColor: "#ccc" }}
  476. />
  477. <Flex>
  478. <NavButton
  479. onClick={onNavChange(-parseInt(options.limit))}
  480. disabled={options.skip === "0"}
  481. px={2}
  482. >
  483. <Icon name="chevronLeft" size={15} />
  484. </NavButton>
  485. <NavButton
  486. onClick={onNavChange(parseInt(options.limit))}
  487. disabled={
  488. parseInt(options.skip) + parseInt(options.limit) > links.total
  489. }
  490. ml={12}
  491. px={2}
  492. >
  493. <Icon name="chevronRight" size={15} />
  494. </NavButton>
  495. </Flex>
  496. </Th>
  497. );
  498. return (
  499. <Col width={1200} maxWidth="95%" margin="40px 0 120px" my={6}>
  500. <H2 mb={3} light>
  501. Recent shortened links.
  502. </H2>
  503. <Table scrollWidth="800px">
  504. <thead>
  505. <Tr justifyContent="space-between">
  506. <Th flexGrow={1} flexShrink={1}>
  507. <Flex as="form" onSubmit={onSubmit}>
  508. <TextInput
  509. {...text("search")}
  510. placeholder="Search..."
  511. height={[30, 32]}
  512. placeholderSize={[13, 13, 13, 13]}
  513. fontSize={[14]}
  514. pl={12}
  515. pr={12}
  516. width={[1]}
  517. br="3px"
  518. bbw="2px"
  519. />
  520. {isAdmin && (
  521. <Checkbox
  522. {...label("all")}
  523. {...checkbox("all")}
  524. label="All links"
  525. ml={3}
  526. fontSize={[14, 15]}
  527. width={[15, 16]}
  528. height={[15, 16]}
  529. />
  530. )}
  531. </Flex>
  532. </Th>
  533. {Nav}
  534. </Tr>
  535. <Tr>
  536. <Th {...ogLinkFlex}>Original URL</Th>
  537. <Th {...createdFlex}>Created</Th>
  538. <Th {...shortLinkFlex}>Short URL</Th>
  539. <Th {...viewsFlex}>Views</Th>
  540. <Th {...actionsFlex}></Th>
  541. </Tr>
  542. </thead>
  543. <tbody style={{ opacity: links.loading ? 0.4 : 1 }}>
  544. {!links.items.length ? (
  545. <Tr width={1} justifyContent="center">
  546. <Td flex="1 1 auto" justifyContent="center">
  547. <Text fontSize={18} light>
  548. {links.loading ? "Loading links..." : tableMessage}
  549. </Text>
  550. </Td>
  551. </Tr>
  552. ) : (
  553. <>
  554. {links.items.map((link, index) => (
  555. <Row
  556. setDeleteModal={setDeleteModal}
  557. index={index}
  558. link={link}
  559. />
  560. ))}
  561. </>
  562. )}
  563. </tbody>
  564. <tfoot>
  565. <Tr justifyContent="flex-end">{Nav}</Tr>
  566. </tfoot>
  567. </Table>
  568. <Modal
  569. id="delete-custom-domain"
  570. show={deleteModal > -1}
  571. closeHandler={() => setDeleteModal(-1)}
  572. >
  573. {linkToDelete && (
  574. <>
  575. <H2 mb={24} textAlign="center" bold>
  576. Delete link?
  577. </H2>
  578. <Text textAlign="center">
  579. Are you sure do you want to delete the link{" "}
  580. <Span bold>"{removeProtocol(linkToDelete.link)}"</Span>?
  581. </Text>
  582. <Flex justifyContent="center" mt={44}>
  583. {deleteLoading ? (
  584. <>
  585. <Icon name="spinner" size={20} stroke={Colors.Spinner} />
  586. </>
  587. ) : deleteMessage.text ? (
  588. <Text fontSize={15} color={deleteMessage.color}>
  589. {deleteMessage.text}
  590. </Text>
  591. ) : (
  592. <>
  593. <Button
  594. color="gray"
  595. mr={3}
  596. onClick={() => setDeleteModal(-1)}
  597. >
  598. Cancel
  599. </Button>
  600. <Button color="red" ml={3} onClick={onDelete}>
  601. <Icon name="trash" stroke="white" mr={2} />
  602. Delete
  603. </Button>
  604. </>
  605. )}
  606. </Flex>
  607. </>
  608. )}
  609. </Modal>
  610. </Col>
  611. );
  612. };
  613. export default LinksTable;