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=>
{column.name} |
)}
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
+ 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
?