Former-commit-id: e9cbcd159172587181e412bf22ead2260976aca9
This commit is contained in:
Ben
2022-04-15 02:13:25 +01:00
parent cdd78d8acf
commit ebf27b1ac0
23 changed files with 1612 additions and 174 deletions

View File

@@ -103,7 +103,7 @@ class Basket extends Component {
Render() {
return {
template: `
template: /* html */`
<span id="basket-wrapper">
<div class="basket">
<img id="basket-icon" class="menu-item" src="https://www.svgrepo.com/show/343743/cart.svg" width="50px" stroke="#222" stroke-width="2px" alt="">

View File

@@ -1,5 +1,4 @@
import { RegisterComponent, Component } from './components.mjs';
import * as Helpers from '../helpers.mjs';
class CompactProductListing extends Component {
static __IDENTIFY() { return 'compact-listing'; }
@@ -10,13 +9,13 @@ class CompactProductListing extends Component {
Render() {
return {
template: `
template: /* html */`
<div class="product-listing">
<div class="product-listing-image">
<img class="product-image"
title="Image of {this.state.name}"
alt="Image of {this.state.name}"
src="{this.state.image}">
src="/api/cdn/${this.state.id}.png">
</div>
<div class="product-listing-info">
<div class="product-listing-name">{this.state.name} {this.state.id}</div>

View File

@@ -54,11 +54,8 @@ export class Component extends HTMLElement {
this.Update(Object.bind(this));
this.setState(this.state);
if (this.attributes.length === 0) {
this.__INVOKE_RENDER(Object.bind(this));
}
this.setState(this.state, false);
this.__INVOKE_RENDER(Object.bind(this));
}
disconnectedCallback() {
@@ -88,8 +85,9 @@ export class Component extends HTMLElement {
return this.state;
}
setState(newState) {
setState(newState, doRender = true) {
this.state = newState;
if (!doRender) return;
this.__INVOKE_RENDER(Object.bind(this));
}

View File

@@ -25,8 +25,9 @@
}
.product-image-container {
aspect-ratio: 1;
flex-grow: 1;
aspect-ratio: 1;
max-width: 600px;
}
.active-image {
@@ -34,6 +35,15 @@
width: 100%;
}
.product-info {
flex-grow: 2;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
margin-left: 1em;
}
@media (pointer:none), (pointer:coarse), screen and (max-width: 900px) {
.product-page, .product-display {
flex-direction: column;
@@ -55,15 +65,6 @@
}
}
.product-info {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
margin-left: 1em;
}
.product-name {
font-size: 2em;
font-weight: bold;
@@ -80,16 +81,6 @@
margin-block-start: 0.83em;
}
.tag {
padding: 0.3em 1em;
margin-right: 1em;
line-height: 1.5em;
font-size: 0.8em;
font-weight: bold;
background-color: #F2CA52;
cursor: pointer;
}
.product-listing-price {
font-size: 1.5em;
}
@@ -181,16 +172,16 @@ input[type=number] {
transform: translateY(4px);
}
.product-details-collapsible {
.collapsible-menu {
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
margin-block-end: 3em;
margin-block-end: 2em;
min-width: 0;
}
.product-details-header {
.menu-header {
width: 100%;
display: flex;
justify-content: space-between;
@@ -199,37 +190,67 @@ input[type=number] {
font-weight: bold;
cursor: pointer;
border-bottom: #1A1A1A solid 1px;
min-width: 0;
}
.product-details-header-arrow {
.menu-header-arrow {
transform: rotate(-180deg);
margin-left: 0.5em;
transition: transform 0.2s ease-in-out;
}
/* rotate the arrow down when the details are open */
.product-details-header-arrow-down {
.menu-header-arrow-down {
margin-left: 0.5em;
transform: rotate(-90deg);
}
.product-details-content {
visibility: hidden;
width: 100%;
.menu-content {
max-width: fit-content;
display: none;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
min-width: 0;
}
.details-open {
visibility: visible;
display: flex;
position: static;
width: auto;
}
.product-details-content-item {
padding-top: 0.6em;
}
.scrollable-container {
width: auto;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
overflow-y: auto;
overflow-x: hidden;
height: 400px;
}
.set-piece-container {
width: 100%;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
margin-block-start: 0.83em;
margin-block-end: 0.83em;
min-width: 0;
}
.sc-listing {
width: 100%;
flex-grow: 5;
min-width: 0;
}
.set-piece-amount {
flex-grow: 1;
font-size: 2.3em;
font-weight: bold;
}

View File

@@ -36,14 +36,13 @@ class ProductList extends Component {
}
return {
template: `
template: /* html */`
<h2>{this.state.title}</h2>
<div class="product-list">
${this.state.products.data.map(product => {
return `<compact-listing-component name="${product.name}"
id="${product.id}"
listing="${product.listing}"
image="${product.image}"
price="${product.price}"
type="${product.type}"
discount="${product.discount || ''}"></compact-listing-component>

View File

@@ -13,18 +13,64 @@ class ProductListing extends Component {
const type = urlParams.get('type');
const id = urlParams.get('id');
const getURL = new URL(`/api/${type}/${id}`, document.baseURI);
const data = await fetch(getURL).then(response => response.json());
console.log(data);
const getProductURL = new URL(`/api/${type}/${id}`, document.baseURI);
const productData = await fetch(getProductURL).then(response => response.json());
let setContents = [];
if (productData.data.type === 'set') {
const allPieces = [];
Object.keys(productData.data.includedPieces).forEach(key => {
allPieces.push(key);
});
const bulkSets = await fetch('/api/bulk/brick', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: allPieces,
}),
}).then(response => response.json());
setContents = bulkSets.data;
}
this.setState({
...this.getState,
...data.data,
});
...productData.data,
setContents,
}, false);
}
Render() {
let setContents = '';
console.log(this.state)
if (this.state.type === 'set') {
setContents = /* html */`
<div class="collapsible-menu">
<div class="menu-header">
<span class="menu-header-text">Set Contents</span>
<img class="menu-header-arrow" src="/res/back-arrow.svg" height="30em" alt="down-arrow">
</div>
<div class="menu-content scrollable-container">
${this.state.setContents.map(piece => /* html */`
<div class="set-piece-container">
<span class="set-piece-amount">x${this.state.includedPieces[piece.id]}</span>
<super-compact-listing-component class="sc-listing" id="${piece.id}"
name="${piece.name}"
tag="${piece.tag}"
type="piece"
price="${piece.price || piece.discount}">
</super-compact-listing-component>
</div>
`).join('')}
</div>
</div>
`;
}
return {
template: `
template: /* html */`
<div class="product-page">
<div class="back-button">
<img class="back-button-svg" src="/res/back-arrow.svg" height="60em" alt="back-arrow">
@@ -39,11 +85,11 @@ class ProductListing extends Component {
<div class="product-info">
<div class="product-tags">
${this.state.tags.map(tag => {
return `<span class="tag">${tag}</span>`;
return `<tag-component name="${tag}"></tag-component>`;
}).join('')}
</div>
<div class="product-name">{this.state.name} [{this.state.date_released}]</div>
<div class="product-name">{this.state.name} {this.state.id}</div>
${this.state.discount
? '<span class="product-listing-price-full">£{this.state.price}</span><span class="product-listing-price-new">£{this.state.discount}</span>'
: '<span class="product-listing-price">£{this.state.price}</span>'}
@@ -61,34 +107,21 @@ class ProductListing extends Component {
<img class="add-to-favorites-button" src="https://www.svgrepo.com/show/25921/heart.svg" width="45px" stroke="#222" stroke-width="2px" alt="Add to Favorites" title="Add to Favorites">
</div>
<div class="product-details-collapsible">
<div class="product-details-header">
<span class="product-details-header-text">Product Details</span>
<img class="product-details-header-arrow" src="/res/back-arrow.svg" height="30em" alt="down-arrow">
<div class="collapsible-menu">
<div class="menu-header">
<span class="menu-header-text">Product Details</span>
<img class="menu-header-arrow" src="/res/back-arrow.svg" height="30em" alt="down-arrow">
</div>
<div class="product-details-content">
<div class="product-details-content-item">
<span class="product-details-date">Released in {this.state.date_released}</span>
</div>
<div class="product-details-content-item">
<span class="product-details-dimensions">Dimensions:&nbsp;</span>
<span class="product-details-dimensions-value">
{this.state.dimensions_x} x {this.state.dimensions_y} x {this.state.dimensions_z}
</span>
</div>
<div class="product-details-content-item">
<span class="product-details-weight">Weight:&nbsp;</span>
<span class="product-details-weight-value">{this.state.weight}</span>
<span class="product-details-weight-unit">g</span>
</div>
<div class="product-details-content-item">
Not suitable for children under the age of 3 years old, small parts are a choking hazard.
</div>
<div class="product-details-content-item">
Not for individual resale.
</div>
<div class="menu-content">
<div class="product-details-content-item">Released in {this.state.date_released}</div>
<div class="product-details-content-item">Dimensions: {this.state.dimensions_x} x {this.state.dimensions_y} x {this.state.dimensions_z}</div>
<div class="product-details-content-item">Weight: {this.state.weight}g</div>
<div class="product-details-content-item">Not suitable for children under the age of 3 years old, small parts are a choking hazard.</div>
<div class="product-details-content-item">Not for individual resale.</div>
</div>
</div>
${setContents}
</div>
</div>
@@ -136,14 +169,15 @@ class ProductListing extends Component {
});
// product details, collapsable
const collapseButton = this.root.querySelector('.product-details-header');
const collapseContent = this.root.querySelector('.product-details-content');
const collapseArrow = this.root.querySelector('.product-details-header-arrow');
const collapseButton = this.root.querySelectorAll('.menu-header');
collapseButton.addEventListener('click', () => {
collapseButton.forEach(el => el.addEventListener('click', (e) => {
const parent = e.path[2].querySelector('.collapsible-menu') ? e.path[1] : e.path[2];
const collapseContent = parent.querySelector('.menu-content');
const collapseArrow = parent.querySelector('.menu-header-arrow');
collapseContent.classList.toggle('details-open');
collapseArrow.classList.toggle('product-details-header-arrow-down');
});
collapseArrow.classList.toggle('menu-header-arrow-down');
}));
// add quantity to basket and then update the basket count
const addToBasket = this.root.querySelector('.add-to-basket-button');

View File

@@ -9,7 +9,7 @@ class Search extends Component {
Render() {
return {
template: `
template: /* html */`
<input id="search-bar" class="menu-item" type="text" placeholder="search..."/>
`,
style: `

View File

@@ -9,7 +9,7 @@ class StoreFront extends Component {
Render() {
return {
template: `
template: /* html */`
<div class="main-carousel">
<div class="carousel-cell">
<img class="carousel-image" src="/res/lego-image1.jpg" alt="">

View File

@@ -0,0 +1,87 @@
import { RegisterComponent, Component } from './components.mjs';
class SuperCompactProductListing extends Component {
static __IDENTIFY() { return 'super-compact-listing'; }
constructor() {
super(SuperCompactProductListing);
}
Render() {
return {
template: /* html */`
<span class="product-listing">
<span class="product-listing-image">
<img class="product-image"
title="Image of {this.state.name}"
alt="Image of {this.state.name}"
src="/api/cdn/${this.state.id}-thumb.png">
</span>
<span class="product-listing-info">
<span class="product-listing-name">{this.state.name}</span>
<tag-component name="{this.state.tag}"></tag-component>
</span>
<span class="product-pricing">
£{this.state.price}
</span>
</span>
`,
style: `
.product-listing {
width: 95%;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
margin: 7px;
z-index: 0;
cursor: pointer;
}
.product-listing-image {
display: block;
margin: 0 auto;
margin-bottom: 7px;
max-width: 100%;
flex-grow: 1
}
.product-image {
object-fit: scale-down;
object-position: center;
}
.product-image:hover {
cursor: hand;
}
.product-listing-info {
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
padding-left: 7px;
padding-right: 7px;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 50
}
.product-pricing {
flex-grow: 1;
text-align: right;
font-size: 0.8em;
font-weight: bold;
color: #E55744;
}
`,
};
}
OnRender() {
this.root.addEventListener('click', () => {
window.location.href = `/product/?type=${this.state.type}&id=${this.state.id}&name=${encodeURIComponent(this.state.name)}`;
});
}
}
RegisterComponent(SuperCompactProductListing);

View File

@@ -0,0 +1,38 @@
import { RegisterComponent, Component } from './components.mjs';
class Tag extends Component {
static __IDENTIFY() { return 'tag'; }
constructor() {
super(Tag);
}
Render() {
return {
template: /* html */`
<span class="tag">{this.state.name}</span>
`,
style: `
.tag {
padding: 0.3em 1em;
margin-right: 0.3em;
margin-top: 0.3em;
margin-bottom: 0.3em;
line-height: 1.5em;
font-size: 0.8em;
font-weight: bold;
background-color: #F2CA52;
cursor: pointer;
}
`,
};
}
OnRender() {
this.root.addEventListener('click', () => {
this.root.classList.toggle('tag-selected');
});
}
}
RegisterComponent(Tag);

View File

@@ -25,7 +25,9 @@
<script type="module" src="/components/basket.mjs"></script>
<script type="module" src="/components/notificationbar.mjs"></script>
<script type="module" src="/components/storefront.mjs"></script>
<script type="module" src="/components/tag.mjs"></script>
<script type="module" src="/components/product-list.mjs"></script>
<script type="module" src="/components/super-compact-listing.mjs"></script>
<script type="module" src="/components/compact-listing.mjs"></script>
<script type="module" src="/components/product-listing.mjs"></script>

View File

@@ -21,7 +21,9 @@
<script type="module" src="/components/basket.mjs"></script>
<script type="module" src="/components/notificationbar.mjs"></script>
<script type="module" src="/components/storefront.mjs"></script>
<script type="module" src="/components/tag.mjs"></script>
<script type="module" src="/components/product-list.mjs"></script>
<script type="module" src="/components/super-compact-listing.mjs"></script>
<script type="module" src="/components/compact-listing.mjs"></script>
<script type="module" src="/components/product-listing.mjs"></script>

View File

@@ -16,9 +16,10 @@ automatically every request
| GET | /api/bricks/ | query, page | no | Query endpoint |
| GET | /api/sets/ | query, page | no | Query endpoint |
| GET | /api/sets/featured | page | no | Query endpoint |
| GET | /api/brick/:id/ | | no | |
| GET | /api/set/:id/ | | no | |
| GET | /api/cdn/:id/ | | no | |
| GET | /api/brick/:id | | no | |
| POST | /api/bulk/brick | array | no | POST due to bulk nature |
| GET | /api/set/:id | | no | |
| GET | /api/cdn/:id | | no | |
| PUT | /api/auth/login/ | | yes | |
| POST | /api/auth/signup/ | | yes | |
| GET | /api/auth/orders/ | | yes | |

View File

@@ -44,7 +44,7 @@ class MyComponent extends Component {
Render() {
return {
template: `<div>{this.state.name}</div>`,
template: /* html */`<div>{this.state.name}</div>`,
style: `div { text-color: red }`,
};
}

1264
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
},
"dependencies": {
"axios": "^0.25.0",
"body-parser": "^1.20.0",
"cli-color": "^2.0.1",
"decompress": "^4.2.1",
"decompress-targz": "^4.1.1",
@@ -39,7 +40,9 @@
"moment": "^2.29.1",
"npm": "^8.6.0",
"pg": "^8.7.3",
"pg-native": "^3.0.0"
"pg-format": "^1.0.4",
"pg-native": "^3.0.0",
"sharp": "^0.30.3"
},
"devDependencies": {
"eslint": "^8.9.0",

View File

@@ -0,0 +1,40 @@
const Database = require('../database/database.js');
const PgFormat = require('pg-format');
async function GetBulkBricks(bricksArr) {
await Database.Query('BEGIN TRANSACTION;');
const dbres = await Database.Query(PgFormat(`
SELECT lego_brick.id, lego_brick.name, tag.name AS "tag", inv.price, inv.new_price AS "discount"
FROM lego_brick
LEFT JOIN lego_brick_tag AS tags ON tags.brick_id = lego_brick.id
LEFT JOIN tag AS tag ON tags.tag = tag.id
LEFT JOIN lego_brick_inventory AS inv ON inv.brick_id = lego_brick.id
WHERE lego_brick.id IN (%L);
`, bricksArr), []);
await Database.Query('END TRANSACTION;');
// validate database response
if (dbres.rows.length === 0) {
return {
error: 'Bricks not found',
long: 'The bricks you are looking for do not exist',
};
}
const bricks = dbres.rows;
// combine tags into a single array
for (const brick of bricks) {
brick.tags = brick.tag.split(',');
}
return bricks;
}
async function GetBrick(brickId) {
}
module.exports = {
GetBulkBricks,
GetBrick,
};

View File

@@ -16,13 +16,15 @@ function ValidateQuery(query) {
async function GetSet(setId) {
await Database.Query('BEGIN TRANSACTION;');
const dbres = await Database.Query(`
SELECT lego_set.id, lego_set.name, description, tag.name AS "tag", inv.price,
SELECT lego_set.id, lego_set.name, description, tag.name AS "tag",
set_contents.brick_id, set_contents.amount, inv.price,
date_released, weight, dimensions_x, dimensions_y, dimensions_z,
new_price AS "discount", inv.stock, inv.last_updated AS "last_stock_update"
FROM lego_set
LEFT JOIN lego_set_inventory AS inv ON inv.set_id = lego_set.id
LEFT JOIN lego_set_tag AS tags ON tags.set_id = lego_set.id
LEFT JOIN tag AS tag ON tags.tag = tag.id
LEFT JOIN set_descriptor AS set_contents ON set_contents.set_id = lego_set.id
WHERE lego_set.id = $1;
`, [setId]);
await Database.Query('END TRANSACTION;');
@@ -36,18 +38,22 @@ async function GetSet(setId) {
}
const tags = dbres.rows.reduce((acc, cur) => {
acc.push(cur.tag);
acc.add(cur.tag);
return acc;
}, []);
}, new Set());
const pieces = dbres.rows.reduce((acc, cur) => {
acc[cur.brick_id] = cur.amount;
return acc;
}, {});
const set = dbres.rows[0];
delete set.tag;
set.tags = tags;
set.includedPieces = pieces;
set.tags = Array.from(tags);
set.image = `/api/cdn/${set.id}.png`;
set.type = 'set';
console.log(set)
return set;
}
@@ -78,7 +84,6 @@ async function GetSets(page, resPerPage) {
const sets = dbres.rows;
for (const set of sets) {
set.image = `/api/cdn/${set.id}.png`;
set.type = 'set';
}

View File

@@ -49,7 +49,7 @@ async function Query(query, params, callback) {
}
// debug moment
Logger.Database(`PSQL Query: ${query.substring(0, 100)}...`);
Logger.Database(`PSQL Query: ${query.substring(0, 500).trim()}...`);
const result = await connection.query(query, params, callback);
return result;
}

View File

@@ -17,6 +17,7 @@ function Init() {
Server.App.get('/api/sets/');
Server.App.get('/api/sets/featured/', Sets.Featured);
Server.App.get('/api/brick/:id', Bricks.Get);
Server.App.post('/api/bulk/brick', Bricks.GetMultiple);
Server.App.get('/api/set/:id', Sets.Get);
Server.App.get('/api/cdn/:id', CDN.Get);

View File

@@ -6,35 +6,33 @@ function Get(req, res) {
}));
}
function Query(req, res, next) {
const query = req.query;
// Validation
const validation = Controller.ValidateQuery(query);
if (!validation.isValid) {
return res.status(400).json({
error: {
short: validation.error,
long: validation.longError,
},
});
async function GetMultiple(req, res) {
if (req.body.ids.length === 0) {
res.send(JSON.stringify({
error: 'No ids provided',
long: 'No ids provided',
}));
return;
}
// Query
Controller.Query(query, (err, data) => {
if (err) {
return res.status(500).json({
error: err,
});
}
const bricks = await Controller.GetBulkBricks(req.body.ids);
res.json(data);
});
if (bricks.error) {
res.send(JSON.stringify(bricks));
return;
}
res.send(JSON.stringify({
data: bricks,
}));
}
function Query(req, res, next) {
next();
}
module.exports = {
Get,
GetMultiple,
Query,
};

View File

@@ -1,19 +1,61 @@
const Logger = require('../logger.js');
const md5 = require('md5');
const fs = require('fs');
// fast thumbnail generation
const sharp = require('sharp');
function Get(req, res) {
// get id from url
const id = req.params.id;
let id = req.params.id;
let thumbnail = false;
if (id.includes('-thumb')) {
thumbnail = true;
id = id.replace('-thumb', '');
}
// work out hash from id
const hash = md5(id.split('.png')[0]);
const bucket = hash.substring(0, 4);
const file = `${process.cwd()}\\db\\img\\${bucket[0]}\\${bucket[1]}\\${bucket[2]}\\${bucket[3]}\\${id}`;
if (fs.existsSync(file)) {
res.sendFile(file);
} else {
res.sendFile(`${process.cwd()}\\db\\img\\default.png`);
// this very randomly fails sometimes
try {
if (fs.existsSync(file)) {
if (thumbnail) {
// generate thumbnail
sharp(file)
.resize({
height: 50,
}) // keep aspect ratio
.toBuffer()
.then(data => {
res.set('Content-Type', 'image/png');
res.send(data);
});
return;
}
res.sendFile(file);
} else {
if (thumbnail) {
sharp(`${process.cwd()}\\res\\default.png`)
.resize({
height: 50,
}) // keep aspect ratio
.toBuffer()
.then(data => {
res.set('Content-Type', 'image/png');
res.send(data);
});
return;
}
res.sendFile(`${process.cwd()}\\db\\img\\default.png`);
}
} catch (err) {
Logger.Error(err);
res.sendStatus(404);
}
}

View File

@@ -1,6 +1,7 @@
const Logger = require('../logger.js');
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
function listen(port) {
@@ -10,6 +11,9 @@ function listen(port) {
Logger.Info('Setting up basic middleware...');
app.use(Logger.ExpressLogger);
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(express.static('client/public/'));
}