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