diff --git a/.gitignore b/.gitignore index 150d81008..57cb15ccf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ storage build/* config/local.* config/docker.* +config/dev.* todo.md startDB.bat diff --git a/client/homebrew/pages/basePages/listPage/brewItem/brewItem.less b/client/homebrew/pages/basePages/listPage/brewItem/brewItem.less index aa7625301..4b3751ca3 100644 --- a/client/homebrew/pages/basePages/listPage/brewItem/brewItem.less +++ b/client/homebrew/pages/basePages/listPage/brewItem/brewItem.less @@ -335,7 +335,7 @@ } //these media queries are just for the list layout and could be moved inside it, not sure it is a good idea -@media screen and (max-width : 1400px) { +@container (max-width : 1400px) { .brewCollection .brewGroup.list { .brewItem:not(.detailsOpen) { .info { @@ -361,7 +361,7 @@ } } } -@media screen and (max-width : 1200px) { +@container (max-width : 1200px) { .brewCollection .brewGroup.list { .brewItem:not(.detailsOpen) { grid-template-columns : 35% 3fr 150px; @@ -388,7 +388,7 @@ } } } -@media screen and (max-width : 800px) { +@container (max-width : 800px) { .brewCollection .brewGroup.list { .brewItem:not(.detailsOpen) { .info { @@ -443,7 +443,7 @@ } } } -@media screen and (max-width : 650px) { +@container (max-width : 650px) { .brewCollection .brewGroup.list { .brewItem:not(.detailsOpen) { grid-template-columns : 3fr 2fr; diff --git a/client/homebrew/pages/basePages/listPage/listPage.less b/client/homebrew/pages/basePages/listPage/listPage.less index 389c83abf..7a9286914 100644 --- a/client/homebrew/pages/basePages/listPage/listPage.less +++ b/client/homebrew/pages/basePages/listPage/listPage.less @@ -21,15 +21,16 @@ .sitePage.listPage { --bg-clr : white; + container-type: inline-size; .content { - padding : clamp(20px, 5vh, 200px) clamp(20px, 20vw, 200px); + padding : clamp(20px, 5vh, 200px) clamp(20px, 20cqi, 200px); overflow-y : scroll; scrollbar-gutter : stable; - @media screen and (max-width : 1200px) { - padding : clamp(20px, 5vh, 200px) clamp(20px, 10vw, 200px); + @container (max-width : 1200px) { + padding : clamp(20px, 5vh, 200px) clamp(20px, 10cqi, 200px); } - @media screen and (max-width : 800px) { - padding : clamp(20px, 5vh, 200px) clamp(20px, 5vw, 200px); + @container (max-width : 800px) { + padding : clamp(20px, 5vh, 200px) clamp(20px, 5cqi, 200px); } &::-webkit-scrollbar { @@ -53,6 +54,7 @@ color : white; } .brewCollection { + h1 { width : fit-content; margin-bottom : 20px; @@ -97,9 +99,9 @@ gap : 20px; justify-items : center; - @media screen and (max-width : 1700px) {--columns : 3;} - @media screen and (max-width : 1400px) {--columns : 2;} - @media screen and (max-width : 900px) {--columns : 1;} + @container (max-width : 1700px) {--columns : 3;} + @container (max-width : 1400px) {--columns : 2;} + @container (max-width : 900px) {--columns : 1;} h1 { position : absolute; } &:not(:has(.brewItem:last-child:nth-child(3n))) { diff --git a/client/homebrew/pages/sharePage/sharePage.jsx b/client/homebrew/pages/sharePage/sharePage.jsx index 7f5b1c235..9edc05938 100644 --- a/client/homebrew/pages/sharePage/sharePage.jsx +++ b/client/homebrew/pages/sharePage/sharePage.jsx @@ -46,21 +46,25 @@ const SharePage = (props)=>{ document.addEventListener('keydown', handleControlKeys); fetchThemeBundle(undefined, setThemeBundle, currentBrew.renderer, currentBrew.theme); - // listen for changes in the brew version - const eventSource = new EventSource('/stream'); - eventSource.addEventListener('message', (evt)=>{ - const messageData = JSON.parse(evt.data); + let eventSource; + // Start a stream if the brew has been updated in the last month + if(Date.parse(props.brew?.updatedAt) > (new Date).setDate((new Date).getDate() - 30)){ + // listen for changes in the brew version + eventSource = new EventSource(`/api/stream/${props.brew.shareId}`); - if(messageData.eventType == 'brewUpdated'){ - if(messageData.shareId == currentBrew.shareId && messageData.version != currentBrew.version) { + eventSource.addEventListener('brewUpdated', (evt)=>{ + const messageData = JSON.parse(evt.data); + + if(messageData.version != currentBrew.version) { console.log('should fetch brew'); fetchUpdatedBrew(); } - } - }); + }); + } return ()=>{ document.removeEventListener('keydown', handleControlKeys); + eventSource?.close(); }; }, []); console.log('brew: ', currentBrew); diff --git a/client/homebrew/pages/vaultPage/vaultPage.jsx b/client/homebrew/pages/vaultPage/vaultPage.jsx index e15443db0..0b1ec1f5d 100644 --- a/client/homebrew/pages/vaultPage/vaultPage.jsx +++ b/client/homebrew/pages/vaultPage/vaultPage.jsx @@ -10,6 +10,13 @@ import ErrorIndex from '../errorPage/errors/errorIndex.js'; import request from '../../utils/request-middleware.js'; import Navbar from '../../navbar/navbar.jsx'; +const DEFAULT_SORT_TYPE = 'alpha'; +const DEFAULT_SORT_DIR = 'asc'; +const DEFAULT_LAYOUT_MODE = 'grid'; +const DEFAULT_THEME_MODE = 'light'; +const USERPAGE_LAYOUT_MODE = 'HB_listPage_layout_mode'; +const USERPAGE_THEME_MODE = 'HB_listPage_theme_mode'; + const VaultPage = (props)=>{ const [pageState, setPageState] = useState(parseInt(props.query.page) || 1); @@ -21,6 +28,8 @@ const VaultPage = (props)=>{ const [totalBrews, setTotalBrews] = useState(null); const [searching, setSearching] = useState(false); const [error, setError] = useState(null); + const [layoutMode, setLayoutMode] = useState(DEFAULT_LAYOUT_MODE); + const [themeMode, setThemeMode] = useState(DEFAULT_THEME_MODE); const titleRef = useRef(null); const authorRef = useRef(null); @@ -31,6 +40,13 @@ const VaultPage = (props)=>{ useEffect(()=>{ disableSubmitIfFormInvalid(); + + const newLayoutMode = localStorage.getItem(USERPAGE_LAYOUT_MODE) || DEFAULT_LAYOUT_MODE; + const newThemeMode = localStorage.getItem(USERPAGE_THEME_MODE) || DEFAULT_THEME_MODE; + + setLayoutMode(newLayoutMode); + setThemeMode(newThemeMode); + loadPage(pageState, true, props.query.sort, props.query.dir); }, []); @@ -68,7 +84,7 @@ const VaultPage = (props)=>{ updateStateWithBrews([], 1); }); - if(response.ok) + if(response?.ok) updateStateWithBrews(response.body.brews, page); }; @@ -77,7 +93,8 @@ const VaultPage = (props)=>{ const response = await request.get(`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`) .catch((error)=>{ - console.log('error at loadTotal: ', error); + console.log('error at loadTotal: '); + console.error(error); setError(error); updateStateWithBrews([], 1); }); @@ -239,7 +256,7 @@ const VaultPage = (props)=>{ const oppositeDir = dirState === 'asc' ? 'desc' : 'asc'; return ( -
+
@@ -250,10 +267,10 @@ const VaultPage = (props)=>{ ); }; - const renderSortBar = ()=>{ + const renderSortOptions = ()=>{ return ( -
+
{renderSortOption('Title', 'title', props.query.dir)} {renderSortOption('Created Date', 'createdAt', props.query.dir)} {renderSortOption('Updated Date', 'updatedAt', props.query.dir)} @@ -262,6 +279,64 @@ const VaultPage = (props)=>{ ); }; + const handleLayoutChange = (e, mode)=>{ + setLayoutMode(e.target.checked ? mode : DEFAULT_LAYOUT_MODE); + localStorage.setItem(USERPAGE_LAYOUT_MODE, e.target.checked ? mode : DEFAULT_LAYOUT_MODE); + return; + }; + const renderLayoutModeOptions = ()=>{ + return ( +
+
View:
+ + + +
+ ); + }; + const handleThemeChange = (e, mode)=>{ + setThemeMode(e.target.checked ? mode : DEFAULT_THEME_MODE); + localStorage.setItem(USERPAGE_THEME_MODE, e.target.checked ? mode : DEFAULT_THEME_MODE); + return; + }; + + const renderThemePicker = ()=>{ + return ( +
+
Theme:
+ + +
+ ) + }; + + + const renderNav = () =>{ + return ( +
+ {renderLayoutModeOptions()} + {renderThemePicker()} + {renderSortOptions()} +
+ ); + + } + const renderPaginationControls = ()=>{ if(!totalBrews || totalBrews < 10) return null; @@ -342,13 +417,23 @@ const VaultPage = (props)=>{ } if(error) { - const errorText = ErrorIndex()[error.HBErrorCode.toString()] || ''; + if(error.HBErrorCode === undefined) { + return ( +
+

Error: Unknown error

+
+ ) + } else { - return ( -
+ const errorText = ErrorIndex()[error.HBErrorCode.toString()] || ''; + + return ( +

Error: {errorText}

-
- ); +
+ ); + } + } if(!brewCollection) { @@ -368,22 +453,26 @@ const VaultPage = (props)=>{ } return ( -
+
{`Brews found: `} {totalBrews} {brewCollection.length > 10 && renderPaginationControls()} - {brewCollection.map((brew, index)=>{ - return ( - - ); - })} +
+
+ {brewCollection.map((brew, index)=>{ + return ( + + ); + })} +
+
{renderPaginationControls()}
); @@ -398,7 +487,7 @@ const VaultPage = (props)=>{
{renderForm()}
- {renderSortBar()} + {renderNav()} {renderFoundBrews()}
diff --git a/client/homebrew/pages/vaultPage/vaultPage.less b/client/homebrew/pages/vaultPage/vaultPage.less index a685893c2..85eabe7a0 100644 --- a/client/homebrew/pages/vaultPage/vaultPage.less +++ b/client/homebrew/pages/vaultPage/vaultPage.less @@ -117,69 +117,40 @@ height : 100%; overflow-y : auto; font-size : 0.34cm; - - h3 { - font-family : 'Open Sans'; - font-weight : 900; - color : white; - } - - .sort-container { - display : flex; - flex-wrap : wrap; - column-gap : 15px; - justify-content : center; - height : 30px; - color : white; - background-color : #555555; - border-top : 1px solid #666666; - border-bottom : 1px solid #666666; - - .sort-option { - display : flex; - align-items : center; - padding : 0 8px; - - &:hover { background-color : #444444; } - - &.active { - background-color : #333333; - - button { - font-weight : 800; - color : white; - - & + .sortDir { padding-left : 5px; } - } - } - - button { - padding : 0; - font-size : 11px; - font-weight : normal; - color : #CCCCCC; - text-transform : uppercase; - background-color : transparent; - - &:hover { background : none; } - } - } - } + container-type : inline-size; .foundBrews { - position : relative; - width : 100%; - height : 100%; - max-height : 100%; - padding : 70px 50px; - container-type : inline-size; + display:flex; + flex-direction: column; + gap:20px; + padding : clamp(20px, 5vh, 200px) clamp(20px, 20vw, 200px); overflow-y : scroll; + scrollbar-gutter : stable; + @container (max-width : 1200px) { + padding : clamp(20px, 5vh, 200px) clamp(20px, 10vw, 200px); + } + @container (max-width : 800px) { + padding : clamp(20px, 5vh, 200px) clamp(20px, 5vw, 200px); + } - h3 { font-size : 25px; } + &::-webkit-scrollbar { + width : 15px; + &:horizontal { + width : auto; + height : 20px; + } + &-thumb { + background : linear-gradient(90deg, var(--bg-clr) 12px, #00000000 12px); + &:horizontal { background : linear-gradient(0deg, var(--bg-clr) 15px, #00000000 15px); } + } + &-corner { visibility : hidden; } + } &.noBrews { - display : grid; - place-items : center; + margin : 10px 0px; + font-size : 25px; + font-style : italic; + font-weight : 900; color : white; } @@ -202,6 +173,64 @@ } } + + .brewCollection { + h1 { + width : fit-content; + margin-bottom : 20px; + font-size : 25px; + color : #DDDDDD; + + &.active::before, + &.inactive::before { + padding-right : 0.5em; + font-family : 'Font Awesome 6 Free'; + font-size : 0.6cm; + font-weight : 900; + } + &.active { + color : white; + &::before { content : '\f107'; } + } + &.inactive::before { content : '\f105'; } + } + h1:hover { cursor : pointer; } + + .brewGroup { + margin-bottom : 20px; + &.grid { + display : flex; + flex-wrap : wrap; + gap : 20px; + justify-content : center; + width : fit-content; + min-width : 200px; + } + + &.list { + display : flex; + flex-direction : column; + gap : 2px; + } + &.card { + --columns : 4; + display : grid; + grid-template-columns : repeat(var(--columns), 1fr); + gap : 20px; + justify-items : center; + + @container (max-width : 1700px) {--columns : 3;} + @container (max-width : 1400px) {--columns : 2;} + @container (max-width : 900px) {--columns : 1;} + h1 { position : absolute; } + + &:not(:has(.brewItem:last-child:nth-child(3n))) { + .brewItem:last-child { margin-right : auto; } + } + } + } + } + .totalBrews { position : fixed; right : 0; @@ -233,66 +262,15 @@ } } - .brewItem { - width : 47%; - margin-right : 40px; - color : black; - isolation : isolate; - transition : width 0.5s; - - &::after { - position : absolute; - inset : 0; - z-index : -2; - display : block; - content : ''; - background-image : url('/assets/parchmentBackground.jpg'); - } - - &:nth-child(even of .brewItem) { margin-right : 0; } - - h2 { - font-family : 'MrEavesRemake'; - font-size : 0.75cm; - font-weight : 800; - line-height : 0.988em; - color : var(--HB_Color_HeaderText); - } - .info { - position : relative; - z-index : 2; - font-family : 'ScalySansRemake'; - font-size : 1.2em; - - >span { - margin-right : 12px; - line-height : 1.5em; - } - } - .links { z-index : 2; } - - hr { - visibility : hidden; - margin : 0px; - } - - .thumbnail { z-index : -1; } - } - .paginationControls { - position : absolute; - top : 35px; - left : 50%; display : grid; grid-template-areas : 'previousPage currentPage nextPage'; grid-template-columns : 50px 1fr 50px; gap : 20px; place-items : center; - width : auto; + width : fit-content; + margin-inline:auto; font-size : 15px; - translate : -50%; - - &:last-child { top : unset; } .pages { display : flex; @@ -314,8 +292,11 @@ text-underline-position : under; cursor : pointer; - &.currentPage { + &:hover { color : gold; + } + &.currentPage { + color:@yellow; text-decoration : underline; pointer-events : none; } @@ -339,6 +320,77 @@ } } } + + .listPageNav { + position : relative; + display : flex; + flex-wrap : wrap; + row-gap : 10px; + column-gap : 20px; + align-items : center; + justify-content : center; + width : 100%; + height : fit-content; + font-family : 'Open Sans', sans-serif; + color : white; + background : #555555; + border-top : 1px solid #666666; + border-bottom : 1px solid #666666; + row-rule : 1px solid #666666; + + > div { + width : fit-content; + height : 30px; + padding-inline : 10px; + } + + .radio-toggle { + z-index : 1; + display : flex; + flex-wrap : wrap; + row-gap : 5px; + align-items : center; + justify-content : center; + text-align : center; + h6 { + margin-right : 5px; + font-size : 14px; + } + + .option { + display : flex; + align-items : center; + height : 100%; + padding : 0 8px; + color : #CCCCCC; + + input[type='radio'] { display : none; } + + button { + padding : 0; + font-size : 11px; + font-weight : normal; + text-transform : uppercase; + background-color : transparent; + } + &:hover { background-color : #444444; } + + &.active, &:has(input:checked) { + font-weight : bold; + color : white; + background-color : #333333; + + button { + height : 100%; + font-weight : 800; + & + .sortDir { padding-left : 5px; } + } + } + } + } + + + } } @keyframes trailingDots { @@ -351,16 +403,4 @@ 66%, 100% { content : ' ...'; } -} - -@container (width < 670px) { - .vaultPage { - - .dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; } - - .dataGroup.resultsContainer .foundBrews .brewItem { - width : 100%; - margin-inline : auto; - } - } -} +} \ No newline at end of file diff --git a/server/app.js b/server/app.js index adc7c0136..dde6b6c27 100644 --- a/server/app.js +++ b/server/app.js @@ -20,6 +20,7 @@ import api from './homebrew.api.js'; const { homebrewApi, getBrew, getCSS } = api; import adminApi from './admin.api.js'; import vaultApi from './vault.api.js'; +import streamApi from './stream.api.js'; import pageRoutes from './page-routes.js'; import serveCompressedStaticAssets from './static-assets.mv.js'; @@ -32,7 +33,6 @@ import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import forceSSL from './forcessl.mw.js'; -import Stream from './eventStreamSource.js'; import dbCheck from './middleware/dbCheck.js'; import cors from 'cors'; @@ -115,6 +115,7 @@ export default async function createApp(vite) { app.use(homebrewApi); app.use(adminApi(vite)); app.use(vaultApi); + app.use(streamApi); String.prototype.replaceAll = function(s, r){return this.split(s).join(r);}; @@ -194,26 +195,6 @@ export default async function createApp(vite) { } }); - // Create Event Stream source for pages to listen to - app.get('/stream', (req, res)=>{ - res.writeHead(200, { - 'Content-Type' : 'text/event-stream', - 'Cache-Control' : 'no-cache', - 'Connection' : 'keep-alive', - 'Content-Encoding' : 'none' - }); - - Stream.on('sendUpdate', (event, data)=>{ - if(isLocalEnvironment) { console.log('Event:', event, '\nData:', data); } - res.write(`data: ${JSON.stringify({ ...data, eventType: event })}\n\n`); - }); - }); - - // After Stream starts, send initStream event - setTimeout(()=>{ - Stream.emit('sendUpdate', 'initStream', { time: new Date }); - }, 1000); - // Local only if(isLocalEnvironment){ diff --git a/server/config.js b/server/config.js index 69674595b..06f47c69a 100644 --- a/server/config.js +++ b/server/config.js @@ -4,4 +4,5 @@ export default nconf .argv() .env({ lowerCase: true }) .file('environment', { file: `config/${process.env.NODE_ENV}.json` }) + .file('development', { file: 'config/dev.json' }) .file('defaults', { file: 'config/default.json' }); \ No newline at end of file diff --git a/server/eventStreamSource.js b/server/eventStreamSource.js index 66fdb3354..52db436d1 100644 --- a/server/eventStreamSource.js +++ b/server/eventStreamSource.js @@ -1,9 +1,94 @@ import { EventEmitter } from 'events'; +import _ from 'lodash'; +import { nanoid } from 'nanoid'; + +import config from './config.js'; + +// DEBUG +const DEBUG = { + showEvents : config.get('showStreamEvents'), + showSubscriberCount : config.get('showStreamSubscriberCount') +}; + +// Delays +const REPORT_DELAY = config.get('REPORT_DELAY') || 30 * 60 * 1000; //ms +const HEARTBEAT_DELAY = config.get('HEARTBEAT_DELAY') || 20 * 1000; //ms + const Stream = new EventEmitter; +// Create array of stream subscribers +let subscribers = []; + +// Listener functions +const subscribe = function(shareId, res){ + const id = nanoid(24); // NanoID is assumed globally unique; + const subscriber = { + id, + shareId, + stream : res + }; + + subscribers.push(subscriber); + + Stream.emit('sendUpdate', 'subscribe', { id: subscriber.id, shareId: subscriber.shareId }); + + return subscriber.id; +}; + +const unsubscribe = function(id){ + subscribers + .filter((sub)=>{return sub.id == id;}) + .forEach((sub)=>{ + // Unsubscribe event - not currently used + // Stream.emit('sendUpdate', 'unsubscribe', { id: sub.id, shareId: sub.shareId }); + sub?.stream?.status(200).end(); + }); + + subscribers = subscribers.filter((sub)=>{ return sub.id != id; }); +}; + +// Create heartbeat +const heartbeatId = config.get('node_env') != 'test' ? + setInterval(()=>{ + if(DEBUG.showEvents){ console.log('STREAM HEARTBEAT @ ', new Date); }; + subscribers + .forEach((sub)=>{ + sub?.stream?.write(`:\n\n`); + }); + }, HEARTBEAT_DELAY) + : + undefined; + +Stream.on('close', ()=>{ + console.log('STREAM CLOSE'); + clearInterval(heartbeatId); +}); + +// Create global sendUpdate listener +Stream.on('sendUpdate', (event, data)=>{ + if(DEBUG.showEvents) console.log('Event:', event, '\nData:', data); + subscribers + .filter((sub)=>{ + if(data?.id) return data.id == sub.id; + return data.shareId == sub.shareId; + }) + .forEach((sub)=>{ + sub?.stream?.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }); +}); + +// DEBUG: Report subscriber count periodically +if(DEBUG.showSubscriberCount){ + const reportIntervalId = setInterval(()=>{ + console.log('Subscriber count:', subscribers.length, ' @ ', new Date); + }, REPORT_DELAY); +} + export default { emit : function(event) {return Stream.emit(event, ...([...arguments].slice(1)));}, // Arguments doesn't work for arrow functions on : (event, listener)=>{return Stream.on(event, listener);}, - off : (event, listener)=>{return Stream.off(event, listener);} + off : (event, listener)=>{return Stream.off(event, listener);}, + subscribe, + unsubscribe }; \ No newline at end of file diff --git a/server/stream.api.js b/server/stream.api.js new file mode 100644 index 000000000..79636469c --- /dev/null +++ b/server/stream.api.js @@ -0,0 +1,23 @@ +import express from 'express'; + +import Stream from './eventStreamSource.js'; + +const app = express.Router(); + +// Create Event Stream source for pages to listen to +app.get('/api/stream/:id', (req, res)=>{ + res.writeHead(200, { + 'Content-Type' : 'text/event-stream', + 'Cache-Control' : 'no-cache', + 'Connection' : 'keep-alive', + 'Content-Encoding' : 'none' + }); + + const subId = Stream.subscribe(req.params.id, res); + + res.on('close', ()=>{ + Stream.unsubscribe(subId); + }); +}); + +export default app; \ No newline at end of file