Add PWA, cache busting, cors

updated security and relying on cors for now

clean up header actions buttons

Updated helmet config, responsive styling, icons fixes, and reordering

update header title to left side and actions on right

Unify modal styling

fix login styling

adding paths for saving/editing to public paths for now to restore functionality. we should refactor this to pass pin/session for any request

Bump cache version

remove post install script

Update PIN logic for firefox compatibility and securely handle redirects

Unify add component section/modal

Unifying file uploaders, modal title styles, add collapsible sections for file uploaders
This commit is contained in:
gitmotion 2025-05-08 17:57:25 -07:00
parent d94bc7f95a
commit 49f93f29d8
21 changed files with 2035 additions and 1247 deletions

View File

@ -4,7 +4,7 @@ NODE_ENV=development
DEBUG=TRUE
# Site Configuration
SITE_TITLE=My Dashboard
SITE_TITLE=DumbAssets
# Base URL Configuration (optional)
# Can be a full URL or just a path
@ -15,4 +15,6 @@ BASE_URL=
# PIN Protection
# The PIN environment variable is based on the package name "dumb-boilerplate"
DUMBASSETS_PIN=1234
DUMBASSETS_PIN=1234
ALLOWED_ORIGINS=*

12
.gitignore vendored
View File

@ -133,6 +133,18 @@ dist
.cursor
.cursor/
# OS files
.DS_Store
Thumbs.db
# Data folder for the data volume mapping
data/
# Generated Files
/public/*manifest.json
/public/assets/*manifest.json
/public/pdfjs
# Ignore asset and receipt data
/data/

83
middleware/cors.js Normal file
View File

@ -0,0 +1,83 @@
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS || '*';
const NODE_ENV = process.env.NODE_ENV || 'production';
let allowedOrigins = [];
function setupOrigins(baseUrl) {
allowedOrigins = [ baseUrl ];
if (NODE_ENV === 'development' || ALLOWED_ORIGINS === '*') allowedOrigins = '*';
else if (ALLOWED_ORIGINS && typeof ALLOWED_ORIGINS === 'string') {
try {
const allowed = ALLOWED_ORIGINS.split(',').map(origin => origin.trim());
allowed.forEach(origin => {
const normalizedOrigin = normalizeOrigin(origin);
if (normalizedOrigin !== baseUrl) allowedOrigins.push(normalizedOrigin);
});
}
catch (error) {
console.error(`Error setting up ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}:`, error);
}
}
console.log("ALLOWED ORIGINS:", allowedOrigins);
return allowedOrigins;
}
function normalizeOrigin(origin) {
if (origin) {
try {
const normalizedOrigin = new URL(origin).origin;
return normalizedOrigin;
} catch (error) {
console.error("Error parsing referer URL:", error);
throw new Error("Error parsing referer URL:", error);
}
}
}
function validateOrigin(origin) {
if (NODE_ENV === 'development' || allowedOrigins === '*') return true;
try {
if (origin) origin = normalizeOrigin(origin);
else {
console.warn("No origin to validate.");
return false;
}
console.log("Validating Origin:", origin);
if (allowedOrigins.includes(origin)) {
console.log("Allowed request from origin:", origin);
return true;
}
else {
console.warn("Blocked request from origin:", origin);
return false;
}
}
catch (error) {
console.error(error);
}
}
function originValidationMiddleware(req, res, next) {
const origin = req.headers.referer || `${req.protocol}://${req.headers.host}`;
const isOriginValid = validateOrigin(origin);
if (isOriginValid) {
next();
} else {
res.status(403).json({ error: 'Forbidden' });
}
}
function getCorsOptions(baseUrl) {
const allowedOrigins = setupOrigins(baseUrl);
const corsOptions = {
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
};
return corsOptions;
}
module.exports = { getCorsOptions, originValidationMiddleware, validateOrigin, allowedOrigins };

3
nodemon.json Normal file
View File

@ -0,0 +1,3 @@
{
"ignore": ["asset-manifest.json", "manifest.json"]
}

145
package-lock.json generated
View File

@ -7,9 +7,11 @@
"": {
"name": "dumb-assets",
"version": "1.0.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^4.18.2",
"express-session": "^1.18.1",
@ -23,7 +25,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^2.0.22"
"nodemon": "^3.1.10"
}
},
"node_modules/@napi-rs/canvas": {
@ -363,9 +365,9 @@
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -376,13 +378,13 @@
}
},
"node_modules/call-bound": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"get-intrinsic": "^1.2.6"
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
@ -482,9 +484,9 @@
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -503,15 +505,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -524,6 +517,19 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@ -729,21 +735,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -827,17 +833,17 @@
}
},
"node_modules/get-intrinsic": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
@ -1237,19 +1243,19 @@
}
},
"node_modules/nodemon": {
"version": "2.0.22",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^3.2.7",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^5.7.1",
"simple-update-notifier": "^1.0.7",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
@ -1258,7 +1264,7 @@
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=8.10.0"
"node": ">=10"
},
"funding": {
"type": "opencollective",
@ -1266,13 +1272,21 @@
}
},
"node_modules/nodemon/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/nodemon/node_modules/ms": {
@ -1302,9 +1316,9 @@
}
},
"node_modules/object-inspect": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
"integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -1534,13 +1548,16 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
@ -1697,26 +1714,16 @@
}
},
"node_modules/simple-update-notifier": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "~7.0.0"
"semver": "^7.5.3"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/simple-update-notifier/node_modules/semver": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
"node": ">=10"
}
},
"node_modules/ssf": {

View File

@ -5,8 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"postinstall": "cp -r node_modules/pdfjs-dist/web public/pdfjs"
"dev": "nodemon server.js"
},
"keywords": [
"asset",
@ -18,6 +17,7 @@
"license": "MIT",
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^4.18.2",
"express-session": "^1.18.1",
@ -31,6 +31,6 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^2.0.22"
"nodemon": "^3.1.10"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.2;fill:#CE6746;enable-background:new ;}
.st1{fill:#CE6746;}
.st2{display:none;fill:#50AD3D;}
.st3{display:none;fill:#5F99FF;}
</style>
<circle class="st0" cx="256" cy="256" r="232.7"/>
<path class="st1" d="M256,0C114.8,0,0,114.8,0,256s114.8,256,256,256s256-114.8,256-256S397.2,0,256,0z M256,465.6
c-115.5,0-209.5-94-209.5-209.5S140.5,46.6,256,46.6s209.5,93.9,209.5,209.5C465.5,371.6,371.5,465.6,256,465.6z"/>
<path class="st2" d="M281.1,240.4c-11.2-4.7-52.5-11.2-52.5-29.3c0-13.3,15.4-17.2,24.7-17.2c8.4,0,18.5,2.7,23.9,8
c3.4,3.2,4.8,5.6,5.9,8.9c1.4,3.8,2.3,8,7.6,8h27.5c6.5,0,8.1-1.2,8.1-8.3c0-30.4-23.9-47.6-51.1-53.5v-23.7c0-5.3-1.7-8.6-8.1-8.6
h-21.9c-6.5,0-8.1,3.2-8.1,8.6V156c-29.2,5.6-53.6,23.9-53.6,57.1c0,37.5,26.1,49.4,54.4,58.8c23.9,8,48.8,8.3,48.8,24.8
c0,16.6-12.3,20.1-27.5,20.1c-10.4,0-21.9-2.7-27.5-9.8c-3.6-4.4-5-8.3-5.3-12.1c-0.6-7.4-3.6-8.3-10.1-8.3h-27.2
c-6.5,0-8.1,1.5-8.1,8.3c0,33.7,27,54.1,56.1,60.6v23.1c0,5.3,1.7,8.6,8.1,8.6h21.9c6.5,0,8.1-3.3,8.1-8.6v-22.4
c33.1-5,55.8-25.1,55.8-60.6C331.1,255.4,301.4,246.8,281.1,240.4"/>
<path class="st1" d="M259.9,271.2l-103,67.4c-8.8,5.8-20.2,2.4-25.4-7.7c-5.2-10.1-2-22.9,6.8-28.7l74.8-49l-77.3-45.8
c-9.1-5.4-12.6-18.1-7.9-28.4c4.7-10.3,16-14.3,25-8.9l105.5,62.4c3.9,3.1,7.1,6.4,8.7,11C271.6,253.3,268.5,265.6,259.9,271.2z
M386.2,328L386.2,328c0-9-7.3-16.4-16.4-16.4h-86.8c-9,0-16.4,7.3-16.4,16.4v0c0,9,7.3,16.4,16.4,16.4h86.8
C378.9,344.3,386.2,337,386.2,328z"/>
<path class="st3" d="M354.3,147.4h-18.5v-7.1c0-4.2-3.4-7.6-7.6-7.6h-7.8c-4.2,0-7.6,3.4-7.6,7.6v7.1l0,0H199.2v-7.1
c0-4.2-3.4-7.6-7.6-7.6h-7.8c-4.2,0-7.6,3.4-7.6,7.6v7.1h-18.5c-17.6,0-31.9,14.3-31.9,31.9v25.6v142.5c0,17.6,14.3,31.9,31.9,31.9
h196.5c17.6,0,31.9-14.3,31.9-31.9V204.9v-25.6C386.1,161.7,371.8,147.4,354.3,147.4z M143.2,179.3c0-8,6.5-14.5,14.5-14.5h18.5v7.1
c0,4.2,3.4,7.6,7.6,7.6h7.8c4.2,0,7.6-3.4,7.6-7.6v-7.1h113.7l0,0v7.1c0,4.2,3.4,7.6,7.6,7.6h7.8c4.2,0,7.6-3.4,7.6-7.6v-7.1h18.5
c8,0,14.5,6.5,14.5,14.5v17H143.2V179.3z M368.8,347.4c0,8-6.5,14.5-14.5,14.5H157.7c-8,0-14.5-6.5-14.5-14.5V213.6h225.5
L368.8,347.4L368.8,347.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -17,7 +17,7 @@ function setCookie(name, value, days = 365) {
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = "expires=" + d.toUTCString();
document.cookie = name + "=" + value + ";" + expires + ";path=/;SameSite=Strict";
debug(`Set cookie ${name}=${value}`);
// debug(`Set cookie ${name}=${value}`);
}
function getCookie(name) {
@ -34,8 +34,8 @@ function getCookie(name) {
// Toggle between light and dark themes
function toggleTheme(event) {
console.log("toggleTheme function called directly");
debug("toggleTheme function called directly");
// console.log("toggleTheme function called directly");
// debug("toggleTheme function called directly");
// Prevent any default behavior
if (event) {
@ -46,7 +46,7 @@ function toggleTheme(event) {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
debug(`Changing theme from ${currentTheme} to ${newTheme}`);
// debug(`Changing theme from ${currentTheme} to ${newTheme}`);
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
@ -58,7 +58,7 @@ function toggleTheme(event) {
function updateThemeToggleState(theme) {
const toggleButton = document.getElementById('themeToggle');
if (toggleButton) {
debug(`Updating toggle button state for theme: ${theme}`);
// debug(`Updating toggle button state for theme: ${theme}`);
toggleButton.setAttribute('aria-pressed', theme === 'dark');
toggleButton.setAttribute('title', `Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`);
}
@ -66,15 +66,15 @@ function updateThemeToggleState(theme) {
// Set up theme toggle functionality
function setupThemeToggle() {
console.log("Setting up theme toggle");
debug("Setting up theme toggle");
// console.log("Setting up theme toggle");
// debug("Setting up theme toggle");
const themeToggle = document.getElementById('themeToggle');
console.log("Theme toggle element:", themeToggle);
// console.log("Theme toggle element:", themeToggle);
if (!themeToggle) {
console.log("Theme toggle button not found!");
debug("Theme toggle button not found");
// console.log("Theme toggle button not found!");
// debug("Theme toggle button not found");
return false;
}
@ -83,14 +83,14 @@ function setupThemeToggle() {
// Add click handler directly
themeToggle.onclick = function(e) {
console.log("Theme toggle clicked via onclick");
// console.log("Theme toggle clicked via onclick");
toggleTheme(e);
};
// Add keyboard support
themeToggle.onkeydown = function(e) {
if (e.key === 'Enter' || e.key === ' ') {
console.log("Theme toggle triggered via keyboard");
// console.log("Theme toggle triggered via keyboard");
e.preventDefault();
toggleTheme(e);
}
@ -104,36 +104,36 @@ function setupThemeToggle() {
const currentTheme = document.documentElement.getAttribute('data-theme');
updateThemeToggleState(currentTheme);
console.log("Theme toggle setup complete");
debug("Theme toggle setup complete");
// console.log("Theme toggle setup complete");
// debug("Theme toggle setup complete");
return true;
}
// Initialize theme based on localStorage or system preference
function initTheme() {
debug("Initializing theme");
// debug("Initializing theme");
const savedTheme = localStorage.getItem('theme');
debug(`Saved theme from localStorage: ${savedTheme || 'none'}`);
// debug(`Saved theme from localStorage: ${savedTheme || 'none'}`);
const prefersDarkMedia = window.matchMedia('(prefers-color-scheme: dark)');
const prefersDark = prefersDarkMedia.matches;
debug(`System prefers dark mode: ${prefersDark}`);
// debug(`System prefers dark mode: ${prefersDark}`);
// Set initial theme
let initialTheme;
if (savedTheme) {
// User has explicitly chosen a theme
initialTheme = savedTheme;
debug(`Using saved theme: ${savedTheme}`);
// debug(`Using saved theme: ${savedTheme}`);
} else {
// Use system preference
initialTheme = prefersDark ? 'dark' : 'light';
debug(`Using system theme: ${initialTheme}`);
// debug(`Using system theme: ${initialTheme}`);
}
// Apply the theme
document.documentElement.setAttribute('data-theme', initialTheme);
debug(`Applied theme: ${initialTheme}`);
// debug(`Applied theme: ${initialTheme}`);
// Listen for system preference changes
prefersDarkMedia.addEventListener('change', (e) => {
@ -142,15 +142,15 @@ function initTheme() {
const newTheme = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
updateThemeToggleState(newTheme);
debug(`System preference changed, set theme to: ${newTheme}`);
// debug(`System preference changed, set theme to: ${newTheme}`);
}
});
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
console.log("DOM Content Loaded - setting up theme");
debug("DOM Content Loaded - setting up theme");
// console.log("DOM Content Loaded - setting up theme");
// debug("DOM Content Loaded - setting up theme");
// Update page titles
const pageTitle = document.getElementById('pageTitle');
@ -168,7 +168,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Also try on window load just in case
window.addEventListener('load', () => {
console.log("Window loaded - checking theme toggle");
// console.log("Window loaded - checking theme toggle");
setupThemeToggle();
});

10
public/helpers/paths.js Normal file
View File

@ -0,0 +1,10 @@
// Helper function to join paths with base path
export const joinPath = (path) => {
const basePath = window.appConfig?.basePath || '';
// Remove any leading slash from path and trailing slash from basePath
const cleanPath = path.replace(/^\/+/, '');
const cleanBase = basePath.replace(/\/+$/, '');
// Join with single slash
return cleanBase ? `${cleanBase}/${cleanPath}` : cleanPath;
};

View File

@ -0,0 +1,67 @@
import { joinPath } from '../helpers/paths.js';
// Function to register the service worker
export const registerServiceWorker = () => {
if ("serviceWorker" in navigator) {
// Add cache-busting version parameter to service worker URL
const swVersion = new Date().getTime(); // Use timestamp as version
navigator.serviceWorker.register(joinPath(`service-worker.js?v=${swVersion}`))
.then((reg) => {
console.log("Service Worker registered:", reg.scope);
// Check version immediately after registration
checkVersion();
})
.catch((err) => console.log("Service Worker registration failed:", err));
// Listen for version messages from the service worker
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'UPDATE_AVAILABLE') {
console.log(`Update available: ${event.data.newVersion} (current: ${event.data.currentVersion})`);
// Tell service worker to perform the update
navigator.serviceWorker.controller.postMessage({ type: 'PERFORM_UPDATE' });
} else if (event.data.type === 'UPDATE_COMPLETE') {
console.log(`Update complete to version: ${event.data.version}`);
// Only reload if update was successful
if (event.data.success !== false) {
console.log("Reloading page to apply new cache");
window.location.reload();
}
}
});
// Check for version updates when the page becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && navigator.serviceWorker.controller) {
checkVersion();
}
});
// // Also check periodically for version updates
// setInterval(() => {
// if (navigator.serviceWorker.controller) {
// checkVersion();
// }
// }, 60 * 60 * 1000); // Check every hour
}
}
// Check the current service worker version
function checkVersion() {
if (!navigator.serviceWorker.controller) return;
// Create a message channel for the response
const messageChannel = new MessageChannel();
// Listen for the response
messageChannel.port1.onmessage = (event) => {
if (event.data.currentVersion !== event.data.newVersion) {
console.log("New version available:", event.data.newVersion);
}
};
// Ask the service worker for its version
navigator.serviceWorker.controller.postMessage(
{ type: 'GET_VERSION' },
[messageChannel.port2]
);
}

View File

@ -3,55 +3,65 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{SITE_TITLE}}</title>
<title id="pageTitle">DumbAssets</title>
<script src="config.js"></script>
<link rel="stylesheet" href="styles.css">
<link rel="icon" type="image/svg+xml" href="assets/dumbassets.svg">
<link rel="manifest" href="manifest.json">
</head>
<body>
<main>
<div class="container">
<div class="header-row">
<button id="sidebarToggle" class="sidebar-toggle" aria-label="Toggle asset list"></button>
<h1 id="siteTitle">{{SITE_TITLE}}</h1>
<div id="header-title" class="header-title">
<h1 id="siteTitle">DumbAssets</h1>
</div>
<div class="header-actions">
<button id="homeBtn" class="home-btn" aria-label="Go to Dashboard">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12L12 4l8 8"></path>
<path d="M5 12v7a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-3a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v3a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-7"></path>
<button id="homeBtn" class="header-btn" aria-label="Go to Dashboard">
<svg xmlns="http://www.w3.org/2000/svg">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M5 12l-2 0l9 -9l9 9l-2 0" />
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" />
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
</svg>
</button>
<button id="notificationBtn" class="notification-btn" aria-label="Notification Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 8 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 5 15.4a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 8a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 8 5.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09A1.65 1.65 0 0 0 16 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 8c.14.31.22.65.22 1v.09A1.65 1.65 0 0 0 21 12c0 .35-.08.69-.22 1z"></path>
<button id="notificationBtn" class="header-btn" aria-label="Notification Settings">
<svg xmlns="http://www.w3.org/2000/svg">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" />
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>
</button>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme">
<svg class="moon" viewBox="0 0 24 24">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg class="sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y1="4.22"></line>
</svg>
</button>
<svg class="moon">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" />
</svg>
<svg class="sun">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14.828 14.828a4 4 0 1 0 -5.656 -5.656a4 4 0 0 0 5.656 5.656z" />
<path d="M6.343 17.657l-1.414 1.414" />
<path d="M6.343 6.343l-1.414 -1.414" />
<path d="M17.657 6.343l1.414 -1.414" />
<path d="M17.657 17.657l1.414 1.414" />
<path d="M4 12h-2" />
<path d="M12 4v-2" />
<path d="M20 12h2" />
<path d="M12 20v2" />
</svg>
</button>
<button id="sidebarToggle" class="sidebar-toggle hidden" aria-label="Toggle asset list"></button>
</div>
<div class="header-spacer"></div>
</div>
<div class="app-container">
<!-- Left panel: Asset list with search -->
<div class="sidebar">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Search assets...">
<button id="clearSearchBtn" class="clear-search-btn" style="display:none;">&times;</button>
<!-- <div>
<button id="clearSearchBtn" class="clear-search-btn" style="display:none;">&times;</button>
<button id="sidebarCloseBtn" class="sidebar-close-btn" style="display:none;" aria-label="Close sidebar">&times;</button>
</div> -->
</div>
<div class="button-container">
<button id="addAssetBtn" class="action-button">Add Asset</button>
@ -83,7 +93,6 @@
</div>
</div>
</div>
<button id="sidebarCloseBtn" class="sidebar-close-btn" style="display:none;" aria-label="Close sidebar">&times;</button>
<!-- Right panel: Asset details and sub-assets -->
<div class="main-content">
@ -105,8 +114,12 @@
<!-- Add/Edit Asset Modal -->
<div id="assetModal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>
<h2 id="modalTitle">Add Asset</h2>
<div class="modal-header">
<h2 id="addAssetTitle" class="modal-title">Add Asset</h2>
<div class="close-btn">
<span>&times;</span>
</div>
</div>
<form id="assetForm">
<div class="form-group">
<label for="assetName">Name *</label>
@ -136,55 +149,67 @@
<label for="assetWarrantyExpiration">Warranty Expiration</label>
<input type="date" id="assetWarrantyExpiration" name="warrantyExpiration">
</div>
<div class="form-group">
<label for="assetPhoto">Photo</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="assetPhoto">
<input type="file" id="assetPhoto" accept="image/*" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span>Drag & drop or click to upload photos</span>
<div class="collapsible-section" data-collapsed="true">
<div class="collapsible-header">
<h3>File Attachments</h3>
<svg class="collapsible-toggle" viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="collapsible-content">
<div class="form-group">
<label for="assetPhoto">Photo</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="assetPhoto">
<input type="file" id="assetPhoto" accept="image/*" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span>Drag & drop or click to upload photos</span>
</div>
</div>
<div id="photoPreview" class="preview-grid"></div>
</div>
</div>
<div id="photoPreview" class="preview-grid"></div>
</div>
</div>
<div class="form-group">
<label for="assetReceipt">Receipt</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="assetReceipt">
<input type="file" id="assetReceipt" accept=".pdf,.jpg,.jpeg,.png" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>Drag & drop or click to upload receipts</span>
<div class="form-group">
<label for="assetReceipt">Receipt</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="assetReceipt">
<input type="file" id="assetReceipt" accept=".pdf,.jpg,.jpeg,.png" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>Drag & drop or click to upload receipts</span>
</div>
</div>
<div id="receiptPreview" class="preview-grid"></div>
</div>
</div>
<div id="receiptPreview" class="preview-grid"></div>
</div>
</div>
<div class="form-group">
<label for="assetManual">Manual</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="assetManual">
<input type="file" id="assetManual" accept=".pdf" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>Drag & drop or click to upload manuals</span>
<div class="form-group">
<label for="assetManual">Manual</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="assetManual">
<input type="file" id="assetManual" accept=".pdf" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>Drag & drop or click to upload manuals</span>
</div>
</div>
<div id="manualPreview" class="preview-grid"></div>
</div>
</div>
<div id="manualPreview" class="preview-grid"></div>
</div>
</div>
<div class="form-group">
<label for="assetLink">Link</label>
<input type="url" id="assetLink" name="link">
@ -204,8 +229,12 @@
<!-- Add/Edit Sub-Asset Modal -->
<div id="subAssetModal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>
<h2 id="subModalTitle">Add Component</h2>
<div class="modal-header">
<h2 id="addComponentTitle" class="modal-title">Add Component</h2>
<div>
<span class="close-btn">&times;</span>
</div>
</div>
<form id="subAssetForm">
<input type="hidden" id="subAssetId" name="id">
<input type="hidden" id="parentAssetId" name="parentId">
@ -250,23 +279,65 @@
<label for="subAssetNotes">Notes</label>
<textarea id="subAssetNotes" name="notes"></textarea>
</div>
<div class="form-group">
<label>Photo</label>
<input type="file" id="subAssetPhoto" name="photo" accept="image/*">
<div id="subPhotoPreview" class="preview-container"></div>
</div>
<div class="form-group">
<label>Receipt</label>
<input type="file" id="subAssetReceipt" name="receipt" accept=".pdf,.jpg,.jpeg,.png">
<div id="subReceiptPreview" class="preview-container"></div>
</div>
<div class="form-group">
<label>Manual</label>
<input type="file" id="subAssetManual" name="manual" accept=".pdf">
<div id="subManualPreview" class="preview-container"></div>
<div class="collapsible-section" data-collapsed="true">
<div class="collapsible-header">
<h3>File Attachments</h3>
<svg class="collapsible-toggle" viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="collapsible-content">
<div class="form-group">
<label for="subAssetPhoto">Photo</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="subAssetPhoto">
<input type="file" id="subAssetPhoto" accept="image/*" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span>Drag & drop or click to upload photos</span>
</div>
</div>
<div id="subPhotoPreview" class="preview-grid"></div>
</div>
</div>
<div class="form-group">
<label for="subAssetReceipt">Receipt</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="subAssetReceipt">
<input type="file" id="subAssetReceipt" accept=".pdf,.jpg,.jpeg,.png" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>Drag & drop or click to upload receipts</span>
</div>
</div>
<div id="subReceiptPreview" class="preview-grid"></div>
</div>
</div>
<div class="form-group">
<label for="subAssetManual">Manual</label>
<div class="file-upload-grid">
<div class="file-upload-box" data-target="subAssetManual">
<input type="file" id="subAssetManual" accept=".pdf,.doc,.docx,.md,.txt" class="file-input" multiple>
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>Drag & drop or click to upload manuals</span>
</div>
</div>
<div id="subManualPreview" class="preview-grid"></div>
</div>
</div>
</div>
</div>
<div class="form-actions">
@ -281,16 +352,34 @@
<div id="importModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Import Assets</h2>
<span class="close">&times;</span>
<h2 id="importAssetTitle" class="modal-title">Import Assets</h2>
<div>
<span class="close-btn">&times;</span>
</div>
</div>
<div class="modal-body">
<div class="import-container">
<div class="file-upload-container">
<div class="form-group">
<!-- <label for="importFile">Import</label> -->
<div class="file-upload-grid">
<div class="file-upload-box" data-target="importFile">
<input type="file" id="importFile" accept=".csv,.xls,.xlsx" class="file-input">
<div class="upload-content">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>Accepted types: .csv, .xls, .xlsx</span>
</div>
</div>
<div id="importFilePreview" class="preview-grid"></div>
</div>
</div>
<!-- <div class="file-upload-container">
<input type="file" id="importFile" accept=".csv,.xls,.xlsx" class="file-input">
<label for="importFile" class="file-label">Choose File</label>
<span id="selectedFileName" class="selected-file">No file chosen</span>
</div>
</div> -->
<div class="column-mapping">
<h3>Column Mapping</h3>
<div class="mapping-container">
@ -344,8 +433,10 @@
<div id="notificationModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Notification Settings</h2>
<span class="close">&times;</span>
<h2 id="noficationSettingsTitle" class="modal-title">Notification Settings</h2>
<div class="close-btn">
<span>&times;</span>
</div>
</div>
<div class="modal-body">
<form id="notificationForm">
@ -364,10 +455,8 @@
</fieldset>
<div class="modal-actions">
<button type="button" id="saveNotificationSettings" class="action-button">Save</button>
<button type="button" id="cancelNotificationSettings" class="action-button">Cancel</button>
</div>
<div class="modal-actions" style="justify-content: flex-start; margin-top: 0.5em;">
<button type="button" id="testNotificationSettings" class="action-button" style="background: var(--secondary-color);">Test</button>
<button type="button" id="testNotificationSettings" class="action-button" style="background: var(--success-color);">Test</button>
<button type="button" id="cancelNotificationSettings" class="action-button" style="background: var(--secondary-color);">Cancel</button>
</div>
</form>
</div>
@ -378,8 +467,6 @@
<div class="dumbware-credit">
Built by <a href="https://dumbware.io" target="_blank" rel="noopener noreferrer">DumbWare</a>
</div>
<script src="script.js" type="module"></script>
<div id="toast" class="toast"></div>
</body>

154
public/js/collapsible.js Normal file
View File

@ -0,0 +1,154 @@
/**
* Simple reusable collapsible sections
* Usage:
* 1. Add class="collapsible-section" to your container
* 2. Add class="collapsible-header" to your clickable header element
* 3. Add class="collapsible-content" to the content you want to collapse
* 4. Add data-collapsed="true" to start collapsed (optional)
*/
// Initialize all collapsible sections
function initCollapsibleSections() {
// Allow a small delay for the DOM to fully render in modals
setTimeout(() => {
document.querySelectorAll('.collapsible-section').forEach(section => {
setupCollapsible(section);
});
}, 10);
// Add window resize handler to update expanded sections only
if (!window._collapsibleResizeHandlerAdded) {
window.addEventListener('resize', debounce(() => {
// Only update sections that are explicitly expanded (not collapsed)
document.querySelectorAll('.collapsible-section:not(.collapsed)').forEach(section => {
const content = section.querySelector('.collapsible-content');
if (content) {
// Don't use ensureContentHeight which could cause issues
// Just set the height directly
requestAnimationFrame(() => {
content.style.height = (content.scrollHeight + 5) + 'px';
});
}
});
}, 250));
window._collapsibleResizeHandlerAdded = true;
}
}
// Debounce helper function to limit resize handler calls
function debounce(func, wait) {
let timeout;
return function() {
const context = this;
const args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Set up a single collapsible section
function setupCollapsible(section) {
const header = section.querySelector('.collapsible-header');
const content = section.querySelector('.collapsible-content');
if (!header || !content) return;
// Store the handler in a data attribute to ensure we can properly remove it
if (header._clickHandler) {
header.removeEventListener('click', header._clickHandler);
}
header._clickHandler = function() {
toggleCollapsible(section);
};
header.addEventListener('click', header._clickHandler);
// Set initial state based on data attribute
const startCollapsed = section.getAttribute('data-collapsed') === 'true';
if (startCollapsed) {
section.classList.add('collapsed');
content.style.height = '0px';
} else {
section.classList.remove('collapsed');
// Make sure the content has rendered before calculating height
ensureContentHeight(content);
}
}
// Toggle a collapsible section
function toggleCollapsible(section) {
const content = section.querySelector('.collapsible-content');
const isCollapsed = section.classList.contains('collapsed');
if (isCollapsed) {
// Expand
section.classList.remove('collapsed');
// Make sure the content has rendered before calculating height
ensureContentHeight(content);
} else {
// Collapse
section.classList.add('collapsed');
// Set height to 0 immediately
content.style.height = '0px';
}
}
// Ensure the content height is calculated properly
function ensureContentHeight(content) {
// Only calculate height if the parent section isn't collapsed
const parentSection = content.closest('.collapsible-section');
if (parentSection && parentSection.classList.contains('collapsed')) {
return;
}
// Force a reflow to get accurate scrollHeight
content.style.display = 'none';
content.offsetHeight; // Trigger reflow
content.style.display = '';
// Set the height after a tiny delay to ensure rendering
requestAnimationFrame(() => {
// Add a bit of extra height to account for any potential rendering issues
content.style.height = (content.scrollHeight + 5) + 'px';
});
}
// Manually expand a section by selector
function expandSection(selector) {
const section = document.querySelector(selector);
if (section) {
// Only expand if it's currently collapsed
if (section.classList.contains('collapsed')) {
section.classList.remove('collapsed');
const content = section.querySelector('.collapsible-content');
if (content) {
requestAnimationFrame(() => {
content.style.height = (content.scrollHeight + 5) + 'px';
});
}
}
}
}
// Manually collapse a section by selector
function collapseSection(selector) {
const section = document.querySelector(selector);
if (section) {
// Only collapse if it's not already collapsed
if (!section.classList.contains('collapsed')) {
section.classList.add('collapsed');
const content = section.querySelector('.collapsible-content');
if (content) content.style.height = '0px';
}
}
}
export {
initCollapsibleSections,
setupCollapsible,
toggleCollapsible,
expandSection,
collapseSection
};

View File

@ -3,30 +3,32 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{SITE_TITLE}}</title>
<title id="pageTitle">DumbAssets</title>
<script src="config.js"></script>
<link rel="stylesheet" href="styles.css">
<link rel="icon" type="image/svg+xml" href="assets/dumbassets.svg">
</head>
<body>
<main>
<form id="pinForm" action="verify-pin" method="POST">
<div class="header-row">
<div class="header-spacer"></div>
<h1 id="siteTitle">{{SITE_TITLE}}</h1>
<form id="pinForm">
<div class="pin-header-row">
<h1 id="siteTitle">DumbAssets</h1>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme" role="button" type="button">
<svg class="moon" viewBox="0 0 24 24">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
<svg class="moon">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" />
</svg>
<svg class="sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y1="4.22"></line>
<svg class="sun">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14.828 14.828a4 4 0 1 0 -5.656 -5.656a4 4 0 0 0 5.656 5.656z" />
<path d="M6.343 17.657l-1.414 1.414" />
<path d="M6.343 6.343l-1.414 -1.414" />
<path d="M17.657 6.343l1.414 -1.414" />
<path d="M17.657 17.657l1.414 1.414" />
<path d="M4 12h-2" />
<path d="M12 4v-2" />
<path d="M20 12h2" />
<path d="M12 20v2" />
</svg>
</button>
</div>
@ -42,144 +44,171 @@
<script>
// PIN input functionality
document.addEventListener('DOMContentLoaded', async function() {
// Theme handled by config.js
// Get PIN length from server
const pinContainer = document.querySelector('.pin-input-container');
const pinForm = document.getElementById('pinForm');
const errorDisplay = document.querySelector('.pin-error');
let pinLength = 4; // Default
try {
// Get actual pin length from server
const response = await fetch('pin-length');
if (response.ok) {
const data = await response.json();
pinLength = data.length || 4;
}
} catch (error) {
console.error('Error fetching PIN length:', error);
}
// Create PIN inputs
for (let i = 0; i < pinLength; i++) {
const input = document.createElement('input');
input.type = 'password';
input.maxLength = 1;
input.className = 'pin-input';
input.name = `pin${i}`;
input.setAttribute('inputmode', 'numeric');
input.setAttribute('pattern', '[0-9]*');
input.required = true;
const submitPin = async () => {
// Collect PIN from inputs
const inputs = Array.from(pinContainer.querySelectorAll('input'));
const pin = inputs.map(input => input.value).join('').trim();
input.addEventListener('input', function(e) {
// Handle input changes
if (this.value) {
this.classList.add('has-value');
// Auto-advance to next input
const nextInput = this.nextElementSibling;
if (nextInput && nextInput.classList.contains('pin-input')) {
nextInput.focus();
} else {
// If this is the last input and it has a value, submit the form
const allInputs = Array.from(pinContainer.querySelectorAll('input'));
const allFilled = allInputs.every(input => input.value.trim() !== '');
if (allFilled) {
// Short delay to show the filled input before submitting
setTimeout(() => {
pinForm.dispatchEvent(new Event('submit'));
}, 300);
}
}
} else {
this.classList.remove('has-value');
try {
const response = await fetch('verify-pin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pin }),
credentials: 'same-origin', // Ensure cookies are sent
redirect: 'follow' // Follow server redirects
});
// If redirected, the response will be a redirect status (3xx)
if (response.redirected) {
window.location.replace(response.url);
return;
}
// Hide error when user starts typing again
errorDisplay.setAttribute('aria-hidden', 'true');
});
input.addEventListener('keydown', function(e) {
// Handle backspace navigation
if (e.key === 'Backspace' && !this.value) {
const prevInput = this.previousElementSibling;
if (prevInput && prevInput.classList.contains('pin-input')) {
prevInput.focus();
prevInput.value = '';
prevInput.classList.remove('has-value');
e.preventDefault();
}
const data = await response.json();
if (response.status === 429) {
// Handle lockout
errorDisplay.textContent = data.error;
errorDisplay.setAttribute('aria-hidden', 'false');
inputs.forEach(input => {
input.value = '';
input.classList.remove('has-value');
input.disabled = true;
});
} else {
// Handle invalid PIN
const message = data.attemptsLeft > 0
? `Incorrect PIN. ${data.attemptsLeft} attempts remaining.`
: 'Incorrect PIN. Last attempt before lockout.';
errorDisplay.textContent = message;
errorDisplay.setAttribute('aria-hidden', 'false');
inputs.forEach(input => {
input.value = '';
input.classList.remove('has-value');
});
inputs[0].focus();
}
});
pinContainer.appendChild(input);
} catch (error) {
console.error('Error:', error);
errorDisplay.textContent = 'An error occurred. Please try again.';
errorDisplay.setAttribute('aria-hidden', 'false');
}
}
// Handle form submission
pinForm.addEventListener('submit', async function(e) {
// Prevent the default form submission
e.preventDefault();
try {
await submitPin();
}
catch (error) {
console.error('Error:', error);
}
});
// Fetch PIN length from server
fetch('pin-length')
.then(response => response.json())
.then(data => {
const pinLength = data.length;
const container = document.querySelector('.pin-input-container');
// Create PIN input fields
for (let i = 0; i < pinLength; i++) {
const input = document.createElement('input');
input.type = 'password';
input.maxLength = 1;
input.className = 'pin-input';
input.setAttribute('inputmode', 'numeric');
input.pattern = '[0-9]*';
input.setAttribute('autocomplete', 'off');
container.appendChild(input);
}
// Handle input behavior
const inputs = container.querySelectorAll('.pin-input');
// Focus first input immediately
if (inputs.length > 0) {
inputs[0].focus();
}
inputs.forEach((input, index) => {
input.addEventListener('input', (e) => {
// Only allow numbers
e.target.value = e.target.value.replace(/[^0-9]/g, '');
if (e.target.value) {
e.target.classList.add('has-value');
if (index < inputs.length - 1) {
inputs[index + 1].focus();
} else {
// Last digit entered, submit the form
const pin = Array.from(inputs).map(input => input.value).join('');
submitPin(pin, inputs);
}
} else {
e.target.classList.remove('has-value');
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && index > 0) {
inputs[index - 1].focus();
}
});
// Prevent paste of multiple characters
input.addEventListener('paste', (e) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text');
const numbers = pastedData.match(/\d/g);
if (numbers) {
numbers.forEach((num, i) => {
if (inputs[index + i]) {
inputs[index + i].value = num;
inputs[index + i].classList.add('has-value');
if (index + i + 1 < inputs.length) {
inputs[index + i + 1].focus();
} else {
// If paste fills all inputs, submit the form
const pin = Array.from(inputs).map(input => input.value).join('');
submitPin(pin, inputs);
}
}
});
}
});
});
});
// Focus first input on page load
setTimeout(() => {
const firstInput = pinContainer.querySelector('input');
if (firstInput) firstInput.focus();
}, 100);
// Handle form submission
pinForm.addEventListener('submit', async function(e) {
e.preventDefault();
// Collect PIN from inputs
const inputs = Array.from(pinContainer.querySelectorAll('input'));
const pin = inputs.map(input => input.value).join('');
try {
const response = await fetch('verify-pin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pin })
});
if (response.ok) {
// Successful login, redirect to main app
window.location.href = '/';
} else {
// Show error
errorDisplay.setAttribute('aria-hidden', 'false');
// Clear inputs and focus first one
inputs.forEach(input => {
input.value = '';
input.classList.remove('has-value');
});
inputs[0].focus();
// If we have more info about the error, show it
try {
const errorData = await response.json();
if (errorData.error) {
errorDisplay.textContent = errorData.error;
}
} catch (e) {
// Ignore parsing error
}
}
} catch (error) {
console.error('Error verifying PIN:', error);
errorDisplay.textContent = 'Error connecting to server. Please try again.';
errorDisplay.setAttribute('aria-hidden', 'false');
}
});
});
document.addEventListener('DOMContentLoaded', function() {
// Set the page and site title from config if available
if (window.appConfig && window.appConfig.siteTitle) {
// Set header
const siteTitleElem = document.getElementById('siteTitle');
if (siteTitleElem) {
siteTitleElem.textContent = window.appConfig.siteTitle;
siteTitleElem.textContent = window.appConfig.siteTitle || 'DumbAssets';
}
const pageTitleElem = document.getElementById('pageTitle');
if (pageTitleElem) {
pageTitleElem.textContent = window.appConfig.siteTitle || 'DumbAssets';
}
// Set tab title
document.title = window.appConfig.siteTitle;
}
});
</script>

View File

@ -19,6 +19,9 @@ import {
renderAssetList,
sortAssets
} from '/src/services/render/index.js';
import { registerServiceWorker } from './helpers/serviceWorkerHelper.js';
// Import collapsible sections functionality
import { initCollapsibleSections } from './js/collapsible.js';
// State management
let assets = [];
@ -61,7 +64,7 @@ const sortWarrantyBtn = document.getElementById('sortWarrantyBtn');
const importModal = document.getElementById('importModal');
const importBtn = document.getElementById('importAssetsBtn');
const importFile = document.getElementById('importFile');
const selectedFileName = document.getElementById('selectedFileName');
// const selectedFileName = document.getElementById('selectedFileName');
const startImportBtn = document.getElementById('startImportBtn');
const columnSelects = document.querySelectorAll('.column-select');
@ -71,7 +74,7 @@ const notificationModal = document.getElementById('notificationModal');
const notificationForm = document.getElementById('notificationForm');
const saveNotificationSettings = document.getElementById('saveNotificationSettings');
const cancelNotificationSettings = document.getElementById('cancelNotificationSettings');
const notificationClose = notificationModal.querySelector('.close');
const notificationClose = notificationModal.querySelector('.close-btn');
const testNotificationSettings = document.getElementById('testNotificationSettings');
// Utility Functions
@ -393,11 +396,11 @@ function renderDashboard() {
<div class="card-value">${allWarranties.length}</div>
</div>
<div class="dashboard-card within60${dashboardFilter === 'within60' ? ' active' : ''}" data-filter="within60">
<div class="card-label">Within 60 days</div>
<div class="card-label">In 60 days</div>
<div class="card-value">${within60}</div>
</div>
<div class="dashboard-card within30${dashboardFilter === 'within30' ? ' active' : ''}" data-filter="within30">
<div class="card-label">Within 30 days</div>
<div class="card-label">In 30 days</div>
<div class="card-value">${within30}</div>
</div>
<div class="dashboard-card expired${dashboardFilter === 'expired' ? ' active' : ''}" data-filter="expired">
@ -443,7 +446,7 @@ function renderEmptyState() {
function openAssetModal(asset = null) {
if (!assetModal || !assetForm) return;
isEditMode = !!asset;
document.getElementById('modalTitle').textContent = isEditMode ? 'Edit Asset' : 'Add Asset';
document.getElementById('addAssetTitle').textContent = isEditMode ? 'Edit Asset' : 'Add Asset';
assetForm.reset();
deletePhoto = false;
deleteReceipt = false;
@ -596,6 +599,11 @@ function openAssetModal(asset = null) {
// Show the modal
assetModal.style.display = 'block';
// // Initialize collapsible sections in the modal - with a slight delay to ensure content is visible
// setTimeout(() => {
// initCollapsibleSections();
// }, 50);
}
function closeAssetModal() {
@ -622,7 +630,7 @@ function closeAssetModal() {
function openSubAssetModal(subAsset = null, parentId = null, parentSubId = null) {
if (!subAssetModal || !subAssetForm) return;
isEditMode = !!subAsset;
document.getElementById('subModalTitle').textContent = isEditMode ? 'Edit Component' : 'Add Component';
document.getElementById('addComponentTitle').textContent = isEditMode ? 'Edit Component' : 'Add Component';
subAssetForm.reset();
deleteSubPhoto = false;
deleteSubReceipt = false;
@ -821,6 +829,11 @@ function openSubAssetModal(subAsset = null, parentId = null, parentSubId = null)
// Show the modal
subAssetModal.style.display = 'block';
// // Initialize any collapsible sections in the modal
// setTimeout(() => {
// initCollapsibleSections();
// }, 50);
}
function closeSubAssetModal() {
@ -844,183 +857,6 @@ function closeSubAssetModal() {
subAssetModal.style.display = 'none';
}
// Event listeners
document.addEventListener('DOMContentLoaded', () => {
// Check if DOM elements exist
if (!assetList || !assetDetails) {
console.error('Required DOM elements not found.');
return;
}
// Set up file upload functionality
initializeFileUploads();
// Initialize the asset renderer module
initRenderer({
// Utility functions
formatDate,
formatCurrency,
// Module functions
openAssetModal,
openSubAssetModal,
deleteAsset,
deleteSubAsset,
createSubAssetElement,
handleSidebarNav,
renderSubAssets,
// Global state
assets,
subAssets,
// DOM elements
assetList,
assetDetails,
subAssetContainer
});
// Initialize the list renderer module
initListRenderer({
// Module functions
updateSelectedIds,
renderAssetDetails,
handleSidebarNav,
// Global state
assets,
subAssets,
selectedAssetId,
dashboardFilter,
currentSort,
searchInput,
// DOM elements
assetList
});
// Set up search
if (searchInput) {
searchInput.addEventListener('input', (e) => {
renderAssetList(e.target.value);
if (clearSearchBtn) {
clearSearchBtn.style.display = e.target.value ? 'flex' : 'none';
}
});
}
if (clearSearchBtn && searchInput) {
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
clearSearchBtn.style.display = 'none';
renderAssetList('');
searchInput.focus();
});
}
// Set up home button
const homeBtn = document.getElementById('homeBtn');
if (homeBtn) {
homeBtn.addEventListener('click', () => {
// Clear selected asset
updateSelectedIds(null, null);
// Remove active class from all asset items
document.querySelectorAll('.asset-item').forEach(item => {
item.classList.remove('active');
});
// Render dashboard
renderEmptyState();
// Close sidebar on mobile
handleSidebarNav();
});
}
// Set up add asset button
if (addAssetBtn) {
addAssetBtn.addEventListener('click', () => {
openAssetModal();
});
}
// Add event listener for escape key to close modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeAssetModal();
closeSubAssetModal();
}
});
// Set the header title from config if available
if (window.appConfig && window.appConfig.siteTitle) {
const siteTitleElem = document.getElementById('siteTitle');
if (siteTitleElem) {
siteTitleElem.textContent = window.appConfig.siteTitle;
}
}
// Set up sort buttons
const sortNameBtn = document.getElementById('sortNameBtn');
const sortWarrantyBtn = document.getElementById('sortWarrantyBtn');
if (sortNameBtn) {
sortNameBtn.addEventListener('click', () => {
const currentDirection = sortNameBtn.getAttribute('data-direction') || 'asc';
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
// Update button state
sortNameBtn.setAttribute('data-direction', newDirection);
sortWarrantyBtn.setAttribute('data-direction', 'asc');
// Update sort settings
currentSort = { field: 'name', direction: newDirection };
updateSort(currentSort);
// Update UI
updateSortButtons(sortNameBtn);
// Re-render with sort
renderAssetList(searchInput ? searchInput.value : '');
});
}
if (sortWarrantyBtn) {
sortWarrantyBtn.addEventListener('click', () => {
const currentDirection = sortWarrantyBtn.getAttribute('data-direction') || 'asc';
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
// Update button state
sortWarrantyBtn.setAttribute('data-direction', newDirection);
sortNameBtn.setAttribute('data-direction', 'asc');
// Update sort settings
currentSort = { field: 'warranty', direction: newDirection };
updateSort(currentSort);
// Update UI
updateSortButtons(sortWarrantyBtn);
// Re-render with sort
renderAssetList(searchInput ? searchInput.value : '');
});
}
// Top Sort Button (optional)
const topSortBtn = document.getElementById('topSortBtn');
if (topSortBtn) {
topSortBtn.addEventListener('click', () => {
const sortOptions = document.getElementById('sortOptions');
if (sortOptions) {
sortOptions.classList.toggle('visible');
}
});
}
// Load initial data
loadAllData();
});
function closeSidebar() {
if (sidebar) sidebar.classList.remove('open');
if (sidebarCloseBtn) sidebarCloseBtn.style.display = 'none';
@ -1065,7 +901,7 @@ importBtn.addEventListener('click', () => {
});
// Close import modal
importModal.querySelector('.close').addEventListener('click', () => {
importModal.querySelector('.close-btn').addEventListener('click', () => {
importModal.style.display = 'none';
});
@ -1074,7 +910,7 @@ importFile.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
selectedFileName.textContent = file.name;
// selectedFileName.textContent = file.name;
try {
// Read the file and get headers
@ -1195,7 +1031,7 @@ startImportBtn.addEventListener('click', async () => {
// Close modal and reset form
importModal.style.display = 'none';
importFile.value = '';
selectedFileName.textContent = 'No file chosen';
// selectedFileName.textContent = 'No file chosen';
startImportBtn.disabled = true;
columnSelects.forEach(select => {
select.innerHTML = '<option value="">Select Column</option>';
@ -1539,4 +1375,189 @@ function createSubAssetElement(subAsset) {
});
return element;
}
}
// Keep at the end
document.addEventListener('DOMContentLoaded', () => {
// Check if DOM elements exist
if (!assetList || !assetDetails) {
console.error('Required DOM elements not found.');
return;
}
// Set up file upload functionality
initializeFileUploads();
// Initialize collapsible sections
initCollapsibleSections();
// Initialize the asset renderer module
initRenderer({
// Utility functions
formatDate,
formatCurrency,
// Module functions
openAssetModal,
openSubAssetModal,
deleteAsset,
deleteSubAsset,
createSubAssetElement,
handleSidebarNav,
renderSubAssets,
// Global state
assets,
subAssets,
// DOM elements
assetList,
assetDetails,
subAssetContainer
});
// Initialize the list renderer module
initListRenderer({
// Module functions
updateSelectedIds,
renderAssetDetails,
handleSidebarNav,
// Global state
assets,
subAssets,
selectedAssetId,
dashboardFilter,
currentSort,
searchInput,
// DOM elements
assetList
});
// Set up search
if (searchInput) {
searchInput.addEventListener('input', (e) => {
renderAssetList(e.target.value);
if (clearSearchBtn) {
clearSearchBtn.style.display = e.target.value ? 'flex' : 'none';
}
});
}
if (clearSearchBtn && searchInput) {
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
clearSearchBtn.style.display = 'none';
renderAssetList('');
searchInput.focus();
});
}
// Set up home button
const homeBtn = document.getElementById('homeBtn');
if (homeBtn) {
homeBtn.addEventListener('click', () => {
// Clear selected asset
updateSelectedIds(null, null);
// Remove active class from all asset items
document.querySelectorAll('.asset-item').forEach(item => {
item.classList.remove('active');
});
// Render dashboard
renderEmptyState();
// Close sidebar on mobile
handleSidebarNav();
});
}
// Set up add asset button
if (addAssetBtn) {
addAssetBtn.addEventListener('click', () => {
openAssetModal();
});
}
// Add event listener for escape key to close modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeAssetModal();
closeSubAssetModal();
}
});
// Set the page and site title from config if available
if (window.appConfig && window.appConfig.siteTitle) {
const siteTitleElem = document.getElementById('siteTitle');
if (siteTitleElem) {
siteTitleElem.textContent = window.appConfig.siteTitle || 'DumbAssets';
}
const pageTitleElem = document.getElementById('pageTitle');
if (pageTitleElem) {
pageTitleElem.textContent = window.appConfig.siteTitle || 'DumbAssets';
}
}
// Set up sort buttons
const sortNameBtn = document.getElementById('sortNameBtn');
const sortWarrantyBtn = document.getElementById('sortWarrantyBtn');
if (sortNameBtn) {
sortNameBtn.addEventListener('click', () => {
const currentDirection = sortNameBtn.getAttribute('data-direction') || 'asc';
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
// Update button state
sortNameBtn.setAttribute('data-direction', newDirection);
sortWarrantyBtn.setAttribute('data-direction', 'asc');
// Update sort settings
currentSort = { field: 'name', direction: newDirection };
updateSort(currentSort);
// Update UI
updateSortButtons(sortNameBtn);
// Re-render with sort
renderAssetList(searchInput ? searchInput.value : '');
});
}
if (sortWarrantyBtn) {
sortWarrantyBtn.addEventListener('click', () => {
const currentDirection = sortWarrantyBtn.getAttribute('data-direction') || 'asc';
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
// Update button state
sortWarrantyBtn.setAttribute('data-direction', newDirection);
sortNameBtn.setAttribute('data-direction', 'asc');
// Update sort settings
currentSort = { field: 'warranty', direction: newDirection };
updateSort(currentSort);
// Update UI
updateSortButtons(sortWarrantyBtn);
// Re-render with sort
renderAssetList(searchInput ? searchInput.value : '');
});
}
// Top Sort Button (optional)
const topSortBtn = document.getElementById('topSortBtn');
if (topSortBtn) {
topSortBtn.addEventListener('click', () => {
const sortOptions = document.getElementById('sortOptions');
if (sortOptions) {
sortOptions.classList.toggle('visible');
}
});
}
// Load initial data
loadAllData();
registerServiceWorker();
});

255
public/service-worker.js Normal file
View File

@ -0,0 +1,255 @@
const CACHE_VERSION = "1.0.1"; // Increment this with each significant change
const CACHE_NAME = `DUMBASSETS_PWA_CACHE_V${CACHE_VERSION}`;
const ASSETS_TO_CACHE = [];
const BASE_PATH = self.registration.scope;
// Helper to prepend base path to URLs that need it
function getAssetPath(url) {
// If the URL is external (starts with http:// or https://), don't modify it
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Remove any leading slashes and join with base path
return `${BASE_PATH}${url.replace(/^\/+/, '')}`;
}
// Check if cache exists and what version it is
async function checkCacheVersion() {
const keys = await caches.keys();
// Find any existing DUMBASSETS cache
const existingCache = keys.find(key => key.startsWith('DUMBASSETS_PWA_CACHE'));
const existingVersion = existingCache ? existingCache.split('V')[1] : null;
// Check if current version cache exists
const currentCacheExists = keys.includes(CACHE_NAME);
// Check for old versions
const oldCaches = keys.filter(key => key !== CACHE_NAME && key.startsWith('DUMBASSETS_PWA_CACHE'));
const hasOldVersions = oldCaches.length > 0;
return {
currentCacheExists,
hasOldVersions,
oldCaches,
existingVersion
};
}
// Function to clean up old caches
async function cleanOldCaches() {
const { oldCaches } = await checkCacheVersion();
if (oldCaches.length > 0) {
console.log("Cleaning up old caches");
await Promise.all(
oldCaches.map(key => {
console.log(`Deleting old cache: ${key}`);
return caches.delete(key);
})
);
}
return oldCaches.length > 0;
}
// Function to notify clients about version status
async function notifyClients() {
const { existingVersion } = await checkCacheVersion();
if (existingVersion !== CACHE_VERSION) {
// Clean up old caches before notifying about updates
await cleanOldCaches();
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'UPDATE_AVAILABLE',
currentVersion: existingVersion,
newVersion: CACHE_VERSION
});
});
});
}
}
const preload = async () => {
console.log("Preparing to install web app cache");
// Check cache status
const { currentCacheExists, existingVersion } = await checkCacheVersion();
// If current version cache already exists, no need to reinstall
if (currentCacheExists) {
console.log(`Cache ${CACHE_NAME} already exists, using existing cache`);
await notifyClients(); // Still check if we need to notify about updates
return;
}
// If we have an older version, clean old caches and notify clients
if (existingVersion && existingVersion !== CACHE_VERSION) {
console.log(`New version ${CACHE_VERSION} available (current: ${existingVersion})`);
// Clean up any old caches to prevent reload loops
await cleanOldCaches();
await notifyClients();
return;
}
// If no cache exists at all, do initial installation
if (!existingVersion) {
await installCache();
}
};
// Function to install or update the cache
async function installCache() {
console.log(`Installing/updating cache to version ${CACHE_VERSION}`);
const cache = await caches.open(CACHE_NAME);
try {
console.log("Fetching asset manifest...");
const response = await fetch(getAssetPath("assets/asset-manifest.json"));
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const assets = await response.json();
// Add base path to relative URLs
const processedAssets = assets.map(asset => getAssetPath(asset));
ASSETS_TO_CACHE.push(...processedAssets);
// Always include critical files
const criticalFiles = [
'index.html',
'index.js',
'styles.css',
'assets/manifest.json',
'assets/dumbassets.png',
];
criticalFiles.forEach(file => {
const filePath = getAssetPath(file);
if (!ASSETS_TO_CACHE.includes(filePath)) {
ASSETS_TO_CACHE.push(filePath);
}
});
console.log("Assets to cache:", ASSETS_TO_CACHE);
await cache.addAll(ASSETS_TO_CACHE);
console.log("Assets cached successfully");
// Clear old caches after successful installation
await cleanOldCaches();
// Notify clients of successful update
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'UPDATE_COMPLETE',
version: CACHE_VERSION,
success: true
});
});
});
} catch (error) {
console.error("Failed to cache assets:", error);
// Notify clients of failed update
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'UPDATE_COMPLETE',
version: CACHE_VERSION,
success: false,
error: error.message
});
});
});
}
}
self.addEventListener("install", (event) => {
console.log("Service Worker installing...");
event.waitUntil(
Promise.all([
preload(),
self.skipWaiting() // Skip waiting to allow new service worker to activate immediately
])
);
});
self.addEventListener("activate", (event) => {
console.log("Service Worker activating...");
event.waitUntil(
Promise.all([
self.clients.claim(), // Take control of all clients immediately
notifyClients() // Check version and notify clients immediately
])
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request)
.then((cachedResponse) => {
// Return cached response if found
if (cachedResponse) {
return cachedResponse;
}
// Clone the request because it can only be used once
return fetch(event.request.clone())
.then((response) => {
// Don't cache if not a valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response because it can only be used once
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
.catch(() => {
// Return a fallback response for navigation requests
if (event.request.mode === 'navigate') {
return caches.match(getAssetPath('index.html'));
}
return new Response('Network error happened', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
});
})
);
});
// Listen for message events from the main script
self.addEventListener('message', async (event) => {
if (event.data && event.data.type === 'GET_VERSION') {
const { existingVersion } = await checkCacheVersion();
// Send the current version to the client
if (event.ports && event.ports[0]) {
event.ports[0].postMessage({
currentVersion: existingVersion,
newVersion: CACHE_VERSION
});
}
} else if (event.data && event.data.type === 'PERFORM_UPDATE') {
// First check and clean up any old caches
await cleanOldCaches();
// User has confirmed they want to update
await installCache();
// Notify clients that update is complete
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'UPDATE_COMPLETE',
version: CACHE_VERSION
});
});
});
}
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
const fs = require("fs");
const path = require("path");
const PUBLIC_DIR = path.join(__dirname, "..", "public");
const ASSETS_DIR = path.join(PUBLIC_DIR, "assets");
const BASE_PATH = process.env.BASE_URL ? new URL(process.env.BASE_URL).pathname.replace(/\/$/, '') : '';
function getFiles(dir, basePath = "/") {
let fileList = [];
const files = fs.readdirSync(dir);
const excludeList = [".DS_Store"]; // Add files or patterns to exclude here
files.forEach((file) => {
const filePath = path.join(dir, file);
const fileUrl = path.join(basePath, file).replace(/\\/g, "/");
if (fs.statSync(filePath).isDirectory()) {
fileList = fileList.concat(getFiles(filePath, fileUrl));
} else {
if (!excludeList.includes(file)){
fileList.push(fileUrl);
}
}
});
return fileList;
}
function generateAssetManifest() {
console.log("Generating Asset manifest...");
const assets = getFiles(PUBLIC_DIR);
fs.writeFileSync(path.join(ASSETS_DIR, "asset-manifest.json"), JSON.stringify(assets, null, 2));
console.log("Asset manifest generated!");
}
function generatePWAManifest(siteTitle) {
generateAssetManifest(); // fetched later in service-worker
const pwaManifest = {
name: siteTitle,
short_name: siteTitle,
description: "A stupidly simple asset tracker",
start_url: BASE_PATH || "/",
scope: BASE_PATH || "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#000000",
icons: [
{
src: `${BASE_PATH}/assets/dumbassets.png`,
type: "image/png",
sizes: "192x192"
},
{
src: `${BASE_PATH}/assets/dumbassets.png`,
type: "image/png",
sizes: "512x512"
}
],
orientation: "any"
};
fs.writeFileSync(path.join(ASSETS_DIR, "manifest.json"), JSON.stringify(pwaManifest, null, 2));
console.log("PWA manifest generated!");
}
module.exports = { generatePWAManifest };

