LinksTable.tsx 21 KB

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