LinksTable.tsx 20 KB

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