LinksTable.tsx 20 KB

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