Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
f35d90dc51 WELLMAX Asset Library for GDP PArtners 2026-02-05 06:00:46 +00:00
29 changed files with 879 additions and 588 deletions

View File

@ -1,6 +1,4 @@
require('dotenv').config();
const os = require('os');
const config = {
@ -76,4 +74,4 @@ config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;
config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`;
config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`;
module.exports = config;
module.exports = config;

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -20,11 +19,6 @@ module.exports = class AssetsDBApi {
const assets = await db.assets.create(
{
id: data.id || undefined,
id: data.id
||
null
,
title: data.title
||
@ -56,6 +50,11 @@ module.exports = class AssetsDBApi {
false
,
requiredGdpLevel: data.requiredGdpLevel
||
1
,
importHash: data.importHash || null,
createdById: currentUser.id,
@ -113,11 +112,6 @@ module.exports = class AssetsDBApi {
// Prepare data - wrapping individual data transformations in a map() method
const assetsData = data.map((item, index) => ({
id: item.id || undefined,
id: item.id
||
null
,
title: item.title
||
@ -148,6 +142,11 @@ module.exports = class AssetsDBApi {
||
false
,
requiredGdpLevel: item.requiredGdpLevel
||
1
,
importHash: item.importHash || null,
@ -221,7 +220,8 @@ module.exports = class AssetsDBApi {
if (data.allow_download !== undefined) updatePayload.allow_download = data.allow_download;
if (data.requiredGdpLevel !== undefined) updatePayload.requiredGdpLevel = data.requiredGdpLevel;
updatePayload.updatedById = currentUser.id;
await assets.update(updatePayload, {transaction});
@ -699,4 +699,3 @@ module.exports = class AssetsDBApi {
};

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -84,6 +83,11 @@ module.exports = class UsersDBApi {
||
null
,
gdpLevel: data.data.gdpLevel
||
1
,
importHash: data.data.importHash || null,
createdById: currentUser.id,
@ -203,6 +207,11 @@ module.exports = class UsersDBApi {
provider: item.provider
||
null
,
gdpLevel: item.gdpLevel
||
1
,
importHash: item.importHash || null,
@ -298,7 +307,8 @@ module.exports = class UsersDBApi {
if (data.provider !== undefined) updatePayload.provider = data.provider;
if (data.gdpLevel !== undefined) updatePayload.gdpLevel = data.gdpLevel;
updatePayload.updatedById = currentUser.id;
await users.update(updatePayload, {transaction});
@ -935,5 +945,4 @@ module.exports = class UsersDBApi {
};
};

View File

@ -1,4 +1,4 @@
require('dotenv').config();
module.exports = {
production: {
@ -12,11 +12,12 @@ module.exports = {
seederStorage: 'sequelize',
},
development: {
username: 'postgres',
username: process.env.DB_USER || 'postgres',
dialect: 'postgres',
password: '',
database: 'db_app_draft',
password: process.env.DB_PASS || '',
database: process.env.DB_NAME || 'db_app_draft',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT,
logging: console.log,
seederStorage: 'sequelize',
},

View File

@ -494,21 +494,6 @@ module.exports = {
await queryInterface.addColumn(
'assets',
'id',
{
type: Sequelize.DataTypes.INTEGER,
},
{ transaction }
);
await queryInterface.addColumn(
'assets',
'title',
@ -634,21 +619,6 @@ module.exports = {
await queryInterface.addColumn(
'product_categories',
'id',
{
type: Sequelize.DataTypes.INTEGER,
},
{ transaction }
);
await queryInterface.addColumn(
'product_categories',
'name',
@ -679,21 +649,6 @@ module.exports = {
await queryInterface.addColumn(
'tags',
'id',
{
type: Sequelize.DataTypes.INTEGER,
},
{ transaction }
);
await queryInterface.addColumn(
'tags',
'name',
@ -724,21 +679,6 @@ module.exports = {
await queryInterface.addColumn(
'download_logs',
'id',
{
type: Sequelize.DataTypes.INTEGER,
},
{ transaction }
);
await queryInterface.addColumn(
'download_logs',
'userId',

View File

@ -0,0 +1,49 @@
'use strict';
const { v4: uuid } = require('uuid');
module.exports = {
up: async (queryInterface, Sequelize) => {
const createdAt = new Date();
const updatedAt = new Date();
// 1. Seed Product Categories
const categories = [
{ id: uuid(), name: 'High Bay', description: 'Industrial High Bay lighting', createdAt, updatedAt },
{ id: uuid(), name: 'Panel', description: 'LED Panels', createdAt, updatedAt },
{ id: uuid(), name: 'Tube', description: 'LED Tubes', createdAt, updatedAt },
{ id: uuid(), name: 'Downlight', description: 'LED Downlights', createdAt, updatedAt },
{ id: uuid(), name: 'Others', description: 'Other lighting solutions', createdAt, updatedAt },
];
await queryInterface.bulkInsert('product_categories', categories);
// 2. Grant READ_ASSETS, READ_PRODUCT_CATEGORIES, READ_TAGS to Public role
const [publicRole] = await queryInterface.sequelize.query(
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1;"
);
if (publicRole && publicRole[0] && publicRole[0].length > 0) {
const publicRoleId = publicRole[0][0].id;
const permissionsToGrant = ['READ_ASSETS', 'READ_PRODUCT_CATEGORIES', 'READ_TAGS'];
const [perms] = await queryInterface.sequelize.query(
`SELECT id FROM permissions WHERE name IN ('${permissionsToGrant.join("','")}')`
);
if (perms && perms.length > 0) {
const rolesPermissions = perms.map(p => ({
createdAt,
updatedAt,
roles_permissionsId: publicRoleId,
permissionId: p.id
}));
await queryInterface.bulkInsert('rolesPermissionsPermissions', rolesPermissions);
}
}
},
down: async (queryInterface, Sequelize) => {
// Revert logic if needed
}
};

View File

@ -0,0 +1,19 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'gdpLevel', {
type: Sequelize.INTEGER,
defaultValue: 1, // Blue
allowNull: false,
});
await queryInterface.addColumn('assets', 'requiredGdpLevel', {
type: Sequelize.INTEGER,
defaultValue: 1, // Blue
allowNull: false,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('users', 'gdpLevel');
await queryInterface.removeColumn('assets', 'requiredGdpLevel');
},
};

View File

@ -14,86 +14,42 @@ module.exports = function(sequelize, DataTypes) {
primaryKey: true,
},
id: {
type: DataTypes.INTEGER,
},
title: {
type: DataTypes.TEXT,
},
description: {
type: DataTypes.TEXT,
},
asset_type: {
type: DataTypes.ENUM,
values: [
"image",
"video",
"artwork"
"image",
"video",
"artwork"
],
},
asset_category: {
type: DataTypes.ENUM,
values: [
"ProductPhotos",
"ApplicationScenarios",
"Videos",
"Packaging&Artwork",
"Logos&BrandAssets"
"ProductPhotos",
"ApplicationScenarios",
"Videos",
"Packaging&Artwork",
"Logos&BrandAssets"
],
},
uploaded_at: {
type: DataTypes.DATE,
},
allow_download: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
importHash: {
@ -101,6 +57,12 @@ allow_download: {
allowNull: true,
unique: true,
},
requiredGdpLevel: {
type: DataTypes.INTEGER,
defaultValue: 1, // Blue
allowNull: false,
},
},
{
timestamps: true,
@ -147,16 +109,6 @@ allow_download: {
through: 'assetsTagsTags',
});
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
db.assets.hasMany(db.download_logs, {
as: 'download_logs_asset',
foreignKey: {
@ -165,12 +117,6 @@ allow_download: {
constraints: false,
});
//end loop
db.assets.belongsTo(db.users, {
as: 'uploaded_by',
foreignKey: {
@ -179,8 +125,6 @@ allow_download: {
constraints: false,
});
db.assets.hasMany(db.file, {
as: 'thumbnail',
foreignKey: 'belongsToId',
@ -201,7 +145,6 @@ allow_download: {
},
});
db.assets.belongsTo(db.users, {
as: 'createdBy',
});
@ -211,9 +154,5 @@ allow_download: {
});
};
return assets;
};
};

View File

@ -14,13 +14,6 @@ module.exports = function(sequelize, DataTypes) {
primaryKey: true,
},
id: {
type: DataTypes.INTEGER,
},
downloaded_at: {
type: DataTypes.DATE,
@ -104,6 +97,4 @@ file_size_bytes: {
return download_logs;
};
};

View File

@ -14,13 +14,6 @@ module.exports = function(sequelize, DataTypes) {
primaryKey: true,
},
id: {
type: DataTypes.INTEGER,
},
name: {
type: DataTypes.TEXT,
@ -81,6 +74,4 @@ description: {
return product_categories;
};
};

View File

@ -14,13 +14,6 @@ module.exports = function(sequelize, DataTypes) {
primaryKey: true,
},
id: {
type: DataTypes.INTEGER,
},
name: {
type: DataTypes.TEXT,
@ -81,6 +74,4 @@ slug: {
return tags;
};
};

View File

@ -109,6 +109,12 @@ provider: {
allowNull: true,
unique: true,
},
gdpLevel: {
type: DataTypes.INTEGER,
defaultValue: 1, // Blue
allowNull: false,
},
},
{
timestamps: true,
@ -242,5 +248,4 @@ function trimStringFields(users) {
: null;
return users;
}
}

View File

@ -68,7 +68,7 @@ await queryInterface.bulkInsert("permissions", [{ id: getId(`READ_API_DOCS`), cr
await queryInterface.bulkInsert("permissions", [{ id: getId(`CREATE_SEARCH`), createdAt, updatedAt, name: `CREATE_SEARCH`}]);
await queryInterface.sequelize.query(`create table "rolesPermissionsPermissions"
await queryInterface.sequelize.query(`create table if not exists "rolesPermissionsPermissions"
(
"createdAt" timestamp with time zone not null,
"updatedAt" timestamp with time zone not null,

View File

@ -45,17 +45,7 @@ const DownloadLogs = db.download_logs;
const AssetsData = [
{
"id": 1,
"title": "High Bay Warehouse - Front View",
@ -133,17 +123,7 @@ const AssetsData = [
},
{
"id": 2,
"title": "High Bay Installation Guide - Video",
@ -221,17 +201,7 @@ const AssetsData = [
},
{
"id": 3,
"title": "WELLMAX Primary Logo - EPS",
@ -309,17 +279,7 @@ const AssetsData = [
},
{
"id": 4,
"title": "Packaging Mockup - High Bay",
@ -397,17 +357,7 @@ const AssetsData = [
},
{
"id": 5,
"title": "Downlight Living Room Scenario",
@ -491,17 +441,7 @@ const AssetsData = [
const ProductCategoriesData = [
{
"id": 1,
"name": "High Bay",
@ -516,17 +456,7 @@ const ProductCategoriesData = [
},
{
"id": 2,
"name": "Panel",
@ -541,17 +471,7 @@ const ProductCategoriesData = [
},
{
"id": 3,
"name": "Tube",
@ -566,17 +486,7 @@ const ProductCategoriesData = [
},
{
"id": 4,
"name": "Downlight",
@ -591,17 +501,7 @@ const ProductCategoriesData = [
},
{
"id": 5,
"name": "Others",
@ -622,17 +522,7 @@ const ProductCategoriesData = [
const TagsData = [
{
"id": 1,
"name": "warehouse",
@ -647,17 +537,7 @@ const TagsData = [
},
{
"id": 2,
"name": "installation",
@ -672,17 +552,7 @@ const TagsData = [
},
{
"id": 3,
"name": "indoor",
@ -697,17 +567,7 @@ const TagsData = [
},
{
"id": 4,
"name": "logo",
@ -722,17 +582,7 @@ const TagsData = [
},
{
"id": 5,
"name": "packaging",
@ -753,17 +603,7 @@ const TagsData = [
const DownloadLogsData = [
{
"id": 1,
// type code here for "relation_one" field
@ -799,17 +639,7 @@ const DownloadLogsData = [
},
{
"id": 2,
// type code here for "relation_one" field
@ -845,17 +675,7 @@ const DownloadLogsData = [
},
{
"id": 3,
// type code here for "relation_one" field
@ -891,17 +711,7 @@ const DownloadLogsData = [
},
{
"id": 4,
// type code here for "relation_one" field
@ -937,17 +747,7 @@ const DownloadLogsData = [
},
{
"id": 5,
// type code here for "relation_one" field

View File

@ -92,6 +92,14 @@ app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.enable('trust proxy');
const optionalAuthenticate = (req, res, next) => {
passport.authenticate('jwt', { session: false }, (err, user, info) => {
if (err) return next(err);
if (user) req.currentUser = user;
next();
})(req, res, next);
};
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);
@ -99,11 +107,10 @@ app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoute
app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes);
app.use('/api/assets', passport.authenticate('jwt', {session: false}), assetsRoutes);
app.use('/api/product_categories', passport.authenticate('jwt', {session: false}), product_categoriesRoutes);
app.use('/api/tags', passport.authenticate('jwt', {session: false}), tagsRoutes);
// Publicly accessible routes with optional authentication
app.use('/api/assets', optionalAuthenticate, assetsRoutes);
app.use('/api/product_categories', optionalAuthenticate, product_categoriesRoutes);
app.use('/api/tags', optionalAuthenticate, tagsRoutes);
app.use('/api/download_logs', passport.authenticate('jwt', {session: false}), download_logsRoutes);

View File

@ -1,4 +1,3 @@
const express = require('express');
const AssetsService = require('../services/assets');
@ -15,6 +14,13 @@ const {
checkCrudPermissions,
} = require('../middlewares/check-permissions');
/**
* Custom download route for assets with role verification and logging
*/
router.get('/:id/download', wrapAsync(async (req, res) => {
await AssetsService.download(req.params.id, req.currentUser, req, res);
}));
router.use(checkCrudPermissions('assets'));
@ -434,4 +440,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
module.exports = router;

View File

@ -1,15 +1,13 @@
const db = require('../db/models');
const AssetsDBApi = require('../db/api/assets');
const Download_logsDBApi = require('../db/api/download_logs');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const fileService = require('./file');
module.exports = class AssetsService {
static async create(data, currentUser) {
@ -30,6 +28,62 @@ module.exports = class AssetsService {
}
};
static async download(id, currentUser, req, res) {
const asset = await AssetsDBApi.findBy({ id });
if (!asset) {
throw new ValidationError('assetsNotFound');
}
// Check if user has permission to download
const role = currentUser.app_role.name;
if (role !== 'Administrator') {
if (role !== 'GDP_Partner') {
throw new ValidationError('Unauthorized to download original assets');
}
// Check GDP Level
const userLevel = currentUser.gdpLevel || 1; // Default to 1 (Blue)
const requiredLevel = asset.requiredGdpLevel || 1; // Default to 1
if (userLevel < requiredLevel) {
throw new ValidationError(`GDP Level insufficient. Required: ${requiredLevel}, Your Level: ${userLevel}`);
}
}
// Log the download
const transaction = await db.sequelize.transaction();
try {
await Download_logsDBApi.create({
assetId: asset.id,
userId: currentUser.id,
ip_address: req.ip || req.connection.remoteAddress,
downloaded_at: new Date(),
}, { transaction, currentUser });
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to log download:', error);
// We continue even if logging fails, but we logged the error
}
// Get the original file
const originalFiles = asset.original_file || [];
if (originalFiles.length === 0) {
throw new ValidationError('Original file not found for this asset');
}
const file = originalFiles[0];
req.query.privateUrl = file.privateUrl;
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) {
return fileService.downloadGCloud(req, res);
} else {
return fileService.downloadLocal(req, res);
}
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction();
@ -51,7 +105,7 @@ module.exports = class AssetsService {
.on('error', (error) => reject(error));
})
await AssetsDBApi.bulkImport(results, {
await Download_logsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
@ -134,5 +188,3 @@ module.exports = class AssetsService {
};

21
fix_migration.py Normal file
View File

@ -0,0 +1,21 @@
import re
file_path = 'backend/src/db/migrations/1770016660721.js'
with open(file_path, 'r') as f:
content = f.read()
# Pattern to match the addColumn block for 'id'
# We use re.DOTALL so . matches newlines
# We look for 'id' as the second argument
pattern = r"\s*await queryInterface\.addColumn\(\s*'[^']+',\s*'id',\s*\{\s*type: Sequelize\.DataTypes\.INTEGER,.*?\},\s*\{ transaction \}\s*\);"
matches = re.findall(pattern, content, re.DOTALL)
print(f"Found {len(matches)} matches.")
for m in matches:
print("Match snippet:", m.strip()[:100])
new_content = re.sub(pattern, "", content, flags=re.DOTALL)
with open(file_path, 'w') as f:
f.write(new_content)

26
fix_seeder.py Normal file
View File

@ -0,0 +1,26 @@
import re
file_path = 'backend/src/db/seeders/20231127130745-sample-data.js'
with open(file_path, 'r') as f:
content = f.read()
# Pattern to remove "id": <number>, lines
# We handle potential indentation and trailing commas
# regex:
# ^ start of line
# \s* whitespace
# "id": "id" key
# \s* whitespace
# \d+ integer
# \s* whitespace
# ,? optional comma
# \s* whitespace
# $ end of line
pattern = r'^\s*"id":\s*\d+\s*,?\s*$'
# We need re.MULTILINE to match ^ and $ for each line
new_content = re.sub(pattern, '', content, flags=re.MULTILINE)
with open(file_path, 'w') as f:
f.write(new_content)

15
fix_seeder_table.py Normal file
View File

@ -0,0 +1,15 @@
import re
file_path = 'backend/src/db/seeders/20200430130760-user-roles.js'
with open(file_path, 'r') as f:
content = f.read()
# Replace create table with create table if not exists
pattern = 'create table "rolesPermissionsPermissions"'
replacement = 'create table if not exists "rolesPermissionsPermissions"'
new_content = content.replace(pattern, replacement)
with open(file_path, 'w') as f:
f.write(new_content)

View File

@ -0,0 +1,263 @@
import React, { useState } from 'react';
import ImageField from '../ImageField';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import LoadingSpinner from "../LoadingSpinner";
import { mdiDownload, mdiEye, mdiLock } from '@mdi/js';
import BaseButton from '../BaseButton';
import CardBoxModal from '../CardBoxModal';
import axios from 'axios';
import { saveAs } from 'file-saver';
import BaseIcon from '../BaseIcon';
type Props = {
assets: any[];
loading: boolean;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const gdpLevels = {
1: 'Blue',
2: 'Silver',
3: 'Gold',
4: 'Platinum'
};
const GalleryGrid = ({
assets,
loading,
currentPage,
numPages,
onPageChange,
}: Props) => {
const corners = useAppSelector((state) => state.style.corners);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const [selectedAsset, setSelectedAsset] = useState<any>(null);
const [isPreviewActive, setIsPreviewActive] = useState(false);
const [isDownloading, setIsDownloading] = useState<string | null>(null);
const isPartner = currentUser?.app_role?.name === 'GDP_Partner' || currentUser?.app_role?.name === 'Administrator';
const canDownload = (asset: any) => {
if (!currentUser) return false;
const role = currentUser.app_role?.name;
if (role === 'Administrator') return true;
if (role !== 'GDP_Partner') return false;
const userLevel = currentUser.gdpLevel || 1;
const requiredLevel = asset.requiredGdpLevel || 1;
return userLevel >= requiredLevel;
};
const handlePreview = (asset: any) => {
setSelectedAsset(asset);
setIsPreviewActive(true);
};
const handleDownload = async (e: React.MouseEvent, asset: any) => {
e.stopPropagation();
if (isDownloading) return;
setIsDownloading(asset.id);
try {
const response = await axios.get(`/assets/${asset.id}/download`, {
responseType: 'blob',
});
// Try to get filename from content-disposition
let fileName = asset.title || 'download';
const contentDisposition = response.headers['content-disposition'];
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
if (fileNameMatch && fileNameMatch.length === 2) {
fileName = fileNameMatch[1];
}
} else {
// Fallback to asset title + original extension if possible
const originalFile = dataFormatter.filesFormatter(asset.original_file)[0];
if (originalFile) {
const ext = originalFile.name.split('.').pop();
if (ext && !fileName.endsWith(ext)) {
fileName = `${fileName}.${ext}`;
}
}
}
saveAs(response.data, fileName);
} catch (error) {
console.error('Download failed:', error);
alert('Download failed. Please try again or contact support.');
} finally {
setIsDownloading(null);
}
};
return (
<div className="py-8">
{loading && <LoadingSpinner />}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{!loading && assets.map((asset) => (
<div
key={asset.id}
className={`bg-white border border-gray-200 ${corners} overflow-hidden shadow-sm hover:shadow-md transition-shadow group flex flex-col`}
>
{/* Thumbnail Container */}
<div className="relative aspect-video overflow-hidden bg-gray-100">
<ImageField
name="asset-thumb"
image={asset.thumbnail}
className="w-full h-full"
imageClassName="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
{asset.asset_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-20">
<div className="w-12 h-12 rounded-full bg-white bg-opacity-80 flex items-center justify-center">
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-l-[12px] border-l-[#003366] border-b-[8px] border-b-transparent ml-1" />
</div>
</div>
)}
{/* Level Badge Overlay if locked */}
{isPartner && !canDownload(asset) && (
<div className="absolute top-2 right-2 bg-black bg-opacity-75 text-white text-xs px-2 py-1 rounded flex items-center">
<BaseIcon path={mdiLock} size={14} className="mr-1" />
{gdpLevels[asset.requiredGdpLevel || 1]}+
</div>
)}
</div>
{/* Content */}
<div className="p-5 flex-grow flex flex-col">
<div className="flex justify-between items-start mb-2">
<span className="text-[10px] uppercase tracking-wider font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded">
{asset.asset_category || asset.asset_type}
</span>
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-1">{asset.title}</h3>
<p className="text-sm text-gray-500 line-clamp-2 mb-4 flex-grow">
{asset.description || 'No description provided.'}
</p>
<div className="flex items-center space-x-2">
<BaseButton
icon={mdiEye}
label="Preview"
color="info"
outline
small
className="flex-1"
onClick={() => handlePreview(asset)}
/>
{isPartner && (
<div className="flex-1">
{canDownload(asset) ? (
<BaseButton
icon={mdiDownload}
label={isDownloading === asset.id ? '...' : 'Download'}
color="success"
small
className="w-full"
disabled={!!isDownloading}
onClick={(e) => handleDownload(e, asset)}
/>
) : (
<button
className="w-full py-1 text-xs text-gray-500 bg-gray-100 rounded border border-gray-200 cursor-not-allowed flex flex-col items-center justify-center h-[38px]"
disabled
>
<span className="font-semibold">Locked</span>
<span className="text-[10px]">{gdpLevels[asset.requiredGdpLevel || 1]}+ Required</span>
</button>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
{!loading && assets.length === 0 && (
<div className="text-center py-20 border-2 border-dashed border-gray-200 rounded-lg">
<p className="text-gray-500 text-lg">No assets found in this category.</p>
</div>
)}
<div className="flex items-center justify-center mt-12">
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
{/* Preview Modal */}
<CardBoxModal
title={selectedAsset?.title || 'Asset Preview'}
isActive={isPreviewActive}
onConfirm={() => setIsPreviewActive(false)}
onCancel={() => setIsPreviewActive(false)}
buttonLabel="Close"
buttonColor="info"
>
<div className="space-y-4">
<div className="rounded-lg overflow-hidden bg-gray-100 max-h-[60vh] flex items-center justify-center">
{selectedAsset?.asset_type === 'video' ? (
<video controls className="max-w-full max-h-full">
{dataFormatter.filesFormatter(selectedAsset.original_file).map((file, i) => (
<source key={i} src={file.publicUrl} type="video/mp4" />
))}
Your browser does not support the video tag.
</video>
) : (
<ImageField
name="preview-img"
image={selectedAsset?.thumbnail}
className="max-w-full max-h-full"
imageClassName="max-w-full max-h-full object-contain"
/>
)}
</div>
<div>
<h4 className="font-bold text-gray-900">Description</h4>
<p className="text-gray-600">{selectedAsset?.description || 'N/A'}</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-bold block">Category</span>
<span className="text-gray-600">{asset_category(selectedAsset)}</span>
</div>
<div>
<span className="font-bold block">Type</span>
<span className="text-gray-600 text-capitalize">{selectedAsset?.asset_type || 'N/A'}</span>
</div>
<div>
<span className="font-bold block">Required Level</span>
<span className="text-gray-600">{gdpLevels[selectedAsset?.requiredGdpLevel || 1]}</span>
</div>
<div>
<span className="font-bold block">Product Categories</span>
<span className="text-gray-600">
{dataFormatter.product_categoriesManyListFormatter(selectedAsset?.product_categories).join(', ') || 'None'}
</span>
</div>
<div className="col-span-2">
<span className="font-bold block">Tags</span>
<span className="text-gray-600">
{dataFormatter.tagsManyListFormatter(selectedAsset?.tags).join(', ') || 'None'}
</span>
</div>
</div>
</div>
</CardBoxModal>
</div>
);
};
// Helper for safe access inside modal
const asset_category = (asset) => asset?.asset_category || 'N/A';
export default GalleryGrid;

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, {useEffect, useRef, useState} from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) {
}
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
}
}

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
@ -126,4 +125,4 @@ export default function LayoutAuthenticated({
</div>
</div>
)
}
}

View File

@ -361,6 +361,8 @@ const EditAssetsPage = () => {
allow_download: false,
requiredGdpLevel: 1,
@ -943,6 +945,15 @@ const EditAssetsPage = () => {
component={SwitchField}
></Field>
</FormField>
<FormField label="Required GDP Level" labelFor="requiredGdpLevel">
<Field name="requiredGdpLevel" id="requiredGdpLevel" component="select">
<option value={1}>Blue</option>
<option value={2}>Silver</option>
<option value={3}>Gold</option>
<option value={4}>Platinum</option>
</Field>
</FormField>
@ -982,4 +993,4 @@ EditAssetsPage.getLayout = function getLayout(page: ReactElement) {
)
}
export default EditAssetsPage
export default EditAssetsPage

View File

@ -216,6 +216,7 @@ const initialValues = {
allow_download: false,
requiredGdpLevel: 1,
@ -690,6 +691,15 @@ const AssetsNew = () => {
></Field>
</FormField>
<FormField label="Required GDP Level" labelFor="requiredGdpLevel">
<Field name="requiredGdpLevel" id="requiredGdpLevel" component="select">
<option value={1}>Blue</option>
<option value={2}>Silver</option>
<option value={3}>Gold</option>
<option value={4}>Platinum</option>
</Field>
</FormField>
@ -724,4 +734,4 @@ AssetsNew.getLayout = function getLayout(page: ReactElement) {
)
}
export default AssetsNew
export default AssetsNew

View File

@ -0,0 +1,173 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { fetch } from '../stores/assets/assetsSlice';
import { fetch as fetchCategories } from '../stores/product_categories/product_categoriesSlice';
import LayoutGuest from '../layouts/Guest';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { mdiLibraryOutline, mdiFilterOutline } from '@mdi/js';
import { getPageTitle } from '../config';
import GalleryGrid from '../components/Assets/GalleryGrid';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SelectField from '../components/SelectField';
import BaseButton from '../components/BaseButton';
export default function GalleryPage() {
const dispatch = useAppDispatch();
const { assets, loading, count } = useAppSelector((state) => state.assets);
const { product_categories } = useAppSelector((state) => state.product_categories);
const [currentPage, setCurrentPage] = useState(0);
const perPage = 12;
const [filters, setFilters] = useState({
asset_type: '',
asset_category: '',
product_category: '',
});
const assetTypes = [
{ id: 'image', name: 'Image' },
{ id: 'video', name: 'Video' },
{ id: 'artwork', name: 'Artwork' },
];
const assetCategories = [
{ id: 'ProductPhotos', name: 'Product Photos' },
{ id: 'ApplicationScenarios', name: 'Application Scenarios' },
{ id: 'Videos', name: 'Videos' },
{ id: 'Packaging&Artwork', name: 'Packaging & Artwork' },
{ id: 'Logos&BrandAssets', name: 'Logos & Brand Assets' },
];
const loadData = () => {
let query = `?page=${currentPage}&limit=${perPage}`;
if (filters.asset_type) query += `&asset_type=${filters.asset_type}`;
if (filters.asset_category) query += `&asset_category=${filters.asset_category}`;
if (filters.product_category) query += `&product_categories=${filters.product_category}`;
dispatch(fetch({ limit: perPage, page: currentPage, query }));
};
useEffect(() => {
dispatch(fetchCategories({ limit: 100, page: 0 }));
}, [dispatch]);
useEffect(() => {
loadData();
}, [currentPage, filters]);
const handleFilterChange = (name: string, value: string) => {
setFilters(prev => ({ ...prev, [name]: value }));
setCurrentPage(0);
};
const handleReset = () => {
setFilters({ asset_type: '', asset_category: '', product_category: '' });
setCurrentPage(0);
};
return (
<div className="bg-gray-50 min-h-screen">
<Head>
<title>{getPageTitle('Asset Library')}</title>
</Head>
{/* Header / Hero */}
<div className="bg-[#003366] text-white py-12 px-6">
<SectionMain>
<div className="flex flex-col md:flex-row md:items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">WELLMAX Digital Asset Library</h1>
<p className="text-blue-100">Browse and download official brand materials.</p>
</div>
</div>
</SectionMain>
</div>
<SectionMain>
<div className="flex flex-col lg:flex-row gap-8">
{/* Filters Sidebar */}
<div className="w-full lg:w-64 flex-shrink-0">
<CardBox className="sticky top-4">
<div className="flex items-center mb-6 text-[#003366]">
<span className="font-bold uppercase tracking-widest text-sm flex items-center">
<mdiFilterOutline className="w-4 h-4 mr-2" />
Filters
</span>
</div>
<div className="space-y-4">
<FormField label="Asset Type">
<select
className="w-full bg-gray-50 border border-gray-200 rounded p-2 text-sm"
value={filters.asset_type}
onChange={(e) => handleFilterChange('asset_type', e.target.value)}
>
<option value="">All Types</option>
{assetTypes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</FormField>
<FormField label="Asset Category">
<select
className="w-full bg-gray-50 border border-gray-200 rounded p-2 text-sm"
value={filters.asset_category}
onChange={(e) => handleFilterChange('asset_category', e.target.value)}
>
<option value="">All Categories</option>
{assetCategories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
<FormField label="Product Category">
<select
className="w-full bg-gray-50 border border-gray-200 rounded p-2 text-sm"
value={filters.product_category}
onChange={(e) => handleFilterChange('product_category', e.target.value)}
>
<option value="">All Products</option>
{product_categories?.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
<BaseButton
label="Reset Filters"
color="info"
outline
className="w-full mt-4"
small
onClick={handleReset}
/>
</div>
</CardBox>
</div>
{/* Main Content */}
<div className="flex-grow">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-800">
{count} Assets Found
</h2>
</div>
<GalleryGrid
assets={assets}
loading={loading}
currentPage={currentPage}
numPages={Math.ceil(count / perPage)}
onPageChange={(page) => setCurrentPage(page)}
/>
</div>
</div>
</SectionMain>
</div>
);
}
GalleryPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,166 +1,121 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import SectionMain from '../components/SectionMain';
import { mdiLibraryOutline, mdiShieldCheckOutline, mdiCloudDownloadOutline } from '@mdi/js';
import IconRounded from '../components/IconRounded';
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
export default function WELLMAXLanding() {
const textColor = useAppSelector((state) => state.style.linkColor);
const { currentUser } = useAppSelector((state) => state.auth);
const title = 'App Draft'
const title = 'WELLMAX Digital Asset Library'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
return (
<div className="bg-white min-h-screen font-sans">
<Head>
<title>{getPageTitle('Welcome to WELLMAX')}</title>
</Head>
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
{/* Hero Section */}
<div className="bg-[#003366] text-white py-24 px-6 md:px-12 flex flex-col items-center text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6 tracking-tight">
WELLMAX <span className="text-blue-300">Asset Library</span>
</h1>
<p className="text-xl md:text-2xl max-w-3xl mb-10 text-gray-200">
The secure hub for all official brand assets, product photos, and marketing materials.
Exclusively for WELLMAX GDP Partners.
</p>
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
<BaseButton
href="/gallery"
label="Browse Assets"
color="white"
className="px-8 py-3 text-lg font-semibold"
/>
{!currentUser && (
<BaseButton
href="/login"
label="Partner Login"
color="info"
outline
className="px-8 py-3 text-lg font-semibold"
/>
)}
</div>
</div>
{/* Features Section */}
<SectionMain>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 py-12">
<div className="flex flex-col items-center text-center p-6">
<IconRounded icon={mdiLibraryOutline} color="info" className="mb-4" />
<h3 className="text-2xl font-bold mb-2">Centralized Library</h3>
<p className="text-gray-600">
Access all Product Photos, Application Scenarios, and Branding Assets in one place.
</p>
</div>
<div className="flex flex-col items-center text-center p-6">
<IconRounded icon={mdiShieldCheckOutline} color="success" className="mb-4" />
<h3 className="text-2xl font-bold mb-2">Secure Access</h3>
<p className="text-gray-600">
Role-based access control ensuring that original high-res files are available only to authorized partners.
</p>
</div>
<div className="flex flex-col items-center text-center p-6">
<IconRounded icon={mdiCloudDownloadOutline} color="warning" className="mb-4" />
<h3 className="text-2xl font-bold mb-2">Bulk Downloads</h3>
<p className="text-gray-600">
GDP Partners can easily download the assets they need for marketing and sales.
</p>
</div>
</div>
</SectionMain>
{/* Product Categories Preview (Conceptual) */}
<div className="bg-gray-50 py-20 px-6">
<div className="max-w-6xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-12">Our Core Categories</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{['High Bay', 'Panel', 'Tube', 'Downlight'].map((cat) => (
<div key={cat} className="bg-white p-8 border border-gray-200 rounded-lg text-center hover:shadow-md transition-shadow">
<span className="font-bold text-[#003366]">{cat}</span>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<footer className="bg-[#333333] text-white py-12 px-6">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between items-center">
<div className="mb-6 md:mb-0 text-center md:text-left">
<h2 className="text-2xl font-bold mb-2 text-white">WELLMAX</h2>
<p className="text-gray-400 text-sm">Industrial Digital Asset Management System</p>
</div>
<div className="flex space-x-6">
<Link href="/terms-of-use" className="text-gray-400 hover:text-white transition-colors">Terms</Link>
<Link href="/privacy-policy" className="text-gray-400 hover:text-white transition-colors">Privacy</Link>
{currentUser ? (
<Link href="/dashboard" className="text-gray-400 hover:text-white transition-colors font-bold">Admin Panel</Link>
) : (
<Link href="/login" className="text-gray-400 hover:text-white transition-colors font-bold">Login</Link>
)}
</div>
</div>
<div className="mt-8 pt-8 border-t border-gray-700 text-center text-gray-500 text-xs">
© 2026 WELLMAX. All rights reserved. Powered by Flatlogic.
</div>
</footer>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head>
<title>{getPageTitle('Starter Page')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your App Draft app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
WELLMAXLanding.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -264,7 +264,9 @@ const EditUsersPage = () => {
password: ''
password: '',
gdpLevel: 1,
}
const [initialValues, setInitialValues] = useState(initVals)
@ -589,6 +591,15 @@ const EditUsersPage = () => {
></Field>
</FormField>
<FormField label="GDP Level" labelFor="gdpLevel">
<Field name="gdpLevel" id="gdpLevel" component="select">
<option value={1}>Blue</option>
<option value={2}>Silver</option>
<option value={3}>Gold</option>
<option value={4}>Platinum</option>
</Field>
</FormField>
@ -692,4 +703,4 @@ EditUsersPage.getLayout = function getLayout(page: ReactElement) {
)
}
export default EditUsersPage
export default EditUsersPage

View File

@ -155,6 +155,8 @@ const initialValues = {
custom_permissions: [],
gdpLevel: 1,
}
@ -431,6 +433,15 @@ const UsersNew = () => {
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
</FormField>
<FormField label="GDP Level" labelFor="gdpLevel">
<Field name="gdpLevel" id="gdpLevel" component="select">
<option value={1}>Blue</option>
<option value={2}>Silver</option>
<option value={3}>Gold</option>
<option value={4}>Platinum</option>
</Field>
</FormField>
@ -499,4 +510,4 @@ UsersNew.getLayout = function getLayout(page: ReactElement) {
)
}
export default UsersNew
export default UsersNew