diff --git a/.eslintrc b/.eslintrc index 2028951..c2cacaf 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,7 +11,8 @@ ], "env": { "browser": true, - "node": true + "node": true, + "es6": true }, "settings": { "react": { diff --git a/components/Admin/Context/forms.js b/components/Admin/Context/forms.js new file mode 100644 index 0000000..a0060db --- /dev/null +++ b/components/Admin/Context/forms.js @@ -0,0 +1,28 @@ +import { useContext, createContext } from 'react'; + +import API from '../../../utils/api'; + +export const FormsContext = createContext(); + +export const reducer = async (forms, action) => { + const { type, slug, form, id, forms: newForms } = action; + let response; + + switch (type) { + case 'INIT': + return newForms; + case 'CREATE': + response = await API.post('/forms', form); + return [...forms, response.data.data]; + case 'UPDATE': + response = await API.put(`/forms/${slug}`, form); + return forms.map((item) => (item._id === id ? { ...item, ...response.data.data } : item)); + case 'DELETE': + await API.delete(`/forms/${slug}`); + return forms.filter((form) => form.slug !== slug); + default: + throw new Error(`Unknown action: ${type}`); + } +}; + +export const useForms = () => useContext(FormsContext); diff --git a/components/Admin/Context/index.js b/components/Admin/Context/index.js new file mode 100644 index 0000000..210a148 --- /dev/null +++ b/components/Admin/Context/index.js @@ -0,0 +1,19 @@ +import useAsyncReducer from '../../../utils/useAsyncReducer'; +import { reducer as reducerLinks, LinksContext } from './links'; +import { reducer as reducerForms, FormsContext } from './forms'; + +export const AdminContextProvider = ({ children, initialState }) => { + const [links, dispatchLinks] = useAsyncReducer(reducerLinks, initialState); + const [forms, dispatchForms] = useAsyncReducer(reducerForms, initialState); + + return ( + + + {children} + + + ); +}; + +export { useLinks } from './links'; +export { useForms } from './forms'; diff --git a/components/Admin/Context.js b/components/Admin/Context/links.js similarity index 69% rename from components/Admin/Context.js rename to components/Admin/Context/links.js index c7d364b..8e1849b 100644 --- a/components/Admin/Context.js +++ b/components/Admin/Context/links.js @@ -1,12 +1,11 @@ import { useContext, createContext } from 'react'; -import useAsyncReducer from '../../utils/useAsyncReducer'; import { arrayMove } from 'react-sortable-hoc'; -import API from '../../utils/api'; +import API from '../../../utils/api'; -const LinksContext = createContext(); +export const LinksContext = createContext(); -const reducer = async (links, action) => { +export const reducer = async (links, action) => { const { type, link, links: newLinks, oldIndex, newIndex } = action; let response; @@ -35,10 +34,4 @@ const reducer = async (links, action) => { } }; -export const AdminContextProvider = ({ children, initialState }) => { - const [links, dispatch] = useAsyncReducer(reducer, initialState); - - return {children}; -}; - export const useLinks = () => useContext(LinksContext); diff --git a/components/Admin/FormsTable/Actions.js b/components/Admin/FormsTable/Actions.js new file mode 100644 index 0000000..11785ed --- /dev/null +++ b/components/Admin/FormsTable/Actions.js @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { Button, Popconfirm, Space, notification } from 'antd'; +import { CloseOutlined, DeleteOutlined, EditOutlined, SaveOutlined } from '@ant-design/icons'; +import { useForms } from '../Context'; +import { useEditing } from './Context'; + +function Actions({ record }) { + const [isVisible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + const { forms, dispatch: dispatchForms } = useForms(); + const { editing, dispatch: dispatchEditing } = useEditing(); + + const edit = (record) => { + editing.form.setFieldsValue({ + name: '', + slug: '', + url: '', + ...record + }); + dispatchEditing({ type: 'EDIT', key: record._id }); + }; + + const save = async (id) => { + const row = await editing.form.validateFields(); + if (forms.some((elem) => elem.slug === row.slug && row.slug !== record.slug)) { + notification['error']({ + message: 'Invalid fields', + description: 'Slug already exists' + }); + } else { + dispatchForms({ type: 'UPDATE', form: row, slug: record.slug, id }); + dispatchEditing({ type: 'CANCEL' }); + } + }; + + const confirmDelete = () => { + setLoading(true); + dispatchForms({ type: 'DELETE', slug: record.slug }); + setVisible(false); + setLoading(false); + }; + + return record._id === editing.key ? ( + + + + + ) : ( + + + setVisible(false)}> + + + + ); +} + +export default Actions; diff --git a/components/Admin/FormsTable/Context/index.js b/components/Admin/FormsTable/Context/index.js new file mode 100644 index 0000000..e7b5e00 --- /dev/null +++ b/components/Admin/FormsTable/Context/index.js @@ -0,0 +1,20 @@ +import { useContext, createContext } from 'react'; + +export const EditingContext = createContext(); + +export const reducer = async (editing, action) => { + // {key: '', + // form: Form.useForm()} + const { type, key } = action; + + switch (type) { + case 'EDIT': + return { ...editing, key }; + case 'CANCEL': + return { ...editing, key: '' }; + default: + throw new Error(`Unknown action: ${type}`); + } +}; + +export const useEditing = () => useContext(EditingContext); diff --git a/components/Admin/FormsTable/NewForm.js b/components/Admin/FormsTable/NewForm.js new file mode 100644 index 0000000..a140386 --- /dev/null +++ b/components/Admin/FormsTable/NewForm.js @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { useForms } from '../Context'; +import { Modal, Tooltip, Button, Input, Space, Form } from 'antd'; +import { QuestionCircleOutlined } from '@ant-design/icons'; + +const { Item } = Form; + +function NewForm() { + const { forms, dispatch } = useForms(); + const [isVisible, setVisible] = useState(false); + const [form] = Form.useForm(); + + const ok = () => { + form.submit(); + setVisible(false); + }; + + return ( + <> + + setVisible(false)}> +
dispatch({ type: 'CREATE', form: values }) && form.resetFields()}> + + Name + + + + + } + rules={[ + { + required: true, + message: 'Please insert a name.' + } + ]}> + + + { + if (forms.some((elem) => elem.slug === value)) { + return Promise.reject(new Error('Slug already exists')); + } else { + Promise.resolve(); + } + } + } + ]}> + + + + + +
+
+ + ); +} + +export default NewForm; diff --git a/components/Admin/FormsTable/index.js b/components/Admin/FormsTable/index.js new file mode 100644 index 0000000..65edab3 --- /dev/null +++ b/components/Admin/FormsTable/index.js @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { Form, Typography, notification } from 'antd'; +import { DateTime } from 'luxon'; +import useAsyncReducer from '../../../utils/useAsyncReducer'; +import { EditingContext, reducer as reducerEditing } from './Context'; +import EditableTable from '../../EditableTable'; +import Actions from './Actions'; +import NewForm from './NewForm'; + +import API from '../../../utils/api'; +import { useForms } from '../Context'; + +function FormsTable() { + const [loading, setLoading] = useState(true); + const { forms, dispatch } = useForms(); + const [form] = Form.useForm(); + const [editing, dispatchEditing] = useAsyncReducer(reducerEditing, { key: '', form: form }); + + const isEditing = (record) => record._id === editing.key; + + useEffect(() => { + API.get('/forms') + .then((response) => { + dispatch({ type: 'INIT', forms: response.data.data }); + setLoading(false); + }) + .catch((error) => { + notification['error']({ + message: `${error.statusText}`, + description: error.message + }); + }); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, []); + + const columns = [ + { + title: 'Name', + editable: true, + required: false, + width: 300, + dataIndex: 'name' + }, + { + title: 'Slug', + editable: true, + width: 175, + dataIndex: 'slug' + }, + { + title: 'URL', + editable: true, + width: 500, + dataIndex: 'url', + render: function Url(url) { + return {url}; + } + }, + { + title: 'Link', + editable: false, + width: 500, + dataIndex: 'slug', + render: function UrlLink(slug) { + const link = `${process.env.NEXT_PUBLIC_APP_URL}/f/${slug}`; + + return ( + + {link} + + ); + } + }, + { + title: 'Visits', + editable: false, + align: 'center', + dataIndex: 'visits' + }, + { + title: 'Last edited', + editable: false, + dataIndex: 'updated', + render: function Updated(updated) { + const formatted = DateTime.fromISO(updated) + .toRelative(Date.now()) + .toLocaleString(DateTime.DATETIME_MED); + return {formatted}; + } + }, + { + title: 'Actions', + fixed: 'right', + render: function Action(_, record) { + return ; + } + } + ]; + + return ( + + dispatchEditing({ type: 'CANCEL' }), + position: ['bottomCenter'] + }} + footer={() => } + /> + + ); +} + +export default FormsTable; diff --git a/components/Admin/LinksTable/NewEntry.js b/components/Admin/LinksTable/NewLink.js similarity index 81% rename from components/Admin/LinksTable/NewEntry.js rename to components/Admin/LinksTable/NewLink.js index 17d953b..ea48966 100644 --- a/components/Admin/LinksTable/NewEntry.js +++ b/components/Admin/LinksTable/NewLink.js @@ -5,7 +5,7 @@ import { QuestionCircleOutlined } from '@ant-design/icons'; const { Item } = Form; -function NewEntry() { +function NewLink() { const { dispatch } = useLinks(); const [isVisible, setVisible] = useState(false); const [form] = Form.useForm(); @@ -21,7 +21,9 @@ function NewEntry() { New setVisible(false)}> -
dispatch({ type: 'CREATE', link: values })}> + dispatch({ type: 'CREATE', link: values }) && form.resetFields()}> @@ -51,7 +53,11 @@ function NewEntry() { rules={[ { required: true, - message: 'Please insert a URL' + message: 'Please insert a url.' + }, + { + type: 'url', + message: 'This field must be a valid url.' } ]}> @@ -68,4 +74,4 @@ function NewEntry() { ); } -export default NewEntry; +export default NewLink; diff --git a/components/Admin/LinksTable/index.js b/components/Admin/LinksTable/index.js index 6e4a19d..3b8a597 100644 --- a/components/Admin/LinksTable/index.js +++ b/components/Admin/LinksTable/index.js @@ -5,7 +5,7 @@ import { Table, Checkbox, notification } from 'antd'; import { Twemoji } from 'react-emoji-render'; import { sortableContainer, sortableElement, sortableHandle } from 'react-sortable-hoc'; import Actions from './Actions'; -import NewEntry from './NewEntry'; +import NewLink from './NewLink'; import API from '../../../utils/api'; import styles from './style.module.css'; @@ -105,7 +105,7 @@ function LinksTable() { }) .catch((error) => { notification['error']({ - message: `${error.response.statusText}`, + message: `${error.statusText}`, description: error.message }); }); @@ -119,7 +119,6 @@ function LinksTable() { columns={columns} dataSource={links} bordered - // pagination={{ position: ['bottomCenter'] }} pagination={false} components={{ body: { @@ -127,7 +126,7 @@ function LinksTable() { row: DraggableBodyRow } }} - footer={() => } + footer={() => } /> ); } diff --git a/components/Admin/Navbar/index.js b/components/Admin/Navbar/index.js index 67adcbc..dfb389c 100644 --- a/components/Admin/Navbar/index.js +++ b/components/Admin/Navbar/index.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { Avatar, Dropdown, Menu } from 'antd'; -import { LinkOutlined, UserOutlined } from '@ant-design/icons'; +import LinkTo from '../../utils/LinkTo'; +import { Avatar, Menu, Typography, Space } from 'antd'; +import { LinkOutlined, FormOutlined, UserOutlined } from '@ant-design/icons'; import API from '../../../utils/api'; @@ -11,6 +11,10 @@ export const navbar = { links: { icon: , title: 'Links' + }, + forms: { + icon: , + title: 'Forms' } }; @@ -25,26 +29,21 @@ function Navbar({ selected }) { {Object.keys(navbar).map((key) => ( - - {navbar[key].title} - + {navbar[key].title} ))} - - - - logout - - - + title={ + + } /> + {user.name} + }> -
- } /> {user.name} -
- + + logout + + ); } diff --git a/components/Admin/Navbar/style.module.css b/components/Admin/Navbar/style.module.css index 646acfa..8586455 100644 --- a/components/Admin/Navbar/style.module.css +++ b/components/Admin/Navbar/style.module.css @@ -1,5 +1,3 @@ .avatar { float: right; - margin-right: 10px; - cursor: pointer; } diff --git a/components/EditableTable/EditableCell.js b/components/EditableTable/EditableCell.js new file mode 100644 index 0000000..17f56d4 --- /dev/null +++ b/components/EditableTable/EditableCell.js @@ -0,0 +1,37 @@ +import { Input, InputNumber, Form } from 'antd'; + +const EditableCell = ({ + editing, + dataIndex, + required = true, + title, + inputType = 'text', + children, + ...restProps +}) => { + const inputNode = inputType === 'number' ? : ; + + return ( + + {editing ? ( + + {inputNode} + + ) : ( + children + )} + + ); +}; + +export default EditableCell; diff --git a/components/EditableTable/index.js b/components/EditableTable/index.js new file mode 100644 index 0000000..688df75 --- /dev/null +++ b/components/EditableTable/index.js @@ -0,0 +1,37 @@ +import { Table, Form } from 'antd'; +import EditableCell from './EditableCell'; + +const EditableTable = ({ form, isEditing, columns, ...props }) => { + const mergedColumns = columns.map((col) => { + if (!col.editable) { + return col; + } + + return { + ...col, + onCell: (record) => ({ + record, + inputType: col.inputType || 'text', + dataIndex: col.dataIndex, + title: col.title, + editing: isEditing(record) + }) + }; + }); + + return ( + + + + ); +}; + +export default EditableTable; diff --git a/components/utils/LinkTo.js b/components/utils/LinkTo.js new file mode 100644 index 0000000..f3f825e --- /dev/null +++ b/components/utils/LinkTo.js @@ -0,0 +1,29 @@ +// Created by Zack Sheppard (@zackdotcomputer) on 1/19/2021 +// Freely available under MIT License +// Workaround for https://github.com/vercel/next.js/issues/5533 +import Link from 'next/link'; + +/// A unified component for the next/link and a standard anchor. +/// Will lift href and all other props from NextLinkProps up to the Link. +/// Will automatically make an tag containing the children and pass it remaining props. +const LinkTo = ({ + children, + href, + as, + replace, + scroll, + shallow, + prefetch, + locale, + ...anchorProps +}) => { + return ( + // These props are lifted up to the `Link` element. All others are passed to the `` + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + {children} + + ); +}; + +export default LinkTo; diff --git a/data/forms.yml b/data/forms.yml deleted file mode 100644 index d438de1..0000000 --- a/data/forms.yml +++ /dev/null @@ -1,7 +0,0 @@ -sócios: https://forms.gle/bEaDxAV6e9KrzRZq5 -socios: https://forms.gle/bEaDxAV6e9KrzRZq5 -material: https://forms.gle/yFYeesnBfaDKgoDK9 -recrutamento: https://forms.gle/peu496UmkFqy4sm9A -feedback: https://form.jotform.com/210226896149360 -dezembro-solidario: https://forms.gle/4MD9DoY9crz4V6va6 -dezembrosolidario: https://forms.gle/4MD9DoY9crz4V6va6 diff --git a/models/Form.js b/models/Form.js new file mode 100644 index 0000000..b4c8a8f --- /dev/null +++ b/models/Form.js @@ -0,0 +1,12 @@ +import mongoose, { Schema } from 'mongoose'; + +const Form = new Schema({ + name: { type: String, required: true }, + slug: { type: String, unique: true, index: true, required: true }, + url: { type: String, required: true }, + visits: { type: Number, default: 0 }, + created: { type: Date, default: Date.now }, + updated: { type: Date, default: Date.now } +}); + +export default mongoose.models.Form || mongoose.model('Form', Form); diff --git a/package-lock.json b/package-lock.json index c27794d..3442b80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "cesium.link", - "version": "2.0.0", + "version": "2.0.0-beta", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "2.0.0", + "version": "2.0.0-beta", "dependencies": { "@ant-design/icons": "^4.4.0", "@auth0/nextjs-auth0": "^1.1.0", "animate.css": "^4.1.1", "antd": "^4.12.2", + "luxon": "^1.26.0", "mongoose": "^5.11.15", "next": "^10.0.7", "next-plugin-yaml": "^1.0.1", @@ -4796,6 +4797,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.26.0.tgz", + "integrity": "sha512-+V5QIQ5f6CDXQpWNICELwjwuHdqeJM1UenlZWx5ujcRMc9venvluCjFb4t5NYLhb6IhkbMVOxzVuOqkgMxee2A==", + "engines": { + "node": "*" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -5283,9 +5292,9 @@ } }, "node_modules/mongodb": { - "version": "3.6.4", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.4.tgz", - "integrity": "sha512-Y+Ki9iXE9jI+n9bVtbTOOdK0B95d6wVGSucwtBkvQ+HIvVdTCfpVRp01FDC24uhC/Q2WXQ8Lpq3/zwtB5Op9Qw==", + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.3.tgz", + "integrity": "sha512-rOZuR0QkodZiM+UbQE5kDsJykBqWi0CL4Ec2i1nrGrUI3KO11r6Fbxskqmq3JK2NH7aW4dcccBuUujAP0ERl5w==", "dependencies": { "bl": "^2.2.1", "bson": "^1.1.4", @@ -5322,17 +5331,17 @@ } }, "node_modules/mongoose": { - "version": "5.11.18", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.11.18.tgz", - "integrity": "sha512-RsrPR9nhkXZbO3ml0DcmdbfeMvFNhgFrP81S6o1P+lFnDTNEKYnGNRCIL+ojD69wj7H5jJaAdZ0SJ5IlKxCHqw==", + "version": "5.11.15", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.11.15.tgz", + "integrity": "sha512-8T4bT6eCGB7MqCm40oVhnhT/1AyAdwe+y1rYUhdl3ljsks3BpYz8whZgcMkIoh6VoCCjipOXRqZqdk1UByvlYA==", "dependencies": { "@types/mongodb": "^3.5.27", "bson": "^1.1.4", "kareem": "2.3.2", - "mongodb": "3.6.4", + "mongodb": "3.6.3", "mongoose-legacy-pluralize": "1.0.2", "mpath": "0.8.3", - "mquery": "3.2.4", + "mquery": "3.2.3", "ms": "2.1.2", "regexp-clone": "1.0.0", "safe-buffer": "5.2.1", @@ -5364,9 +5373,9 @@ } }, "node_modules/mquery": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.4.tgz", - "integrity": "sha512-uOLpp7iRX0BV1Uu6YpsqJ5b42LwYnmu0WeF/f8qgD/On3g0XDaQM6pfn0m6UxO6SM8DioZ9Bk6xxbWIGHm2zHg==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.3.tgz", + "integrity": "sha512-cIfbP4TyMYX+SkaQ2MntD+F2XbqaBHUYWk3j+kqdDztPWok3tgyssOZxMHMtzbV1w9DaSlvEea0Iocuro41A4g==", "dependencies": { "bluebird": "3.5.1", "debug": "3.1.0", @@ -13724,6 +13733,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.26.0.tgz", + "integrity": "sha512-+V5QIQ5f6CDXQpWNICELwjwuHdqeJM1UenlZWx5ujcRMc9venvluCjFb4t5NYLhb6IhkbMVOxzVuOqkgMxee2A==" + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -14092,9 +14106,9 @@ "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" }, "mongodb": { - "version": "3.6.4", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.4.tgz", - "integrity": "sha512-Y+Ki9iXE9jI+n9bVtbTOOdK0B95d6wVGSucwtBkvQ+HIvVdTCfpVRp01FDC24uhC/Q2WXQ8Lpq3/zwtB5Op9Qw==", + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.3.tgz", + "integrity": "sha512-rOZuR0QkodZiM+UbQE5kDsJykBqWi0CL4Ec2i1nrGrUI3KO11r6Fbxskqmq3JK2NH7aW4dcccBuUujAP0ERl5w==", "requires": { "bl": "^2.2.1", "bson": "^1.1.4", @@ -14105,17 +14119,17 @@ } }, "mongoose": { - "version": "5.11.18", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.11.18.tgz", - "integrity": "sha512-RsrPR9nhkXZbO3ml0DcmdbfeMvFNhgFrP81S6o1P+lFnDTNEKYnGNRCIL+ojD69wj7H5jJaAdZ0SJ5IlKxCHqw==", + "version": "5.11.15", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.11.15.tgz", + "integrity": "sha512-8T4bT6eCGB7MqCm40oVhnhT/1AyAdwe+y1rYUhdl3ljsks3BpYz8whZgcMkIoh6VoCCjipOXRqZqdk1UByvlYA==", "requires": { "@types/mongodb": "^3.5.27", "bson": "^1.1.4", "kareem": "2.3.2", - "mongodb": "3.6.4", + "mongodb": "3.6.3", "mongoose-legacy-pluralize": "1.0.2", "mpath": "0.8.3", - "mquery": "3.2.4", + "mquery": "3.2.3", "ms": "2.1.2", "regexp-clone": "1.0.0", "safe-buffer": "5.2.1", @@ -14135,9 +14149,9 @@ "integrity": "sha512-eb9rRvhDltXVNL6Fxd2zM9D4vKBxjVVQNLNijlj7uoXUy19zNDsIif5zR+pWmPCWNKwAtqyo4JveQm4nfD5+eA==" }, "mquery": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.4.tgz", - "integrity": "sha512-uOLpp7iRX0BV1Uu6YpsqJ5b42LwYnmu0WeF/f8qgD/On3g0XDaQM6pfn0m6UxO6SM8DioZ9Bk6xxbWIGHm2zHg==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.3.tgz", + "integrity": "sha512-cIfbP4TyMYX+SkaQ2MntD+F2XbqaBHUYWk3j+kqdDztPWok3tgyssOZxMHMtzbV1w9DaSlvEea0Iocuro41A4g==", "requires": { "bluebird": "3.5.1", "debug": "3.1.0", diff --git a/package.json b/package.json index 3416d7e..a276532 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@auth0/nextjs-auth0": "^1.1.0", "animate.css": "^4.1.1", "antd": "^4.12.2", + "luxon": "^1.26.0", "mongoose": "^5.11.15", "next": "^10.0.7", "next-plugin-yaml": "^1.0.1", diff --git a/pages/[redirect]/[key].js b/pages/[redirect]/[key].js index 60fc1db..116dfde 100644 --- a/pages/[redirect]/[key].js +++ b/pages/[redirect]/[key].js @@ -1,7 +1,7 @@ import ErrorPage from 'next/error'; +import API from '../../utils/api'; import { domain, github, gitlab } from '../../data/settings.yml'; -import forms from '../../data/forms.yml'; const repos = { gh: github, @@ -14,10 +14,6 @@ const maps = { j: 'jobs' }; -const uris = { - f: forms -}; - export async function getServerSideProps({ params }) { const { key, redirect } = params; @@ -39,16 +35,27 @@ export async function getServerSideProps({ params }) { }; } - if (redirect in uris) { - if (!(key in uris[redirect])) { + if (redirect === 'f') { + const response = await API.get(`/forms/${key}/url`) + .then((response) => { + return response; + }) + .catch((error) => { + return error; + }); + + if (!response.data.success) { return { - notFound: true + props: { + code: response.status, + message: response.data.error.message || response.statusText + } }; } return { redirect: { - destination: `${uris[redirect][key]}`, + destination: response.data.data, permanent: false } }; @@ -59,6 +66,8 @@ export async function getServerSideProps({ params }) { }; } -const Redirect = () => ; +const Redirect = ({ message = 'This page could not be found.', code = 404 }) => ( + +); export default Redirect; diff --git a/pages/admin/index.js b/pages/admin/index.js index 7472bdc..cbfeb34 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -1,6 +1,7 @@ import { withPageAuthRequired } from '@auth0/nextjs-auth0'; import { AdminContextProvider } from '../../components/Admin/Context'; import LinksTable from '../../components/Admin/LinksTable'; +import FormsTable from '../../components/Admin/FormsTable'; import Navbar, { navbar as entries } from '../../components/Admin/Navbar'; import Footer from '../../components/Footer'; @@ -28,6 +29,7 @@ function Admin({ tab }) { {(!tab || tab === 'links') && } + {(!tab || tab === 'forms') && }
); diff --git a/pages/api/forms/[slug]/index.js b/pages/api/forms/[slug]/index.js new file mode 100644 index 0000000..229e129 --- /dev/null +++ b/pages/api/forms/[slug]/index.js @@ -0,0 +1,65 @@ +import { withApiAuthRequired } from '@auth0/nextjs-auth0'; +import dbConnect from '../../../../utils/database'; +import Form from '../../../../models/Form'; + +export default withApiAuthRequired(async (req, res) => { + const { + query: { slug }, + method + } = req; + + await dbConnect(); + + switch (method) { + case 'GET': + try { + const form = await Form.findOne({ slug }); + + if (!form) { + return res.status(404).json({ success: false, error: { message: 'Form not found' } }); + } + res.status(200).json({ success: true, data: form }); + } catch (error) { + res.status(400).json({ success: false, error: { message: error.message } }); + } + break; + + case 'PUT': + try { + const form = await Form.findOneAndUpdate( + slug, + { ...req.body, updated: Date.now() }, + { + new: true, + runValidators: true + } + ); + if (!form) { + return res.status(404).json({ success: false, error: { message: 'Form not found' } }); + } + res.status(200).json({ success: true, data: form }); + } catch (error) { + res.status(400).json({ success: false, error: { message: error.message } }); + } + break; + + case 'DELETE': + try { + const deleted = await Form.deleteOne({ slug }); + if (!deleted) { + return res.status(400).json({ success: false }); + } + res.status(200).json({ success: true, data: {} }); + } catch (error) { + res.status(400).json({ success: false, error: { message: error.message } }); + } + break; + default: + res.setHeader('Allow', ['GET', 'PUT', 'DELETE']); + res.status(405).json({ + success: false, + error: { message: `Method ${method} Not Allowed` } + }); + break; + } +}); diff --git a/pages/api/forms/[slug]/url.js b/pages/api/forms/[slug]/url.js new file mode 100644 index 0000000..aa97771 --- /dev/null +++ b/pages/api/forms/[slug]/url.js @@ -0,0 +1,33 @@ +import dbConnect from '../../../../utils/database'; +import Form from '../../../../models/Form'; + +export default async (req, res) => { + const { + query: { slug }, + method + } = req; + + await dbConnect(); + + switch (method) { + case 'GET': + try { + const form = await Form.findOneAndUpdate({ slug }, { $inc: { visits: 1 } }, { new: true }); + + if (!form) { + return res.status(404).json({ success: false, error: { message: 'Form not found' } }); + } + res.status(200).json({ success: true, data: form.url }); + } catch (error) { + res.status(400).json({ success: false, error: { message: error.message } }); + } + break; + default: + res.setHeader('Allow', ['GET']); + res.status(405).json({ + success: false, + error: { message: `Method ${method} Not Allowed` } + }); + break; + } +}; diff --git a/pages/api/forms/index.js b/pages/api/forms/index.js new file mode 100644 index 0000000..9642ae1 --- /dev/null +++ b/pages/api/forms/index.js @@ -0,0 +1,35 @@ +import { withApiAuthRequired } from '@auth0/nextjs-auth0'; +import dbConnect from '../../../utils/database'; +import Form from '../../../models/Form'; + +export default withApiAuthRequired(async (req, res) => { + const { method } = req; + + await dbConnect(); + + switch (method) { + case 'GET': + try { + const forms = await Form.find({}).sort({ created: 'asc' }); + res.status(200).json({ success: true, data: forms }); + } catch (error) { + res.status(400).json({ success: false, error: { message: error.message } }); + } + break; + case 'POST': + try { + const form = await Form.create(req.body); + res.status(201).json({ success: true, data: form }); + } catch (error) { + res.status(400).json({ success: false, error: { message: error.message } }); + } + break; + default: + res.setHeader('Allow', ['GET', 'POST']); + res.status(405).json({ + success: false, + error: { message: `Method ${method} Not Allowed` } + }); + break; + } +}); diff --git a/pages/api/links/[id].js b/pages/api/links/[id].js index f95de67..ba11480 100644 --- a/pages/api/links/[id].js +++ b/pages/api/links/[id].js @@ -51,7 +51,7 @@ export default withApiAuthRequired(async (req, res) => { } break; default: - res.setHeader('Allow', ['POST']); + res.setHeader('Allow', ['GET', 'PUT', 'DELETE']); res.status(405).json({ success: false, error: { message: `Method ${method} Not Allowed` } diff --git a/pages/api/links/index.js b/pages/api/links/index.js index 7f11975..af223eb 100644 --- a/pages/api/links/index.js +++ b/pages/api/links/index.js @@ -25,7 +25,7 @@ export default withApiAuthRequired(async (req, res) => { } break; default: - res.setHeader('Allow', ['POST']); + res.setHeader('Allow', ['GET', 'POST']); res.status(405).json({ success: false, error: { message: `Method ${method} Not Allowed` }