223
server.js
View File

@ -5,24 +5,34 @@
// --- SECURITY & CONFIG IMPORTS ---
require('dotenv').config();
console.log('process.env:', process.env);
// console.log('process.env:', process.env);
const express = require('express');
const session = require('express-session');
const helmet = require('helmet');
const crypto = require('crypto');
const path = require('path');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const fs = require('fs');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const XLSX = require('xlsx');
const { sendNotification } = require('./src/services/notifications/appriseNotifier');
const { startWarrantyCron } = require('./src/services/notifications/warrantyCron');
const { generatePWAManifest } = require("./scripts/pwa-manifest-generator");
const { originValidationMiddleware, getCorsOptions } = require('./middleware/cors');
const app = express();
const PORT = process.env.PORT || 3000;
const DEBUG = process.env.DEBUG === 'TRUE';
const NODE_ENV = process.env.NODE_ENV || 'production';
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
const DEMO_MODE = process.env.DEMO_MODE === 'true';
const SITE_TITLE = DEMO_MODE ? `${process.env.SITE_TITLE || 'DumbAssets'} (DEMO)` : (process.env.SITE_TITLE || 'DumbAssets');
const PUBLIC_DIR = path.join(__dirname, 'public');
const ASSETS_DIR = path.join(PUBLIC_DIR, 'assets');
generatePWAManifest(SITE_TITLE);
// Set timezone from environment variable or default to America/Chicago
process.env.TZ = process.env.TZ || 'America/Chicago';
@ -34,22 +44,22 @@ function debugLog(...args) {
// --- BASE PATH & PIN CONFIG ---
const BASE_PATH = (() => {
if (!process.env.BASE_URL) {
if (!BASE_URL) {
debugLog('No BASE_URL set, using empty base path');
return '';
}
try {
const url = new URL(process.env.BASE_URL);
const url = new URL(BASE_URL);
const path = url.pathname.replace(/\/$/, '');
debugLog('Base URL Configuration:', {
originalUrl: process.env.BASE_URL,
originalUrl: BASE_URL,
extractedPath: path,
protocol: url.protocol,
hostname: url.hostname
});
return path;
} catch {
const path = process.env.BASE_URL.replace(/\/$/, '');
const path = BASE_URL.replace(/\/$/, '');
debugLog('Using direct path as BASE_URL:', path);
return path;
}
@ -87,30 +97,62 @@ function recordAttempt(ip) {
// --- SECURITY MIDDLEWARE ---
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
},
},
noSniff: true, // Prevent MIME type sniffing
frameguard: { action: 'deny' }, // Prevent clickjacking
hsts: { maxAge: 31536000, includeSubDomains: true }, // Enforce HTTPS for one year
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
crossOriginResourcePolicy: { policy: 'same-origin' },
referrerPolicy: { policy: 'no-referrer-when-downgrade' }, // Set referrer policy
ieNoOpen: true, // Prevent IE from executing downloads
// Disabled Helmet middlewares:
contentSecurityPolicy: false, // Disable CSP for now
dnsPrefetchControl: true, // Disable DNS prefetching
permittedCrossDomainPolicies: false,
originAgentCluster: false,
xssFilter: false,
}));
app.use(express.json());
app.set('trust proxy', 1);
app.use(cors(getCorsOptions(BASE_URL)));
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: true,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: false, // Set to true in production with HTTPS
sameSite: 'lax',
secure: (BASE_URL.startsWith('https') && NODE_ENV === 'production'),
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// --- AUTHENTICATION MIDDLEWARE FOR ALL PROTECTED ROUTES ---
app.use(BASE_PATH, (req, res, next) => {
// List of paths that should be publicly accessible
const publicPaths = [
'/login',
'/pin-length',
'/verify-pin',
'/config.js',
'/assets/',
'/styles.css',
'/manifest.json',
'/asset-manifest.json',
];
// Check if the current path matches any of the public paths
if (publicPaths.some(path => req.path.startsWith(path))) {
return next();
}
// For all other paths, apply both origin validation and auth middleware
originValidationMiddleware(req, res, () => {
authMiddleware(req, res, next);
});
});
// --- PIN VERIFICATION ---
function verifyPin(storedPin, providedPin) {
if (!storedPin || !providedPin) return false;
@ -121,7 +163,7 @@ function verifyPin(storedPin, providedPin) {
}
// --- AUTH MIDDLEWARE ---
const authMiddleware = (req, res, next) => {
function authMiddleware(req, res, next) {
debugLog('Auth check for path:', req.path, 'Method:', req.method);
if (!PIN || PIN.trim() === '') return next();
if (!req.session.authenticated) {
@ -136,7 +178,7 @@ const authMiddleware = (req, res, next) => {
});
} else {
// Redirect to login for page requests
return res.redirect(BASE_PATH + '/login');
return res.redirect(BASE_PATH + '/login');
}
}
debugLog('Auth successful - Valid session found');
@ -155,7 +197,7 @@ app.get(BASE_PATH + '/config.js', async (req, res) => {
window.appConfig = {
basePath: '${BASE_PATH}',
debug: ${DEBUG},
siteTitle: '${process.env.SITE_TITLE || 'DumbTitle'}'
siteTitle: '${SITE_TITLE}'
};
`);
@ -170,6 +212,15 @@ app.get(BASE_PATH + '/config.js', async (req, res) => {
res.end();
});
// Serve static files for public assets
app.use(BASE_PATH + '/', express.static(path.join(PUBLIC_DIR)));
app.get(BASE_PATH + "/manifest.json", (req, res) => {
res.sendFile(path.join(ASSETS_DIR, "manifest.json"));
});
app.get(BASE_PATH + "/asset-manifest.json", (req, res) => {
res.sendFile(path.join(ASSETS_DIR, "asset-manifest.json"));
});
// Unprotected routes and files (accessible without login)
app.get(BASE_PATH + '/login', (req, res) => {
if (!PIN || PIN.trim() === '') return res.redirect(BASE_PATH + '/');
@ -184,39 +235,64 @@ app.get(BASE_PATH + '/pin-length', (req, res) => {
app.post(BASE_PATH + '/verify-pin', (req, res) => {
debugLog('PIN verification attempt from IP:', req.ip);
// If no PIN is set, authentication is successful
if (!PIN || PIN.trim() === '') {
debugLog('PIN verification bypassed - No PIN configured');
req.session.authenticated = true;
return res.status(200).json({ success: true });
}
// Check if IP is locked out
const ip = req.ip;
if (isLockedOut(ip)) {
const attempts = loginAttempts.get(ip);
const timeLeft = Math.ceil((LOCKOUT_TIME - (Date.now() - attempts.lastAttempt)) / 1000 / 60);
return res.status(429).json({ error: `Too many attempts. Please try again in ${timeLeft} minutes.` });
debugLog('PIN verification blocked - IP is locked out:', ip);
return res.status(429).json({
error: `Too many attempts. Please try again in ${timeLeft} minutes.`
});
}
const { pin } = req.body;
if (!pin || typeof pin !== 'string') {
debugLog('PIN verification failed - Invalid PIN format');
return res.status(400).json({ error: 'Invalid PIN format' });
}
const delay = crypto.randomInt(50, 150);
setTimeout(() => {
if (verifyPin(PIN, pin)) {
resetAttempts(ip);
req.session.authenticated = true;
res.cookie(`${projectName}_PIN`, pin, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000
});
res.status(200).json({ success: true });
} else {
recordAttempt(ip);
const attempts = loginAttempts.get(ip);
const attemptsLeft = MAX_ATTEMPTS - attempts.count;
res.status(401).json({ error: 'Invalid PIN', attemptsLeft: Math.max(0, attemptsLeft) });
}
}, delay);
// Verify PIN first
const isPinValid = verifyPin(PIN, pin);
if (isPinValid) {
debugLog('PIN verification successful');
// Reset attempts on successful login
resetAttempts(ip);
// Set authentication in session immediately
req.session.authenticated = true;
// Set secure cookie
res.cookie(`${projectName}_PIN`, pin, {
httpOnly: true,
secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'),
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000
});
// Redirect to main page on success
res.redirect(BASE_PATH + '/');
} else {
debugLog('PIN verification failed - Invalid PIN');
// Record failed attempt
recordAttempt(ip);
const attempts = loginAttempts.get(ip);
const attemptsLeft = MAX_ATTEMPTS - attempts.count;
res.status(401).json({
error: 'Invalid PIN',
attemptsLeft: Math.max(0, attemptsLeft)
});
}
});
// Login page static assets (need to be accessible without authentication)
@ -227,29 +303,6 @@ app.use(BASE_PATH + '/script.js', express.static('public/script.js'));
app.use(BASE_PATH + '/src/services/fileUpload', express.static('src/services/fileUpload'));
app.use(BASE_PATH + '/src/services/render', express.static('src/services/render'));
// --- AUTHENTICATION MIDDLEWARE FOR ALL PROTECTED ROUTES ---
app.use((req, res, next) => {
// Skip auth for login page and login-related resources
if (req.path === BASE_PATH + '/login' ||
req.path === BASE_PATH + '/pin-length' ||
req.path === BASE_PATH + '/verify-pin' ||
req.path === BASE_PATH + '/styles.css' ||
req.path === BASE_PATH + '/script.js' ||
req.path === BASE_PATH + '/config.js' ||
req.path.startsWith(BASE_PATH + '/src/services/fileUpload/') ||
req.path.startsWith(BASE_PATH + '/src/services/render/')) {
return next();
}
// Apply authentication middleware
authMiddleware(req, res, next);
});
// Protected static file serving (only accessible after authentication)
app.use('/Images', authMiddleware, express.static(path.join(__dirname, 'data', 'Images')));
app.use('/Receipts', authMiddleware, express.static(path.join(__dirname, 'data', 'Receipts')));
app.use('/Manuals', authMiddleware, express.static(path.join(__dirname, 'data', 'Manuals')));
// Protected API routes
app.use('/api', (req, res, next) => {
console.log(`API Request: ${req.method} ${req.path}`);
@ -969,46 +1022,6 @@ app.post('/api/notification-test', authMiddleware, async (req, res) => {
}
});
// --- CATCH-ALL: Serve index.html if authenticated, else redirect to login ---
const SITE_TITLE = process.env.SITE_TITLE || 'DumbAssets';
// Serve index.html with dynamic SITE_TITLE for main app
app.get(BASE_PATH + '/', authMiddleware, (req, res) => {
let html = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
html = html.replace(/\{\{SITE_TITLE\}\}/g, SITE_TITLE);
res.send(html);
});
// Serve login.html with dynamic SITE_TITLE
app.get(BASE_PATH + '/login', (req, res) => {
if (!PIN || PIN.trim() === '') return res.redirect(BASE_PATH + '/');
if (req.session.authenticated) return res.redirect(BASE_PATH + '/');
let html = fs.readFileSync(path.join(__dirname, 'public', 'login.html'), 'utf8');
html = html.replace(/\{\{SITE_TITLE\}\}/g, SITE_TITLE);
res.send(html);
});
// Redirect /index.html to /
app.get(BASE_PATH + '/index.html', (req, res) => res.redirect(BASE_PATH + '/'));
// Redirect /login.html to /login
app.get(BASE_PATH + '/login.html', (req, res) => res.redirect(BASE_PATH + '/login'));
// --- CATCH-ALL: Serve index.html if authenticated, else redirect to login ---
app.get('*', (req, res, next) => {
// Skip API and static asset requests
if (req.path.startsWith('/api/') || req.path.startsWith('/Images/') || req.path.startsWith('/Receipts/') || req.path.endsWith('.css') || req.path.endsWith('.js') || req.path.endsWith('.ico')) {
return next();
}
// Auth check
if (!PIN || PIN.trim() === '' || req.session.authenticated) {
let html = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
html = html.replace(/\{\{SITE_TITLE\}\}/g, SITE_TITLE);
return res.send(html);
} else {
return res.redirect(BASE_PATH + '/login');
}
});
// --- CLEANUP LOCKOUTS ---
setInterval(() => {
const now = Date.now();
@ -1028,9 +1041,9 @@ app.listen(PORT, () => {
port: PORT,
basePath: BASE_PATH,
pinProtection: !!PIN,
nodeEnv: process.env.NODE_ENV || 'development',
nodeEnv: NODE_ENV,
debug: DEBUG
});
console.log(`Server running on port ${PORT}`);
console.log(`Server running on: ${BASE_URL}`);
});
// --- END ---

View File

@ -19,6 +19,9 @@ export function initializeFileUploads() {
setupFilePreview('subAssetReceipt', 'subReceiptPreview', true);
setupFilePreview('subAssetManual', 'subManualPreview', true);
// Initialize import file uploads
setupFilePreview('importFile', 'importFilePreview', true);
// Initialize drag and drop functionality
setupDragAndDrop();

View File

@ -122,8 +122,8 @@ function renderAssetDetails(assetId, isSubAsset = false) {
<div class="asset-title">
<h2>${asset.name}</h2>
<div class="asset-meta">
Added on ${formatDate(asset.createdAt)}
${asset.updatedAt !== asset.createdAt ? ` • Updated on ${formatDate(asset.updatedAt)}` : ''}
Added: ${formatDate(asset.createdAt)}
${asset.updatedAt !== asset.createdAt ? ` • Updated: ${formatDate(asset.updatedAt)}` : ''}
</div>
</div>
<div class="asset-actions">