diff --git a/.gitignore b/.gitignore index 814f57a..0daf454 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ yarn-error.log* .env.development.local .env.test.local .env.production.local - +.env.cloud # vercel .vercel diff --git a/README.md b/README.md index 12fc0d9..ed8c520 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Contributions for the following are very welcome. - [ ] Link Suggestion on 404 - [x] Link Usage Metrics - [x] Number: Usage Total Count - - [ ] Graph: Usage over Time + - [x] Graph: Usage of last 14 days - [ ] Link Ownership - [ ] Link Parameters - [ ] Private Links @@ -196,6 +196,8 @@ AUTH0_COOKIE_SECRET= AUTH0_COOKIE_DOMAIN=localhost AUTH0_REDIRECT_URL=http://localhost:3000/api/callback AUTH0_POST_LOGOUT_REDIRECT_URL=http://localhost:3000 +HOSTNAME=http://localhost:3000 +LOGONAME=go.mydomain.dev EOL docker-compose up @@ -219,6 +221,8 @@ AUTH0_COOKIE_SECRET= AUTH0_COOKIE_DOMAIN=localhost AUTH0_REDIRECT_URL=http://localhost:3000/api/callback AUTH0_POST_LOGOUT_REDIRECT_URL=http://localhost:3000 +HOSTNAME=http://localhost:3000 +LOGONAME=go.mydomain.dev EOL # Environment Variables for the Cloud SQL Proxy diff --git a/components/LinkTable/index.tsx b/components/LinkTable/index.tsx index b640d3b..a00961c 100644 --- a/components/LinkTable/index.tsx +++ b/components/LinkTable/index.tsx @@ -7,8 +7,14 @@ import { DropdownMenu, Icon, } from 'bumbag'; - -import { GetAllLinksQuery } from '../../lib/queries/getAllLinks.graphql'; +import { Sparklines, SparklinesLine } from 'react-sparklines'; +import { compareAsc, eachDayOfInterval, sub } from 'date-fns'; +import { formatWithOptions } from 'date-fns/fp'; +import { nl } from 'date-fns/locale'; +import { + GetAllLinksQuery, + LinkUsageMetric, +} from '../../lib/queries/getAllLinks.graphql'; interface Props { data: GetAllLinksQuery; @@ -20,6 +26,34 @@ interface Props { onEdit: (linkId: string) => void | Promise; } +const dateToString = formatWithOptions({ locale: nl }, 'dd/MM/yyyy'); + +const convertMetricsToLineChartData = ( + linkUsageMetrics: Pick[] +) => { + const days = eachDayOfInterval({ + end: new Date(), + start: sub(new Date(), { + days: 14, + }), + }); + + const dates = [ + ...linkUsageMetrics.map((metric) => new Date(metric.accessedAt)), + ...days, + ].sort(compareAsc); + + const countPerDate = dates.map(dateToString).reduce( + (acc, date) => ({ + ...acc, + [date]: (acc[date] || 0) + 1, + }), + {} as Record + ); + + return Object.values(countPerDate); +}; + export const LinkTable: React.FC = ({ data, isDeleteEnabled, @@ -45,76 +79,89 @@ export const LinkTable: React.FC = ({ Alias Destination - Usage - + + Usage (14 days) + + Actions <> {links?.nodes.map((link) => ( - <> - - {link.alias} - - - {link.url} - - - - {link.linkUsageMetrics.totalCount} - - - - onEdit(link.id)}> - Edit - - { - const url = new URL( - link.alias, - document.location.origin - ).toString(); - onShare(url); - }}> - Share - - onAnalytics(link.id)}> - Analytics - - { - onDelete(link.id); - }}> - Delete - - - }> - - - - - + + {link.alias} + + + {link.url} + + + + + + +
+ + Total Usage: {link.linkUsageMetrics.totalCount} + +
+ + + onEdit(link.id)}> + Edit + + { + const url = new URL( + link.alias, + document.location.origin + ).toString(); + onShare(url); + }}> + Share + + onAnalytics(link.id)}> + Analytics + + { + onDelete(link.id); + }}> + Delete + + + }> + + + +
))}
diff --git a/lib/queries/getAllLinks.graphql b/lib/queries/getAllLinks.graphql index ad481df..058f0a7 100644 --- a/lib/queries/getAllLinks.graphql +++ b/lib/queries/getAllLinks.graphql @@ -6,6 +6,9 @@ query getAllLinks { alias linkUsageMetrics { totalCount + nodes { + accessedAt + } } } } diff --git a/package.json b/package.json index 4965856..398e674 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "apollo-utilities": "^1.3.4", "bumbag": "1.0.0-rc.11", "bumbag-server": "1.0.0-rc.11", + "date-fns": "^2.16.1", "emotion": "^10.0.27", "express": "^4.17.1", "express-jwt": "^6.0.0", @@ -48,6 +49,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-kawaii": "^0.16.0", + "react-sparklines": "^1.7.0", "yup": "^0.29.1" }, "devDependencies": { @@ -60,6 +62,7 @@ "@types/jwt-decode": "^2.2.1", "@types/react": "^16.9.34", "@types/react-dom": "^16.9.7", + "@types/react-sparklines": "^1.7.0", "@types/yup": "^0.29.3", "graphql-let": "0.x", "prettier": "^2.0.5", diff --git a/pages/_app.tsx b/pages/_app.tsx index 7b33563..45c924d 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -11,13 +11,23 @@ import { faShare, faTrashAlt, faChartBar, + faSignOutAlt, } from '@fortawesome/free-solid-svg-icons'; const theme: ThemeConfig = { + modes: { + useSystemColorMode: true, + }, Icon: { iconSets: [ { - icons: [faEdit, faShare, faTrashAlt, faChartBar], + icons: [ + faEdit, + faShare, + faTrashAlt, + faChartBar, + faSignOutAlt, + ], prefix: 'solid-', type: 'font-awesome', }, @@ -30,7 +40,7 @@ export default function App({ Component, pageProps }: AppProps) { return ( - + diff --git a/pages/index.tsx b/pages/index.tsx index 6213e23..094f4df 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -7,13 +7,14 @@ import { Modal, Dialog, Flex, - PageContent, - Card, - Stack, + TopNav, + PageWithHeader, + DropdownMenu, + Avatar, Heading, - Button, Spinner, useToasts, + Container, } from 'bumbag'; import { IClaims } from '../lib/auth'; @@ -24,13 +25,20 @@ import { useCreateLinkMutation } from '../lib/mutations/createLink.graphql'; import { useDeleteLinkMutation } from '../lib/mutations/deleteLink.graphql'; interface Props { + logoname: string; + hostname: string; user: IClaims; grants: { permissions: string[]; }; } -const Index: React.FC = ({ grants }) => { +const Index: React.FC = ({ + user, + logoname, + hostname, + grants, +}) => { const [createLink] = useCreateLinkMutation(); const [deleteLink] = useDeleteLinkMutation(); const { loading, data, refetch } = useGetAllLinksQuery(); @@ -42,129 +50,143 @@ const Index: React.FC = ({ grants }) => { const canDelete = grants.permissions.includes('delete:golinks'); return ( - - - - go.armand1m.dev - - + + + + {logoname} + + + Create + + + + + + window.location.replace('/api/logout') + }> + Logout + + + }> + + + + + + + }> + + {canCreate && ( + + { + try { + await createLink({ + variables: values, + }); + + toasts.success({ + title: 'Link Created', + message: 'Link was successfully created.', + }); + + createLinkModal.hide(); + form.resetForm(); + refetch(); + } catch (e) { + console.error( + 'Failed to create Link, details: ', + e + ); + toasts.danger({ + title: 'Failed to create Link', + message: 'An unexpected error occurred.', + }); + } + }} + /> + + )} + + {loading && ( + + - - - - - {canCreate && ( - <> - - Create - - - { - try { - await createLink({ - variables: values, - }); - - toasts.success({ - title: 'Link Created', - message: 'Link was successfully created.', - }); - - createLinkModal.hide(); - form.resetForm(); - refetch(); - } catch (e) { - console.error( - 'Failed to create Link, details: ', - e - ); - toasts.danger({ - title: 'Failed to create Link', - message: 'An unexpected error occurred.', - }); - } - }} - /> - - - )} - - {loading && ( - - - - )} - - {data !== undefined && data !== null && ( - { - /** Open EditLinkForm with data prefilled. */ - }} - onShare={async (linkUrl) => { - try { - await navigator.clipboard.writeText(linkUrl); - - toasts.success({ - title: 'Link Copied', - message: 'Link is in your clipboard.', - }); - } catch (e) { - console.error( - 'Failed to Copy Link, details: ', - e - ); - toasts.danger({ - title: 'Failed to Copy Link', - message: 'An unexpected error occurred.', - }); - } - }} - onAnalytics={async (_linkId) => { - /** Open Analytics Modal */ - }} - onDelete={async (linkId) => { - try { - await deleteLink({ - variables: { - id: linkId, - }, - }); - - toasts.success({ - title: 'Link Deleted', - message: 'Link was successfully deleted.', - }); - - refetch(); - } catch (e) { - console.error( - 'Failed to delete Link, details: ', - e - ); - toasts.danger({ - title: 'Failed to delete Link', - message: 'An unexpected error occurred.', - }); - } - }} - /> - )} - - - - + )} + + {data !== undefined && data !== null && ( + { + /** Open EditLinkForm with data prefilled. */ + }} + onShare={async (linkUrl) => { + try { + await navigator.clipboard.writeText(linkUrl); + + toasts.success({ + title: 'Link Copied', + message: 'Link is in your clipboard.', + }); + } catch (e) { + console.error('Failed to Copy Link, details: ', e); + toasts.danger({ + title: 'Failed to Copy Link', + message: 'An unexpected error occurred.', + }); + } + }} + onAnalytics={async (_linkId) => { + /** Open Analytics Modal */ + }} + onDelete={async (linkId) => { + try { + await deleteLink({ + variables: { + id: linkId, + }, + }); + + toasts.success({ + title: 'Link Deleted', + message: 'Link was successfully deleted.', + }); + + refetch(); + } catch (e) { + console.error('Failed to delete Link, details: ', e); + toasts.danger({ + title: 'Failed to delete Link', + message: 'An unexpected error occurred.', + }); + } + }} + /> + )} + + ); }; @@ -178,12 +200,16 @@ export const getServerSideProps: GetServerSideProps = async ( const session = await auth0.getSession(request); const grants = await getPermissionsFromSession(session); const user = session?.user; + const logoname = process.env.LOGONAME; + const hostname = process.env.HOSTNAME; if (user) { return { props: { user, grants, + logoname, + hostname, }, }; } diff --git a/yarn.lock b/yarn.lock index a948ac5..9eb56aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2021,6 +2021,13 @@ dependencies: "@types/react" "*" +"@types/react-sparklines@^1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@types/react-sparklines/-/react-sparklines-1.7.0.tgz#f956d0f7b0e746ad445ce1cd250fe81f8a384684" + integrity sha512-Vd+cME7+Yy3kFNhnid9EBIKiyCQ/at8nqDczIs0UYfIB8AtaRJPqekigv02biOsIbQCvxyvIAIjiTKOC+hHNbA== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^16.9.34": version "16.9.43" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.43.tgz#c287f23f6189666ee3bebc2eb8d0f84bcb6cdb6b" @@ -3937,6 +3944,11 @@ date-fns@^1.27.2: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== +date-fns@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.16.1.tgz#05775792c3f3331da812af253e1a935851d3834b" + integrity sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ== + de-indent@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" @@ -7991,7 +8003,7 @@ prop-types-exact@1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@15.7.2, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@15.7.2, prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -8188,6 +8200,13 @@ react-refresh@0.8.3: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-sparklines@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60" + integrity sha512-bJFt9K4c5Z0k44G8KtxIhbG+iyxrKjBZhdW6afP+R7EnIq+iKjbWbEFISrf3WKNFsda+C46XAfnX0StS5fbDcg== + dependencies: + prop-types "^15.5.10" + react@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"