diff --git a/api/items.js b/api/items.js index bdd7edc..7916d15 100644 --- a/api/items.js +++ b/api/items.js @@ -13,7 +13,13 @@ const upload = require('multer')({ }) router.get('/', async (req, res) => { - const items = await db.item.findAll() + const showUnpublished = + // Only respect query parameter if user is admin + (req.user && req.user.is_admin) + ? req.query.showUnpublished + : false + + const items = await db.item.findAll(showUnpublished) res.json(items) }) @@ -44,11 +50,6 @@ router.get('/by-slug/:slug', async (req, res) => { res.json(item) }) -router.delete('/:uuid', async (req, res) => { - const result = await db.item.removeItem(req.params.uuid) - res.json({deleted: true}) -}) - router.post('/:uuid/images', upload.single('image'), async (req, res) => { // Handle either image upload body or JSON body try { @@ -63,3 +64,13 @@ router.post('/:uuid/images', upload.single('image'), async (req, res) => { res.json(await db.item.findById(req.params.uuid)) }) + +router.post('/:uuid/publish', async (req, res) => { + const item = await db.item.publish(req.params.uuid) + res.json(item) +}) + +router.post('/:uuid/unpublish', async (req, res) => { + const item = await db.item.unpublish(req.params.uuid) + res.json(item) +}) diff --git a/components/form/decimalInput.js b/components/form/decimalInput.js new file mode 100644 index 0000000..12e4b4b --- /dev/null +++ b/components/form/decimalInput.js @@ -0,0 +1,92 @@ +import React, {useState, useRef} from 'react' +import useMeasure from 'use-measure' + +import styles from './styles.module.css' + +function truncateFixed(num, fixed) { + var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?'); + return num.toString().match(re)[0]; +} + +export default function Input({label, prefix, numDecimals, error, hint, name, value, onChange, onBlur, isValid}){ + const [currentDecimals, setCurrentDecimals] = useState((numDecimals === undefined || value === undefined || value === '') ? -1 : numDecimals) + const currentValue = value === undefined ? 0 : typeof value === 'number' ? value : typeof value === 'string' ? parseInt(value || '0', 10) : 0 + + const spanRef = useRef(); + const {width} = useMeasure(spanRef); + + const updateParent = value => onChange({target: {value}}) + + const onKeyDown = (ev) => { + const {key} = ev + switch(key) { + case 'Backspace': + setCurrentDecimals(dec => Math.max(dec - 1, -1)) + if(currentDecimals > 0) { + updateParent(parseFloat(truncateFixed(currentValue, currentDecimals - 1))) + } else { + updateParent(Math.floor(currentValue / 10)) + } + break; + + case '.': + setCurrentDecimals(0) + break; + + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + const digit = parseInt(key, 10) + // If we're still in the whole numbers + if(currentDecimals < 0) { + updateParent(currentValue * 10 + digit) + } else if (currentDecimals === 0) { + // Add decimal + setCurrentDecimals(dec => dec + 1) + updateParent(parseFloat(currentValue.toFixed(0) + '.' + digit)) + } else if (currentDecimals < numDecimals) { + // Add to existing decimals + setCurrentDecimals(dec => dec + 1) + updateParent(parseFloat(currentValue.toFixed(currentDecimals) + digit)) + } else { + // Can we play a tick noise of some sort? + } + break; + + default: + return; + } + + ev.preventDefault(); + } + + const filledText = ((prefix + ' ') || '') + + (Math.floor(currentValue) || '0') + + (currentDecimals >= 0 ? '.' : '') + + (currentDecimals > 0 ? currentValue.toFixed(currentDecimals).split('.')[1] : '') + + const remainingText = + (currentDecimals < 0 ? '.' : '') + + (numDecimals !== undefined && currentDecimals < numDecimals ? '0'.repeat(numDecimals - Math.max(currentDecimals, 0)) : '') + + return ( +
+ +
+ {}} onKeyDown={onKeyDown} onBlur={onBlur} /> +
+ {filledText} + {remainingText} +
+
+ {hint && {error || (isValid ? '' : hint)}} +
+ ) +} diff --git a/components/form/form.js b/components/form/form.js index c7b95ff..ea1280e 100644 --- a/components/form/form.js +++ b/components/form/form.js @@ -4,7 +4,8 @@ import axios from 'axios' import styles from './styles.module.css' export const Input = require('./input.js').default -export const NumInput = require('./numInput.js').default +export const IntegerInput = require('./integerInput.js').default +export const DecimalInput = require('./decimalInput.js').default export const Button = require('./button.js').default const errorReducer = (errors, action) => { @@ -84,11 +85,12 @@ export const FormController = function FormController({children, className, url initialState.fields[child.props.name] = { name: child.props.name, validate: child.props.validate, + transform: child.props.transform, value: child.props.initialValue || "", isValid: child.props.validate ?(child.props.initialValue ? child.props.validate(child.props.initialValue): false) :true, - touched: false + touched: child.props.initialValue !== undefined } }) @@ -103,7 +105,10 @@ export const FormController = function FormController({children, className, url const data = {} for(const name in state.fields){ const field = state.fields[name] - data[field.name] = field.value + if(field.transform) + data[field.name] = field.transform(field.value) + else + data[field.name] = field.value } if(url) diff --git a/components/form/input.js b/components/form/input.js index 1ef9b05..325ebd8 100644 --- a/components/form/input.js +++ b/components/form/input.js @@ -2,7 +2,9 @@ import React from 'react' import styles from './styles.module.css' -export default function Input({label, error, hint, type, name, value, onChange, onBlur, isValid}){ +export default function Input({_label, error, hint, type, name, value, onChange, onBlur, isValid}){ + const label = _label || name.replace(name[0], name[0].toUpperCase()) + return (
diff --git a/components/form/numInput.js b/components/form/integerInput.js similarity index 100% rename from components/form/numInput.js rename to components/form/integerInput.js diff --git a/components/form/styles.module.css b/components/form/styles.module.css index 5c9479f..82aacad 100644 --- a/components/form/styles.module.css +++ b/components/form/styles.module.css @@ -29,7 +29,8 @@ border: solid 1px rgba(0,0,0,.2); border-radius: 2px; transition-property: border-color,box-shadow; - transition: .2s ease-in-out; + transition-duration: .2s; + transition-timing-function: ease-in-out; } :global(.dark) .formElementContainer input, :global(.dark) .formElementContainer .complexInput { @@ -40,6 +41,31 @@ display: flex; flex-direction: row; overflow: hidden; + background: white; + position: relative; +} + +.complexInput > .inputDecorators { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + pointer-events: none; + padding: 9px 10px; +} + +.inputDecorators > span { + display: inline-block; +} + +.inputDecorators > span { + color: black; + transition: .3s opacity; +} + +.complexInput input:focus + .inputDecorators .numRemaining { + opacity: .6; } .complexInput > button { @@ -94,4 +120,4 @@ .formElementContainer button[disabled] { opacity: .4; filter: saturate(.2); -} \ No newline at end of file +} diff --git a/components/table/table.js b/components/table/table.js index 577ef52..8c1d3e4 100644 --- a/components/table/table.js +++ b/components/table/table.js @@ -5,7 +5,7 @@ export default function Table({columns, rows, foot}) { - {columns.map(column=> + {columns?.map(column=> )} diff --git a/db/models/item.js b/db/models/item.js index 9e03bac..1ad3e14 100644 --- a/db/models/item.js +++ b/db/models/item.js @@ -7,8 +7,15 @@ const sharp = require('sharp') const item = module.exports = {} -item.findAll = async () => { - const query = 'select * from sos.v_item' +item.findAll = async (showUnpublished = false) => { + let queryPublished = 'select * from sos.v_item where item_published = true' + let queryAll = 'select * from sos.v_item' + + const query = ( + showUnpublished === "true" + ? queryAll + : queryPublished + ) debug(query); @@ -130,3 +137,27 @@ item.removeItem = async (item_uuid) => { const {rows} = await pg.query(query) return joinjs.map(rows, mappings, 'itemMap', 'item_')[0] } + +item.publish = async (item_uuid) => { + const query = { + text: 'select * from sos.publish_item($1)', + values: [ + item_uuid + ] + } + + const {rows} = await pg.query(query) + return joinjs.map(rows, mappings, 'itemMap', 'item_')[0] +} + +item.unpublish = async (item_uuid) => { + const query = { + text: 'select * from sos.unpublish_item($1)', + values: [ + item_uuid + ] + } + + const {rows} = await pg.query(query) + return joinjs.map(rows, mappings, 'itemMap', 'item_')[0] +} diff --git a/db/sql/2-views.sql b/db/sql/2-views.sql index ffe9edc..eb37fdf 100644 --- a/db/sql/2-views.sql +++ b/db/sql/2-views.sql @@ -6,7 +6,7 @@ create or replace view sos.v_item as "image".image_mime_type, "image".image_date_uploaded, "user".user_email, - num_added - num_removed as item_number_in_stock + coalesce(num_added - num_removed, 0) as item_number_in_stock from sos."item" left join sos."image" on item.item_uuid = image.image_item_uuid left join sos."user" on image.image_uploader_uuid = "user".user_uuid diff --git a/db/sql/3-functions.sql b/db/sql/3-functions.sql index 3f4ef2a..b4c6b6c 100644 --- a/db/sql/3-functions.sql +++ b/db/sql/3-functions.sql @@ -346,3 +346,27 @@ begin -- Return cart return query select * from sos.v_cart where cart_uuid = _cart_uuid; end; $function$; + +create or replace function sos.publish_item(_item_uuid uuid) + returns setof sos.v_item + language plpgsql +as $function$ +begin + update sos."item" + set item_published = true + where item_uuid = _item_uuid; + + return query select * from sos.v_item where item_uuid = _item_uuid; +end; $function$; + +create or replace function sos.unpublish_item(_item_uuid uuid) + returns setof sos.v_item + language plpgsql +as $function$ +begin + update sos."item" + set item_published = false + where item_uuid = _item_uuid; + + return query select * from sos.v_item where item_uuid = _item_uuid; +end; $function$; diff --git a/package-lock.json b/package-lock.json index 320d5e5..4109e52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6290,6 +6290,11 @@ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" }, + "resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "resolve": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", @@ -7578,6 +7583,14 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "use-measure": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/use-measure/-/use-measure-0.3.0.tgz", + "integrity": "sha512-HohD5JGamEaNVN6dUM+pfVBUCblIVk8poTZgTTNTA+hd0TXHMazfBQS8SSb+L4ejlIg+CD3yzkPZDL4Y5A1LlA==", + "requires": { + "resize-observer-polyfill": "^1.5.1" + } + }, "use-subscription": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/use-subscription/-/use-subscription-1.1.1.tgz", diff --git a/package.json b/package.json index 3ab87c5..8d853b3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react": "^16.12.0", "react-dom": "^16.12.0", "sharp": "^0.24.1", + "use-measure": "^0.3.0", "validator": "^12.2.0" } } diff --git a/pages/admin/index.js b/pages/admin/index.js index 75cbadc..8a809f3 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -3,5 +3,6 @@ import useRequireAdmin from '../../hooks/useRequireAdmin' export default function AdminDashboard(){ useRequireAdmin() + // TODO: button for making a stockchange transaction return

Admin Dashboard

} diff --git a/pages/admin/items/[slug].js b/pages/admin/items/[slug].js new file mode 100644 index 0000000..2ebd94c --- /dev/null +++ b/pages/admin/items/[slug].js @@ -0,0 +1,30 @@ +import React from 'react' +import {FormController, Input, DecimalInput, Button} from '~/components/form' + +EditItem.getInitialProps = async ({ctx: {axios, query: {slug}}}) => { + const {data: item} = await axios.get(`/api/items/by-slug/${slug}`) + return {item} +} + +export default function EditItem({item}) { + const stringLengthAtLeastOne = str => str.length > 0 + const slugRestrictions = str => { + if(str.length < 3) return false; + if(str.length > 20) return false; + if(!str.match(/^[-a-z0-9_]*$/i)) return false; + return true; + } + + return ( + <> +

Editing {item.name}

+ + + + + Math.floor(float * 100)} /> + + + + ) +} diff --git a/pages/admin/items/index.js b/pages/admin/items/index.js new file mode 100644 index 0000000..6f6a9f8 --- /dev/null +++ b/pages/admin/items/index.js @@ -0,0 +1,56 @@ +import React, {useState} from 'react' +import Link from 'next/link' +import axios from 'axios' + +import Table from '~/components/table' + +Items.getInitialProps = async ({ctx}) => { + const {data: items} = await ctx.axios.get('/api/items?showUnpublished=true') + return {items} +} + +export default function Items({items: _items}){ + const [items, setItems] = useState(_items) + + const updateItem = newItem => setItems(items=>items.map(i => { + if(i.uuid === newItem.uuid) + return newItem + return i + })) + + const publish = item => async () => { + const {data: newItem} = await axios.post(`/api/items/${item.uuid}/publish`) + updateItem(newItem) + } + + const unpublish = item => async () => { + const {data: newItem} = await axios.post(`/api/items/${item.uuid}/unpublish`) + updateItem(newItem) + } + + return ( + <> +

Items

+
{column.name}
item.name}, + {name: 'URL', extractor: item => + /store/item/{item.urlslug} }, + {name: 'Price', extractor: item => `$${(item.price_cents / 100).toFixed(2)}`}, + {name: 'Number in stock', extractor: item => item.number_in_stock}, + {name: 'Actions', extractor: item => ( + + Edit + { + item.published + ? + : + } + + )} + ]} + rows={items} + /> + + ) +} diff --git a/pages/store/item/[slug].js b/pages/store/item/[slug].js index 96c837e..2249fd4 100644 --- a/pages/store/item/[slug].js +++ b/pages/store/item/[slug].js @@ -1,7 +1,7 @@ import React, {useState} from 'react' import Head from 'next/head' import Router from 'next/router' -import {FormController, NumInput, Button} from '~/components/form' +import {FormController, IntegerInput, Button} from '~/components/form' import isNum from 'validator/lib/isNumeric' import useCart from '../../../hooks/useCart' @@ -56,7 +56,7 @@ export default function Item({item}){ { item.description.split('\n').map(p=>p.trim()).filter(p=>p !== '').map((p,i)=>

{p}

) } {setCart(cart); Router.push('/store/cart')}}> - isNum(value, {no_symbols: true})} /> + isNum(value, {no_symbols: true})} /> { item.number_in_stock > 0 ?