Merge pull request #231 from kimatata/develop
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
.env
|
||||
database.sqlite
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright-screenshots/
|
||||
|
||||
71
Dockerfile
Normal file
71
Dockerfile
Normal file
@@ -0,0 +1,71 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install root dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Install frontend dependencies
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Install backend dependencies
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json backend/package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Build frontend
|
||||
FROM base AS frontend-builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/frontend/node_modules ./frontend/node_modules
|
||||
COPY frontend ./frontend
|
||||
|
||||
WORKDIR /app/frontend
|
||||
RUN npm run build
|
||||
|
||||
# Final production image
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8000
|
||||
ENV FRONTEND_ORIGIN=http://localhost:8000
|
||||
|
||||
# Create a non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy backend files
|
||||
COPY backend ./backend
|
||||
|
||||
# Copy frontend build
|
||||
COPY --from=frontend-builder /app/frontend/.next/standalone ./
|
||||
COPY --from=frontend-builder /app/frontend/.next/static ./.next/static
|
||||
COPY --from=frontend-builder /app/frontend/public ./public
|
||||
|
||||
# Copy Next.js module for the server
|
||||
COPY --from=deps /app/frontend/node_modules/next ./node_modules/next
|
||||
|
||||
# Install backend dependencies directly in the container to ensure native modules are built correctly
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json backend/package-lock.json* ./
|
||||
RUN npm ci
|
||||
WORKDIR /app
|
||||
|
||||
# Copy custom combined_server.js that combines frontend and backend
|
||||
COPY combined_server.js ./
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run database migrations and start the combined server
|
||||
CMD ["node", "combined_server.js"]
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
database.sqlite
|
||||
public/uploads/
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
EXPOSE $PORT
|
||||
RUN npx sequelize db:migrate
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
18
backend/config/config.js
Normal file
18
backend/config/config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const path = require('path');
|
||||
|
||||
const databasePath = path.resolve(__dirname, '../database/database.sqlite');
|
||||
|
||||
module.exports = {
|
||||
development: {
|
||||
dialect: 'sqlite',
|
||||
storage: databasePath,
|
||||
},
|
||||
test: {
|
||||
dialect: 'sqlite',
|
||||
storage: databasePath,
|
||||
},
|
||||
production: {
|
||||
dialect: 'sqlite',
|
||||
storage: databasePath,
|
||||
},
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"development": {
|
||||
"dialect": "sqlite",
|
||||
"storage": "database/database.sqlite"
|
||||
},
|
||||
"test": {
|
||||
"dialect": "sqlite",
|
||||
"storage": "database/database.sqlite"
|
||||
},
|
||||
"production": {
|
||||
"dialect": "sqlite",
|
||||
"storage": "database/database.sqlite"
|
||||
}
|
||||
}
|
||||
152
backend/index.js
152
backend/index.js
@@ -1,153 +1,7 @@
|
||||
const express = require('express');
|
||||
const RateLimit = require('express-rate-limit');
|
||||
const path = require('path');
|
||||
const { Sequelize } = require('sequelize');
|
||||
const app = express();
|
||||
|
||||
// enable frontend access
|
||||
const cors = require('cors');
|
||||
const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:8000';
|
||||
const corsOptions = {
|
||||
origin: frontendOrigin,
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// enable json middleware
|
||||
app.use(express.json());
|
||||
|
||||
// enable rate limiter
|
||||
const limiter = RateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1h
|
||||
max: 1000, // 1000 requests per hour
|
||||
message: 'Too many requests from this IP, please try again after an hour',
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Specify the directory to serve static files
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// init sequalize
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: 'database/database.sqlite',
|
||||
logging: false,
|
||||
});
|
||||
|
||||
// "/"
|
||||
const indexRoute = require('./routes/index');
|
||||
app.use('/', indexRoute);
|
||||
|
||||
// "users"
|
||||
const usersIndexRoute = require('./routes/users/index')(sequelize);
|
||||
const usersFindRoute = require('./routes/users/find')(sequelize);
|
||||
const usersSearchRoute = require('./routes/users/search')(sequelize);
|
||||
const signUpRoute = require('./routes/users/signup')(sequelize);
|
||||
const signInRoute = require('./routes/users/signin')(sequelize);
|
||||
app.use('/users', usersIndexRoute);
|
||||
app.use('/users', usersFindRoute);
|
||||
app.use('/users', usersSearchRoute);
|
||||
app.use('/users', signUpRoute);
|
||||
app.use('/users', signInRoute);
|
||||
// ESM import
|
||||
(async () => {
|
||||
const updateRoute = await import('./routes/users/update.mjs');
|
||||
app.use('/users', updateRoute.default(sequelize));
|
||||
})();
|
||||
|
||||
// "/projects"
|
||||
const projectsIndexRoute = require('./routes/projects/index')(sequelize);
|
||||
const projectsShowRoute = require('./routes/projects/show')(sequelize);
|
||||
const projectsNewRoute = require('./routes/projects/new')(sequelize);
|
||||
const projectsEditRoute = require('./routes/projects/edit')(sequelize);
|
||||
const projectsDeleteRoute = require('./routes/projects/delete')(sequelize);
|
||||
app.use('/projects', projectsIndexRoute);
|
||||
app.use('/projects', projectsShowRoute);
|
||||
app.use('/projects', projectsNewRoute);
|
||||
app.use('/projects', projectsEditRoute);
|
||||
app.use('/projects', projectsDeleteRoute);
|
||||
|
||||
// "/folders"
|
||||
const foldersIndexRoute = require('./routes/folders/index')(sequelize);
|
||||
const foldersNewRoute = require('./routes/folders/new')(sequelize);
|
||||
const foldersEditRoute = require('./routes/folders/edit')(sequelize);
|
||||
const foldersDeleteRoute = require('./routes/folders/delete')(sequelize);
|
||||
app.use('/folders', foldersIndexRoute);
|
||||
app.use('/folders', foldersNewRoute);
|
||||
app.use('/folders', foldersEditRoute);
|
||||
app.use('/folders', foldersDeleteRoute);
|
||||
|
||||
// "/cases"
|
||||
const casesDownloadRoute = require('./routes/cases/download')(sequelize);
|
||||
const casesIndexRoute = require('./routes/cases/index')(sequelize);
|
||||
const casesIndexByProjectIdRoute = require('./routes/cases/indexByProjectId')(sequelize);
|
||||
const casesShowRoute = require('./routes/cases/show')(sequelize);
|
||||
const casesNewRoute = require('./routes/cases/new')(sequelize);
|
||||
const casesEditRoute = require('./routes/cases/edit')(sequelize);
|
||||
const casesDeleteRoute = require('./routes/cases/delete')(sequelize);
|
||||
app.use('/cases', casesDownloadRoute);
|
||||
app.use('/cases', casesIndexRoute);
|
||||
app.use('/cases', casesIndexByProjectIdRoute);
|
||||
app.use('/cases', casesShowRoute);
|
||||
app.use('/cases', casesNewRoute);
|
||||
app.use('/cases', casesEditRoute);
|
||||
app.use('/cases', casesDeleteRoute);
|
||||
|
||||
// "/steps"
|
||||
const stepsEditRoute = require('./routes/steps/edit')(sequelize);
|
||||
app.use('/steps', stepsEditRoute);
|
||||
|
||||
// "/attachments"
|
||||
const attachmentsNewRoute = require('./routes/attachments/new')(sequelize);
|
||||
const attachmentsDeleteRoute = require('./routes/attachments/delete')(sequelize);
|
||||
const attachmentsDownloadRoute = require('./routes/attachments/download')(sequelize);
|
||||
app.use('/attachments', attachmentsNewRoute);
|
||||
app.use('/attachments', attachmentsDeleteRoute);
|
||||
app.use('/attachments', attachmentsDownloadRoute);
|
||||
|
||||
// "/runs"
|
||||
const runsDownloadRoute = require('./routes/runs/download')(sequelize);
|
||||
const runsIndexRoute = require('./routes/runs/index')(sequelize);
|
||||
const runsShowRoute = require('./routes/runs/show')(sequelize);
|
||||
const runsNewRoute = require('./routes/runs/new')(sequelize);
|
||||
const runsEditRoute = require('./routes/runs/edit')(sequelize);
|
||||
const runDeleteRoute = require('./routes/runs/delete')(sequelize);
|
||||
app.use('/runs', runsDownloadRoute);
|
||||
app.use('/runs', runsIndexRoute);
|
||||
app.use('/runs', runsShowRoute);
|
||||
app.use('/runs', runsNewRoute);
|
||||
app.use('/runs', runsEditRoute);
|
||||
app.use('/runs', runDeleteRoute);
|
||||
|
||||
// "/runcases"
|
||||
const runCaseIndexRoute = require('./routes/runcases/index')(sequelize);
|
||||
const runCaseEditRoute = require('./routes/runcases/edit')(sequelize);
|
||||
app.use('/runcases', runCaseIndexRoute);
|
||||
app.use('/runcases', runCaseEditRoute);
|
||||
|
||||
// "/members"
|
||||
const membersIndexRoute = require('./routes/members/index')(sequelize);
|
||||
const membersNewRoute = require('./routes/members/new')(sequelize);
|
||||
const membersEditRoute = require('./routes/members/edit')(sequelize);
|
||||
const membersDeleteRoute = require('./routes/members/delete')(sequelize);
|
||||
const membersCheckRoute = require('./routes/members/check')(sequelize);
|
||||
app.use('/members', membersIndexRoute);
|
||||
app.use('/members', membersNewRoute);
|
||||
app.use('/members', membersEditRoute);
|
||||
app.use('/members', membersDeleteRoute);
|
||||
app.use('/members', membersCheckRoute);
|
||||
|
||||
// "/home"
|
||||
const homeIndexRoute = require('./routes/home/index')(sequelize);
|
||||
app.use('/home', homeIndexRoute);
|
||||
const server = require('./server');
|
||||
|
||||
const PORT = process.env.PORT || 8001;
|
||||
app.listen(PORT, () => {
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Backend server is running on port ${PORT}`);
|
||||
console.log(`Access from the frontend origin: ${frontendOrigin} is valid.`);
|
||||
if (!process.env.SECRET_KEY) {
|
||||
console.log(
|
||||
"[Warning]: Default key is used for token generation. Please set the environment variable 'SECRET_KEY'`."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"scripts": {
|
||||
"start": "node --env-file=.env index",
|
||||
"dev": "node index",
|
||||
"migrate": "bash scripts/migrate.sh",
|
||||
"seed": "bash scripts/seed.sh",
|
||||
"drop": "bash scripts/drop.sh"
|
||||
"migrate": "sequelize db:migrate",
|
||||
"seed": "sequelize db:seed:all",
|
||||
"drop": "sequelize db:migrate:undo:all"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
npx sequelize db:migrate:undo:all
|
||||
echo "Dropped all tables"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
npx sequelize db:migrate
|
||||
echo "Migration complete."
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
npx sequelize db:seed:all
|
||||
153
backend/server.js
Normal file
153
backend/server.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const RateLimit = require('express-rate-limit');
|
||||
const { Sequelize } = require('sequelize');
|
||||
const app = express();
|
||||
|
||||
// enable frontend access
|
||||
const cors = require('cors');
|
||||
const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:8000';
|
||||
const corsOptions = {
|
||||
origin: frontendOrigin,
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// enable json middleware
|
||||
app.use(express.json());
|
||||
|
||||
// enable rate limiter
|
||||
const limiter = RateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1h
|
||||
max: 1000, // 1000 requests per hour
|
||||
message: 'Too many requests from this IP, please try again after an hour',
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Specify the directory to serve static files
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// init sequalize
|
||||
const databasePath = path.resolve(__dirname, 'database/database.sqlite');
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: databasePath,
|
||||
logging: false,
|
||||
});
|
||||
|
||||
// "/"
|
||||
const indexRoute = require('./routes/index');
|
||||
app.use('/', indexRoute);
|
||||
|
||||
// "users"
|
||||
const usersIndexRoute = require('./routes/users/index')(sequelize);
|
||||
const usersFindRoute = require('./routes/users/find')(sequelize);
|
||||
const usersSearchRoute = require('./routes/users/search')(sequelize);
|
||||
const signUpRoute = require('./routes/users/signup')(sequelize);
|
||||
const signInRoute = require('./routes/users/signin')(sequelize);
|
||||
app.use('/users', usersIndexRoute);
|
||||
app.use('/users', usersFindRoute);
|
||||
app.use('/users', usersSearchRoute);
|
||||
app.use('/users', signUpRoute);
|
||||
app.use('/users', signInRoute);
|
||||
// ESM import
|
||||
(async () => {
|
||||
const updateRoute = await import('./routes/users/update.mjs');
|
||||
app.use('/users', updateRoute.default(sequelize));
|
||||
})();
|
||||
|
||||
// "/projects"
|
||||
const projectsIndexRoute = require('./routes/projects/index')(sequelize);
|
||||
const projectsShowRoute = require('./routes/projects/show')(sequelize);
|
||||
const projectsNewRoute = require('./routes/projects/new')(sequelize);
|
||||
const projectsEditRoute = require('./routes/projects/edit')(sequelize);
|
||||
const projectsDeleteRoute = require('./routes/projects/delete')(sequelize);
|
||||
app.use('/projects', projectsIndexRoute);
|
||||
app.use('/projects', projectsShowRoute);
|
||||
app.use('/projects', projectsNewRoute);
|
||||
app.use('/projects', projectsEditRoute);
|
||||
app.use('/projects', projectsDeleteRoute);
|
||||
|
||||
// "/folders"
|
||||
const foldersIndexRoute = require('./routes/folders/index')(sequelize);
|
||||
const foldersNewRoute = require('./routes/folders/new')(sequelize);
|
||||
const foldersEditRoute = require('./routes/folders/edit')(sequelize);
|
||||
const foldersDeleteRoute = require('./routes/folders/delete')(sequelize);
|
||||
app.use('/folders', foldersIndexRoute);
|
||||
app.use('/folders', foldersNewRoute);
|
||||
app.use('/folders', foldersEditRoute);
|
||||
app.use('/folders', foldersDeleteRoute);
|
||||
|
||||
// "/cases"
|
||||
// "/cases"
|
||||
const casesDownloadRoute = require('./routes/cases/download')(sequelize);
|
||||
const casesIndexRoute = require('./routes/cases/index')(sequelize);
|
||||
const casesIndexByProjectIdRoute = require('./routes/cases/indexByProjectId')(sequelize);
|
||||
const casesShowRoute = require('./routes/cases/show')(sequelize);
|
||||
const casesNewRoute = require('./routes/cases/new')(sequelize);
|
||||
const casesEditRoute = require('./routes/cases/edit')(sequelize);
|
||||
const casesDeleteRoute = require('./routes/cases/delete')(sequelize);
|
||||
app.use('/cases', casesDownloadRoute);
|
||||
app.use('/cases', casesIndexRoute);
|
||||
app.use('/cases', casesIndexByProjectIdRoute);
|
||||
app.use('/cases', casesShowRoute);
|
||||
app.use('/cases', casesNewRoute);
|
||||
app.use('/cases', casesEditRoute);
|
||||
app.use('/cases', casesDeleteRoute);
|
||||
|
||||
// "/steps"
|
||||
const stepsEditRoute = require('./routes/steps/edit')(sequelize);
|
||||
app.use('/steps', stepsEditRoute);
|
||||
|
||||
// "/attachments"
|
||||
const attachmentsNewRoute = require('./routes/attachments/new')(sequelize);
|
||||
const attachmentsDeleteRoute = require('./routes/attachments/delete')(sequelize);
|
||||
const attachmentsDownloadRoute = require('./routes/attachments/download')(sequelize);
|
||||
app.use('/attachments', attachmentsNewRoute);
|
||||
app.use('/attachments', attachmentsDeleteRoute);
|
||||
app.use('/attachments', attachmentsDownloadRoute);
|
||||
|
||||
// "/runs"
|
||||
const runsDownloadRoute = require('./routes/runs/download')(sequelize);
|
||||
const runsIndexRoute = require('./routes/runs/index')(sequelize);
|
||||
const runsShowRoute = require('./routes/runs/show')(sequelize);
|
||||
const runsNewRoute = require('./routes/runs/new')(sequelize);
|
||||
const runsEditRoute = require('./routes/runs/edit')(sequelize);
|
||||
const runDeleteRoute = require('./routes/runs/delete')(sequelize);
|
||||
app.use('/runs', runsDownloadRoute);
|
||||
app.use('/runs', runsIndexRoute);
|
||||
app.use('/runs', runsShowRoute);
|
||||
app.use('/runs', runsNewRoute);
|
||||
app.use('/runs', runsEditRoute);
|
||||
app.use('/runs', runDeleteRoute);
|
||||
|
||||
// "/runcases"
|
||||
const runCaseIndexRoute = require('./routes/runcases/index')(sequelize);
|
||||
const runCaseEditRoute = require('./routes/runcases/edit')(sequelize);
|
||||
app.use('/runcases', runCaseIndexRoute);
|
||||
app.use('/runcases', runCaseEditRoute);
|
||||
|
||||
// "/members"
|
||||
const membersIndexRoute = require('./routes/members/index')(sequelize);
|
||||
const membersNewRoute = require('./routes/members/new')(sequelize);
|
||||
const membersEditRoute = require('./routes/members/edit')(sequelize);
|
||||
const membersDeleteRoute = require('./routes/members/delete')(sequelize);
|
||||
const membersCheckRoute = require('./routes/members/check')(sequelize);
|
||||
app.use('/members', membersIndexRoute);
|
||||
app.use('/members', membersNewRoute);
|
||||
app.use('/members', membersEditRoute);
|
||||
app.use('/members', membersDeleteRoute);
|
||||
app.use('/members', membersCheckRoute);
|
||||
|
||||
// "/home"
|
||||
const homeIndexRoute = require('./routes/home/index')(sequelize);
|
||||
app.use('/home', homeIndexRoute);
|
||||
|
||||
if (!process.env.SECRET_KEY) {
|
||||
console.log(
|
||||
"[Warning]: Default key is used for token generation. Please set the environment variable 'SECRET_KEY'`."
|
||||
);
|
||||
}
|
||||
|
||||
// Export the app instead of starting the server
|
||||
module.exports = app;
|
||||
87
combined_server.js
Normal file
87
combined_server.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// Use express from backend node_modules
|
||||
const express = require('./backend/node_modules/express');
|
||||
const { createServer: createHttpServer } = require('http');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
console.log('Running database migrations...');
|
||||
// Use execSync with cwd option to run in the backend directory
|
||||
execSync('npx sequelize-cli db:migrate', {
|
||||
cwd: path.join(__dirname, 'backend'),
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
console.log('Database migrations completed successfully.');
|
||||
|
||||
if (process.env.IS_DEMO === 'true' || process.env.IS_DEMO === '1') {
|
||||
console.log('Demo mode detected. Seeding the database...');
|
||||
execSync('npx sequelize-cli db:seed:all', {
|
||||
cwd: path.join(__dirname, 'backend'),
|
||||
stdio: 'inherit',
|
||||
});
|
||||
console.log('Database seeding completed successfully.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error running database migrations or seeding:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
try {
|
||||
const server = express();
|
||||
const httpServer = createHttpServer(server);
|
||||
|
||||
// Import the backend app
|
||||
const backendApp = require('./backend/server');
|
||||
|
||||
// Use the backend app for /api routes
|
||||
server.use('/api', backendApp);
|
||||
|
||||
// For Next.js standalone build
|
||||
// Check if we have the Next.js server file
|
||||
const nextServerPath = './node_modules/next/dist/server/next.js';
|
||||
if (fs.existsSync(nextServerPath)) {
|
||||
// Import Next.js
|
||||
const next = require(nextServerPath);
|
||||
|
||||
// Initialize Next.js app
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const nextApp = next({ dev, dir: path.join(__dirname, '.') });
|
||||
const handle = nextApp.getRequestHandler();
|
||||
|
||||
// Prepare Next.js
|
||||
nextApp.prepare().then(() => {
|
||||
console.log('nextjs prepared');
|
||||
// Use Next.js to handle all other routes
|
||||
server.all('*', (req, res) => {
|
||||
return handle(req, res);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.error('Next.js module not found at:', nextServerPath);
|
||||
server.all('*', (req, res) => {
|
||||
res.status(500).send('Frontend server not available');
|
||||
});
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 8000;
|
||||
httpServer.listen(PORT, (err) => {
|
||||
if (err) throw err;
|
||||
console.log(`> Ready on http://localhost:${PORT}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error starting server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations().then(() => {
|
||||
startServer();
|
||||
}).catch(error => {
|
||||
console.error('Failed to start application:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,31 +1,15 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: unittcms-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
ports:
|
||||
- '8001:8001'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- FRONTEND_ORIGIN=http://localhost:8000
|
||||
- PORT=8001
|
||||
volumes:
|
||||
- db-data:/app/database
|
||||
|
||||
frontend:
|
||||
image: unittcms-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
unittcms:
|
||||
image: unittcms:latest
|
||||
build: .
|
||||
ports:
|
||||
- '8000:8000'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_BACKEND_ORIGIN=http://localhost:8001
|
||||
- PORT=8000
|
||||
depends_on:
|
||||
- backend
|
||||
- SECRET_KEY=your_secret_key_here
|
||||
- IS_DEMO=false # set to true to seed the database
|
||||
volumes:
|
||||
- db-data:/app/backend/database
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
sidebar_position: 5
|
||||
draft: true
|
||||
---
|
||||
|
||||
# Deployment
|
||||
|
||||
@@ -10,7 +10,7 @@ First, clone the repository.
|
||||
git clone https://github.com/kimatata/unittcms.git
|
||||
```
|
||||
|
||||
and start containers with the following command.
|
||||
and start container with the following command.
|
||||
|
||||
```bash
|
||||
cd unittcms
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Override settings
|
||||
|
||||
The system will work with default settings without setting environment variables, but you can override the settings by placing `.env` file.
|
||||
UnitTCMS will work with the default settings, but you can override them as needed.
|
||||
|
||||
## setting frontend environment variables
|
||||
:::warning[Strongly Recommended]
|
||||
|
||||
Create `.env` File on `frontend/`
|
||||
It is strongly recommended to change `SECRET_KEY` from the default value in production.
|
||||
|
||||
:::
|
||||
|
||||
## Docker
|
||||
|
||||
If you are self-hosting UnitTCMS with Docker, you can customize the environment using the `environment` section in `docker-compose.yaml`.
|
||||
|
||||
```yaml title="docker-compose.yaml"
|
||||
services:
|
||||
unittcms:
|
||||
image: unittcms:latest
|
||||
build: .
|
||||
ports:
|
||||
- '8000:8000'
|
||||
// highlight-start
|
||||
environment:
|
||||
- PORT=8000
|
||||
- SECRET_KEY=your_secret_key_here
|
||||
- IS_DEMO=false # set to true to seed the database
|
||||
// highlight-end
|
||||
volumes:
|
||||
- db-data:/app/backend/database
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
```
|
||||
|
||||
## From Source
|
||||
|
||||
If you are self-hosting UnitTCMS from source, you can override the environment by placing `.env` files in the appropriate directory.
|
||||
|
||||
### Setting frontend environment variables
|
||||
|
||||
Create a `.env` file in the `frontend/` directory:
|
||||
|
||||
```.env title="frontend/.env"
|
||||
NEXT_PUBLIC_BACKEND_ORIGIN=http://localhost:8001
|
||||
```
|
||||
|
||||
## setting backend environment variables
|
||||
### Setting backend environment variables
|
||||
|
||||
Create `.env` File on `backend/`
|
||||
|
||||
:::warning[Strongly Recommended]
|
||||
|
||||
It is strongly recommended to set SECRET_KEY in production.
|
||||
|
||||
:::
|
||||
Create a `.env` file in the `backend/` directory:
|
||||
|
||||
```.env title="backend/.env"
|
||||
FRONTEND_ORIGIN=http://localhost:8000
|
||||
|
||||
75
docs/docs/getstarted/from-source.md
Normal file
75
docs/docs/getstarted/from-source.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Running UnitTCMS from Source
|
||||
|
||||
While using Docker is the recommended and easiest way to run UnitTCMS, this chapter explains how to run it directly from source. This approach is useful for those who cannot use Docker or are interested in contributing to UnitTCMS development. Since the frontend and backend are completely decoupled, this setup also supports more advanced use cases — such as serving the frontend via AWS S3 + CloudFront, or replacing the default SQLite database with another backend solution.
|
||||
|
||||
:::info[Prerequisite]
|
||||
|
||||
Prerequisite: v20 or higher node must be installed.
|
||||
|
||||
:::
|
||||
|
||||
To use UnitTCMS, you need to run both frontend server and backend(API) server.
|
||||
|
||||
First, clone the repository.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kimatata/unittcms.git
|
||||
```
|
||||
|
||||
## Run backend server
|
||||
|
||||
Place the .env file at `backend/.env`.
|
||||
|
||||
```.env title="backend/.env"
|
||||
FRONTEND_ORIGIN=http://localhost:8000
|
||||
```
|
||||
|
||||
Move to backend directory, then install dependencies.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
Initialize the database with the following command.
|
||||
|
||||
```bash
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
Start backend server.
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Run frontend server
|
||||
|
||||
Move to frontend directory, then install dependencies.
|
||||
|
||||
Place the .env file at `frontend/.env`.
|
||||
|
||||
```.env title="frontend/.env"
|
||||
NEXT_PUBLIC_BACKEND_ORIGIN=http://localhost:8001
|
||||
```
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
Build frontend code
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Start frontend server
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Running UnitTCMS manually
|
||||
|
||||
:::info[Prerequisite]
|
||||
|
||||
Prerequisite: v20 or higher node must be installed.
|
||||
|
||||
:::
|
||||
|
||||
To use UnitTCMS, you need run frontend server and backend(API) server.
|
||||
|
||||
First, clone the repository.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kimatata/unittcms.git
|
||||
```
|
||||
|
||||
## Run backend server
|
||||
|
||||
Move to backend directory, then install dependencies.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm ci
|
||||
```
|
||||
|
||||
Initialize the database with the following command.
|
||||
|
||||
```bash
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
Start backend server.
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Run frontend server
|
||||
|
||||
Move to frontend directory, then install dependencies.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
```
|
||||
|
||||
Build frontend code
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Start frontend server
|
||||
|
||||
```
|
||||
npm run start
|
||||
```
|
||||
@@ -7,4 +7,4 @@ sidebar_position: 1
|
||||
UnitTCMS is designed for self-hosted use. There are two ways to self-host the application: “Docker” and “Manual”.
|
||||
|
||||
- Docker (Recommended)
|
||||
- Manual
|
||||
- From Source
|
||||
|
||||
12604
docs/package-lock.json
generated
12604
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,11 @@
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.4.0",
|
||||
"@docusaurus/module-type-aliases": "^3.4.0",
|
||||
"@docusaurus/preset-classic": "^3.5.2",
|
||||
"@docusaurus/theme-mermaid": "^3.5.2",
|
||||
"@docusaurus/types": "^3.4.0",
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/module-type-aliases": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@docusaurus/types": "^3.7.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto('http://localhost:8000/');
|
||||
await expect(page).toHaveTitle('Open Source Test Case Management System | UnitTCMS');
|
||||
});
|
||||
12
e2e/not-demo-mode.spec.ts
Normal file
12
e2e/not-demo-mode.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('If not demo mode, the landing page will not be displayed and will be redirected to the projects page.', async ({
|
||||
page,
|
||||
}) => {
|
||||
const screenshotDir = 'playwright-screenshots/not-demo-redirect';
|
||||
|
||||
await page.goto('http://localhost:8000/');
|
||||
await expect(page).toHaveURL(/\/projects/);
|
||||
|
||||
await page.screenshot({ path: `${screenshotDir}/landing-page-redirect.png`, fullPage: true });
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD HOSTNAME="0.0.0.0" node server.js
|
||||
@@ -1,5 +1,5 @@
|
||||
const Config = {
|
||||
apiServer: process.env.NEXT_PUBLIC_BACKEND_ORIGIN || 'http://localhost:8001',
|
||||
apiServer: process.env.NEXT_PUBLIC_BACKEND_ORIGIN || '/api',
|
||||
|
||||
// set 'NEXT_PUBLIC_IS_DEMO=true' in frontend/.env
|
||||
isDemoSite: process.env.NEXT_PUBLIC_IS_DEMO === 'true' || false,
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"languages": "Languages"
|
||||
},
|
||||
"Toast": {
|
||||
"need_signed_in": "You need signed in",
|
||||
"need_signed_in": "You need to sign in",
|
||||
"session_expired": "Session expired"
|
||||
},
|
||||
"Auth": {
|
||||
|
||||
@@ -24,6 +24,7 @@ import DropdownAccount from './DropdownAccount';
|
||||
import DropdownLanguage from './DropdownLanguage';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import Config from '@/config/config';
|
||||
|
||||
type NabbarMenuMessages = {
|
||||
projects: string;
|
||||
@@ -54,6 +55,10 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
label: messages.projects,
|
||||
isExternal: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (Config.isDemoSite) {
|
||||
commonLinks.push(
|
||||
{
|
||||
uid: 'docs',
|
||||
href: 'https://kimatata.github.io/unittcms/docs/getstarted/selfhost',
|
||||
@@ -65,8 +70,9 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
href: 'https://kimatata.github.io/unittcms/docs/roadmap/',
|
||||
label: messages.roadmap,
|
||||
isExternal: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -94,7 +100,7 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
<NavbarItem className="hidden md:block">
|
||||
<Chip size="sm" variant="flat">
|
||||
<Link className="data-[active=true]:text-primary data-[active=true]:font-medium" href="/" locale={locale}>
|
||||
1.0.0-beta.12
|
||||
1.0.0-beta.13
|
||||
</Link>
|
||||
</Chip>
|
||||
</NavbarItem>
|
||||
|
||||
106
frontend/src/app/[locale]/LandingPage.tsx
Normal file
106
frontend/src/app/[locale]/LandingPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Divider } from '@heroui/react';
|
||||
import PaneMainTitle from './PaneMainTitle';
|
||||
import PaneMainFeatures from './PaneMainFeatures';
|
||||
import DemoImage from './DemoImage';
|
||||
import { title, subtitle } from '@/components/primitives';
|
||||
import { PageType } from '@/types/base';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
|
||||
export default function LandingPage({ params }: PageType) {
|
||||
const t = useTranslations('Index');
|
||||
|
||||
const demoImages = [
|
||||
{
|
||||
uid: 'project',
|
||||
title: t('project_title'),
|
||||
subTitle: t('project_subtitle'),
|
||||
},
|
||||
{
|
||||
uid: 'case',
|
||||
title: t('case_management_title'),
|
||||
subTitle: t('case_management_subtitle'),
|
||||
},
|
||||
{
|
||||
uid: 'run',
|
||||
title: t('run_management_title'),
|
||||
subTitle: t('run_management_subtitle'),
|
||||
},
|
||||
{
|
||||
uid: 'member',
|
||||
title: t('member_management_title'),
|
||||
subTitle: t('member_management_subtitle'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-screen-xl my-12">
|
||||
<div className="flex flex-wrap">
|
||||
<div className="w-full md:w-7/12 order-last md:order-first p-4">
|
||||
<PaneMainTitle locale={params.locale as LocaleCodeType} />
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-5/12 p-4">
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10rem',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
📋
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'blur(48px)',
|
||||
zIndex: 0,
|
||||
background: 'linear-gradient(to bottom, #ffecd2, #fcb69f)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap flex-col items-center"
|
||||
style={{
|
||||
marginTop: '6rem',
|
||||
}}
|
||||
>
|
||||
<PaneMainFeatures />
|
||||
</div>
|
||||
|
||||
<Divider className="my-12" />
|
||||
<div className="flex flex-wrap lg:text-left text-center">
|
||||
{demoImages.map((demoImage) => (
|
||||
<div key={demoImage.uid} className="flex flex-wrap">
|
||||
<div className="w-full lg:w-5/12 p-4">
|
||||
<h2 className={title({ size: 'sm', color: 'pink' })}>{demoImage.title}</h2>
|
||||
<h4 className={subtitle({ class: 'mt-4' })}>{demoImage.subTitle}</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center w-full lg:w-7/12 p-4">
|
||||
<DemoImage imageName={demoImage.uid} altText={demoImage.title} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider className="my-12" />
|
||||
<div className="w-full text-center py-2">
|
||||
<div>Copyright © 2024-present UnitTCMS</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +1,13 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Divider } from '@heroui/react';
|
||||
import { title, subtitle } from '@/components/primitives';
|
||||
import PaneMainTitle from './PaneMainTitle';
|
||||
import PaneMainFeatures from './PaneMainFeatures';
|
||||
import DemoImage from './DemoImage';
|
||||
import { redirect } from 'next/navigation'
|
||||
import { PageType } from '@/types/base';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import Config from '@/config/config';
|
||||
import LandingPage from '@/src/app/[locale]/LandingPage';
|
||||
|
||||
export default function Home({ params }: PageType) {
|
||||
const t = useTranslations('Index');
|
||||
|
||||
const demoImages = [
|
||||
{
|
||||
uid: 'project',
|
||||
title: t('project_title'),
|
||||
subTitle: t('project_subtitle'),
|
||||
},
|
||||
{
|
||||
uid: 'case',
|
||||
title: t('case_management_title'),
|
||||
subTitle: t('case_management_subtitle'),
|
||||
},
|
||||
{
|
||||
uid: 'run',
|
||||
title: t('run_management_title'),
|
||||
subTitle: t('run_management_subtitle'),
|
||||
},
|
||||
{
|
||||
uid: 'member',
|
||||
title: t('member_management_title'),
|
||||
subTitle: t('member_management_subtitle'),
|
||||
},
|
||||
];
|
||||
if (!Config.isDemoSite) {
|
||||
redirect(`/${params.locale}/projects`)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-screen-xl my-12">
|
||||
<div className="flex flex-wrap">
|
||||
<div className="w-full md:w-7/12 order-last md:order-first p-4">
|
||||
<PaneMainTitle locale={params.locale as LocaleCodeType} />
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-5/12 p-4">
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10rem',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
📋
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'blur(48px)',
|
||||
zIndex: 0,
|
||||
background: 'linear-gradient(to bottom, #ffecd2, #fcb69f)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap flex-col items-center"
|
||||
style={{
|
||||
marginTop: '6rem',
|
||||
}}
|
||||
>
|
||||
<PaneMainFeatures />
|
||||
</div>
|
||||
|
||||
<Divider className="my-12" />
|
||||
<div className="flex flex-wrap lg:text-left text-center">
|
||||
{demoImages.map((demoImage) => (
|
||||
<div key={demoImage.uid} className="flex flex-wrap">
|
||||
<div className="w-full lg:w-5/12 p-4">
|
||||
<h2 className={title({ size: 'sm', color: 'pink' })}>{demoImage.title}</h2>
|
||||
<h4 className={subtitle({ class: 'mt-4' })}>{demoImage.subTitle}</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center w-full lg:w-7/12 p-4">
|
||||
<DemoImage imageName={demoImage.uid} altText={demoImage.title} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider className="my-12" />
|
||||
<div className="w-full text-center py-2">
|
||||
<div>Copyright © 2024-present UnitTCMS</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return <LandingPage params={params} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user