mirror of
https://github.com/DumbWareio/DumbAssets.git
synced 2026-01-09 06:10:52 +08:00
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:
parent
d94bc7f95a
commit
49f93f29d8
@ -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
12
.gitignore
vendored
@ -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
83
middleware/cors.js
Normal 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
3
nodemon.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignore": ["asset-manifest.json", "manifest.json"]
|
||||
}
|
||||
145
package-lock.json
generated
145
package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/assets/dumbassets.png
Normal file
BIN
public/assets/dumbassets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
30
public/assets/dumbassets.svg
Normal file
30
public/assets/dumbassets.svg
Normal 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 |
@ -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
10
public/helpers/paths.js
Normal 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;
|
||||
};
|
||||
67
public/helpers/serviceWorkerHelper.js
Normal file
67
public/helpers/serviceWorkerHelper.js
Normal 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]
|
||||
);
|
||||
}
|
||||
@ -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;">×</button>
|
||||
<!-- <div>
|
||||
<button id="clearSearchBtn" class="clear-search-btn" style="display:none;">×</button>
|
||||
<button id="sidebarCloseBtn" class="sidebar-close-btn" style="display:none;" aria-label="Close sidebar">×</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">×</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">×</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>×</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">×</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">×</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">×</span>
|
||||
<h2 id="importAssetTitle" class="modal-title">Import Assets</h2>
|
||||
<div>
|
||||
<span class="close-btn">×</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">×</span>
|
||||
<h2 id="noficationSettingsTitle" class="modal-title">Notification Settings</h2>
|
||||
<div class="close-btn">
|
||||
<span>×</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
154
public/js/collapsible.js
Normal 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
|
||||
};
|
||||
@ -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>
|
||||
|
||||
395
public/script.js
395
public/script.js
@ -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
255
public/service-worker.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
1180
public/styles.css
1180
public/styles.css
File diff suppressed because it is too large
Load Diff
66
scripts/pwa-manifest-generator.js
Normal file
66
scripts/pwa-manifest-generator.js
Normal 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
223
server.js
@ -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 ---
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user