Remove decorative icons and update CORS configuration
This commit is contained in:
101
DEPLOYMENT.md
Normal file
101
DEPLOYMENT.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Filamenteka Deployment Guide
|
||||
|
||||
## Architecture
|
||||
|
||||
Filamenteka now uses:
|
||||
- **Frontend**: Next.js deployed on AWS Amplify
|
||||
- **Database**: PostgreSQL on AWS RDS (publicly accessible)
|
||||
- **API**: Node.js server that can be run locally or deployed anywhere
|
||||
|
||||
## AWS RDS Setup
|
||||
|
||||
1. Navigate to the terraform directory:
|
||||
```bash
|
||||
cd terraform
|
||||
```
|
||||
|
||||
2. Initialize Terraform:
|
||||
```bash
|
||||
terraform init
|
||||
```
|
||||
|
||||
3. Apply the infrastructure:
|
||||
```bash
|
||||
terraform apply
|
||||
```
|
||||
|
||||
4. After deployment, get the database connection details:
|
||||
```bash
|
||||
terraform output -json
|
||||
```
|
||||
|
||||
5. Get the database password from AWS Secrets Manager:
|
||||
```bash
|
||||
aws secretsmanager get-secret-value --secret-id filamenteka-db-credentials --query SecretString --output text | jq -r .password
|
||||
```
|
||||
|
||||
## Running the API
|
||||
|
||||
### Option 1: Local Development
|
||||
|
||||
1. Create `.env` file in the `api` directory:
|
||||
```
|
||||
DATABASE_URL=postgresql://filamenteka_admin:[PASSWORD]@[RDS_ENDPOINT]/filamenteka
|
||||
JWT_SECRET=your-secret-key-here
|
||||
ADMIN_PASSWORD=your-password-here
|
||||
PORT=4000
|
||||
```
|
||||
|
||||
2. Install dependencies and run migrations:
|
||||
```bash
|
||||
cd api
|
||||
npm install
|
||||
npm run migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Option 2: Deploy on a VPS/Cloud Service
|
||||
|
||||
You can deploy the Node.js API on:
|
||||
- Heroku
|
||||
- Railway
|
||||
- Render
|
||||
- AWS EC2
|
||||
- DigitalOcean
|
||||
- Any VPS with Node.js
|
||||
|
||||
Just ensure the `DATABASE_URL` points to your RDS instance.
|
||||
|
||||
## Frontend Configuration
|
||||
|
||||
Update `.env.local` to point to your API:
|
||||
```
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api # For local
|
||||
# or
|
||||
NEXT_PUBLIC_API_URL=https://your-api-domain.com/api # For production
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **RDS Security**: The current configuration allows access from anywhere (0.0.0.0/0). In production:
|
||||
- Update the security group to only allow your IP addresses
|
||||
- Or use a VPN/bastion host
|
||||
- Or deploy the API in the same VPC and restrict access
|
||||
|
||||
2. **API Security**:
|
||||
- Change the default admin password
|
||||
- Use strong JWT secrets
|
||||
- Enable HTTPS in production
|
||||
|
||||
## Database Management
|
||||
|
||||
Connect to the PostgreSQL database using any client:
|
||||
```
|
||||
psql postgresql://filamenteka_admin:[PASSWORD]@[RDS_ENDPOINT]/filamenteka
|
||||
```
|
||||
|
||||
Or use a GUI tool like:
|
||||
- pgAdmin
|
||||
- TablePlus
|
||||
- DBeaver
|
||||
- DataGrip
|
||||
62
__tests__/auth-security.test.ts
Normal file
62
__tests__/auth-security.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('Authentication Security Tests', () => {
|
||||
it('should use /upadaj route instead of /admin', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminLoginPath = join(process.cwd(), 'app', 'upadaj', 'page.tsx');
|
||||
|
||||
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
const loginContent = readFileSync(adminLoginPath, 'utf-8');
|
||||
|
||||
// Check that /admin is not used
|
||||
expect(dashboardContent).not.toContain("'/admin'");
|
||||
expect(loginContent).not.toContain("'/admin/dashboard'");
|
||||
|
||||
// Check that /upadaj is used
|
||||
expect(dashboardContent).toContain("'/upadaj'");
|
||||
expect(loginContent).toContain("'/upadaj/dashboard'");
|
||||
});
|
||||
|
||||
it('should have proper password hash in terraform vars', () => {
|
||||
const tfvarsPath = join(process.cwd(), 'terraform', 'terraform.tfvars');
|
||||
const tfvarsContent = readFileSync(tfvarsPath, 'utf-8');
|
||||
|
||||
// Check that password hash is present and looks like bcrypt
|
||||
expect(tfvarsContent).toMatch(/admin_password_hash\s*=\s*"\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}"/);
|
||||
|
||||
// Ensure the new password hash is set (this is the hash for Filamenteka2025!)
|
||||
expect(tfvarsContent).toContain('$2b$10$5G9fgrNGEKMMDunJkjtzy.vWCmLNIftf6HTby25TylgQHqsePI3CG');
|
||||
});
|
||||
|
||||
it('should include proper CORS headers in Lambda functions', () => {
|
||||
const filamentsLambda = join(process.cwd(), 'lambda', 'filaments', 'index.js');
|
||||
const authLambda = join(process.cwd(), 'lambda', 'auth', 'index.js');
|
||||
const colorsLambda = join(process.cwd(), 'lambda', 'colors', 'index.js');
|
||||
|
||||
const filamentsContent = readFileSync(filamentsLambda, 'utf-8');
|
||||
const authContent = readFileSync(authLambda, 'utf-8');
|
||||
const colorsContent = readFileSync(colorsLambda, 'utf-8');
|
||||
|
||||
// Check that all Lambda functions include X-Accept-Format in CORS headers
|
||||
expect(filamentsContent).toContain('X-Accept-Format');
|
||||
expect(authContent).toContain('X-Accept-Format');
|
||||
expect(colorsContent).toContain('X-Accept-Format');
|
||||
});
|
||||
|
||||
it('should have JWT authentication in protected endpoints', () => {
|
||||
const authLambda = join(process.cwd(), 'lambda', 'auth', 'index.js');
|
||||
const colorsLambda = join(process.cwd(), 'lambda', 'colors', 'index.js');
|
||||
|
||||
const authContent = readFileSync(authLambda, 'utf-8');
|
||||
const colorsContent = readFileSync(colorsLambda, 'utf-8');
|
||||
|
||||
// Check for JWT in auth Lambda
|
||||
expect(authContent).toContain('jwt.sign');
|
||||
expect(authContent).toContain('jwt.verify');
|
||||
|
||||
// Check for auth verification in colors Lambda
|
||||
expect(colorsContent).toContain('verifyAuth');
|
||||
expect(colorsContent).toContain('Authorization');
|
||||
});
|
||||
});
|
||||
42
__tests__/data-structure.test.ts
Normal file
42
__tests__/data-structure.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('Data Structure Tests', () => {
|
||||
it('should have bojaHex field in Filament interface', () => {
|
||||
const filamentTypePath = join(process.cwd(), 'src', 'types', 'filament.ts');
|
||||
const typeContent = readFileSync(filamentTypePath, 'utf-8');
|
||||
|
||||
expect(typeContent).toContain('bojaHex?: string;');
|
||||
});
|
||||
|
||||
it('should handle V2 data format in Lambda', () => {
|
||||
const filamentsLambdaPath = join(process.cwd(), 'lambda', 'filaments', 'index.js');
|
||||
const lambdaContent = readFileSync(filamentsLambdaPath, 'utf-8');
|
||||
|
||||
// Check for V2 format handling
|
||||
expect(lambdaContent).toContain('X-Accept-Format');
|
||||
expect(lambdaContent).toContain('transformToLegacy');
|
||||
expect(lambdaContent).toContain('acceptsNewFormat');
|
||||
});
|
||||
|
||||
it('should have colors table structure', () => {
|
||||
const colorsLambdaPath = join(process.cwd(), 'lambda', 'colors', 'index.js');
|
||||
const colorsContent = readFileSync(colorsLambdaPath, 'utf-8');
|
||||
|
||||
// Check for colors table handling
|
||||
expect(colorsContent).toContain('COLORS_TABLE_NAME');
|
||||
expect(colorsContent).toContain('name: data.name');
|
||||
expect(colorsContent).toContain('hex: data.hex');
|
||||
});
|
||||
|
||||
it('should have proper DynamoDB table configuration', () => {
|
||||
const dynamodbTfPath = join(process.cwd(), 'terraform', 'dynamodb.tf');
|
||||
const tfContent = readFileSync(dynamodbTfPath, 'utf-8');
|
||||
|
||||
// Check for DynamoDB configuration
|
||||
expect(tfContent).toContain('aws_dynamodb_table');
|
||||
expect(tfContent).toContain('${var.app_name}-filaments');
|
||||
expect(tfContent).toContain('hash_key');
|
||||
expect(tfContent).toContain('billing_mode = "PAY_PER_REQUEST"');
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe('No Mock Data Tests', () => {
|
||||
|
||||
it('should use NEXT_PUBLIC_API_URL in all components', () => {
|
||||
const pagePath = join(process.cwd(), 'app', 'page.tsx');
|
||||
const adminPath = join(process.cwd(), 'app', 'admin', 'dashboard', 'page.tsx');
|
||||
const adminPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
|
||||
const pageContent = readFileSync(pagePath, 'utf-8');
|
||||
const adminContent = readFileSync(adminPath, 'utf-8');
|
||||
|
||||
77
__tests__/ui-features.test.ts
Normal file
77
__tests__/ui-features.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('UI Features Tests', () => {
|
||||
it('should have color hex input in admin form', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for color input
|
||||
expect(adminContent).toContain('type="color"');
|
||||
expect(adminContent).toContain('bojaHex');
|
||||
expect(adminContent).toContain('Hex kod boje');
|
||||
});
|
||||
|
||||
it('should display color hex in frontend table', () => {
|
||||
const filamentTablePath = join(process.cwd(), 'src', 'components', 'FilamentTable.tsx');
|
||||
const tableContent = readFileSync(filamentTablePath, 'utf-8');
|
||||
|
||||
// Check for color hex display
|
||||
expect(tableContent).toContain('filament.bojaHex');
|
||||
expect(tableContent).toContain('backgroundColor: filament.bojaHex');
|
||||
});
|
||||
|
||||
it('should have checkboxes for boolean fields', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for checkbox inputs
|
||||
expect(adminContent).toMatch(/type="checkbox"[\s\S]*?name="refill"/);
|
||||
expect(adminContent).toMatch(/type="checkbox"[\s\S]*?name="vakum"/);
|
||||
expect(adminContent).toMatch(/type="checkbox"[\s\S]*?name="otvoreno"/);
|
||||
});
|
||||
|
||||
it('should have number input for quantity', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for number input
|
||||
expect(adminContent).toMatch(/type="number"[\s\S]*?name="kolicina"/);
|
||||
expect(adminContent).toContain('min="0"');
|
||||
expect(adminContent).toContain('step="1"');
|
||||
});
|
||||
|
||||
it('should have predefined brand options', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for brand select dropdown
|
||||
expect(adminContent).toContain('<option value="Bambu Lab">Bambu Lab</option>');
|
||||
expect(adminContent).toContain('<option value="PolyMaker">PolyMaker</option>');
|
||||
expect(adminContent).toContain('<option value="Prusament">Prusament</option>');
|
||||
});
|
||||
|
||||
it('should have sidebar navigation in admin', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const colorsPagePath = join(process.cwd(), 'app', 'upadaj', 'colors', 'page.tsx');
|
||||
|
||||
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
const colorsContent = readFileSync(colorsPagePath, 'utf-8');
|
||||
|
||||
// Check for sidebar
|
||||
expect(dashboardContent).toContain('href="/upadaj/dashboard"');
|
||||
expect(dashboardContent).toContain('href="/upadaj/colors"');
|
||||
expect(colorsContent).toContain('href="/upadaj/dashboard"');
|
||||
expect(colorsContent).toContain('href="/upadaj/colors"');
|
||||
});
|
||||
|
||||
it('should have Safari-specific select styling', () => {
|
||||
const selectCssPath = join(process.cwd(), 'src', 'styles', 'select.css');
|
||||
const selectContent = readFileSync(selectCssPath, 'utf-8');
|
||||
|
||||
// Check for Safari fixes
|
||||
expect(selectContent).toContain('-webkit-appearance: none !important');
|
||||
expect(selectContent).toContain('@supports (-webkit-appearance: none)');
|
||||
expect(selectContent).toContain('-webkit-min-device-pixel-ratio');
|
||||
});
|
||||
});
|
||||
7
api/.dockerignore
Normal file
7
api/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.env.example
|
||||
README.md
|
||||
.git
|
||||
.gitignore
|
||||
14
api/.env.example
Normal file
14
api/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Database connection
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/filamenteka
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=your-secret-key-here
|
||||
|
||||
# Admin password
|
||||
ADMIN_PASSWORD=your-admin-password
|
||||
|
||||
# Server port
|
||||
PORT=4000
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
18
api/Dockerfile
Normal file
18
api/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
75
api/migrate.js
Normal file
75
api/migrate.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL?.includes('amazonaws.com') ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
// Read schema file
|
||||
const schemaPath = path.join(__dirname, '..', 'database', 'schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||
|
||||
// Execute schema
|
||||
await pool.query(schema);
|
||||
|
||||
console.log('Database migration completed successfully');
|
||||
|
||||
// Import legacy data if available
|
||||
try {
|
||||
const dataPath = path.join(__dirname, '..', 'data.json');
|
||||
if (fs.existsSync(dataPath)) {
|
||||
const legacyData = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
|
||||
|
||||
// Import colors
|
||||
const colors = new Set();
|
||||
legacyData.forEach(item => {
|
||||
if (item.boja) colors.add(item.boja);
|
||||
});
|
||||
|
||||
for (const color of colors) {
|
||||
const hex = legacyData.find(item => item.boja === color)?.bojaHex || '#000000';
|
||||
await pool.query(
|
||||
'INSERT INTO colors (name, hex) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET hex = $2',
|
||||
[color, hex]
|
||||
);
|
||||
}
|
||||
|
||||
// Import filaments
|
||||
for (const item of legacyData) {
|
||||
await pool.query(
|
||||
`INSERT INTO filaments (brand, tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina, cena)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
item.brand,
|
||||
item.tip,
|
||||
item.finish,
|
||||
item.boja,
|
||||
item.bojaHex,
|
||||
item.refill,
|
||||
item.vakum,
|
||||
item.otvoreno,
|
||||
item.kolicina || 1,
|
||||
item.cena
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Legacy data imported successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error importing legacy data:', error);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
1504
api/package-lock.json
generated
Normal file
1504
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
api/package.json
Normal file
22
api/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "filamenteka-api",
|
||||
"version": "1.0.0",
|
||||
"description": "API backend for Filamenteka",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"migrate": "node migrate.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.3",
|
||||
"cors": "^2.8.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
175
api/server.js
Normal file
175
api/server.js
Normal file
@@ -0,0 +1,175 @@
|
||||
const express = require('express');
|
||||
const { Pool } = require('pg');
|
||||
const cors = require('cors');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 80;
|
||||
|
||||
// PostgreSQL connection
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL?.includes('amazonaws.com') ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: true, // Allow all origins in development
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// Health check route
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'Filamenteka API' });
|
||||
});
|
||||
|
||||
// JWT middleware
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key', (err, user) => {
|
||||
if (err) return res.sendStatus(403);
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
// Auth endpoints
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// For now, simple hardcoded admin check
|
||||
if (username === 'admin' && password === process.env.ADMIN_PASSWORD) {
|
||||
const token = jwt.sign({ username }, process.env.JWT_SECRET || 'your-secret-key', { expiresIn: '24h' });
|
||||
res.json({ token });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Colors endpoints
|
||||
app.get('/api/colors', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM colors ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching colors:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch colors' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/colors', authenticateToken, async (req, res) => {
|
||||
const { name, hex } = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'INSERT INTO colors (name, hex) VALUES ($1, $2) RETURNING *',
|
||||
[name, hex]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating color:', error);
|
||||
res.status(500).json({ error: 'Failed to create color' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/colors/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, hex } = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'UPDATE colors SET name = $1, hex = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING *',
|
||||
[name, hex, id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating color:', error);
|
||||
res.status(500).json({ error: 'Failed to update color' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/colors/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
await pool.query('DELETE FROM colors WHERE id = $1', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting color:', error);
|
||||
res.status(500).json({ error: 'Failed to delete color' });
|
||||
}
|
||||
});
|
||||
|
||||
// Filaments endpoints
|
||||
app.get('/api/filaments', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM filaments ORDER BY created_at DESC');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching filaments:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch filaments' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/filaments', authenticateToken, async (req, res) => {
|
||||
const { brand, tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina, cena } = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO filaments (brand, tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina, cena)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
||||
[brand, tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina || 1, cena]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating filament:', error);
|
||||
res.status(500).json({ error: 'Failed to create filament' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { brand, tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina, cena } = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE filaments
|
||||
SET brand = $1, tip = $2, finish = $3, boja = $4, boja_hex = $5,
|
||||
refill = $6, vakum = $7, otvoreno = $8, kolicina = $9, cena = $10,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $11 RETURNING *`,
|
||||
[brand, tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina || 1, cena, id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating filament:', error);
|
||||
res.status(500).json({ error: 'Failed to update filament' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/filaments/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
await pool.query('DELETE FROM filaments WHERE id = $1', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting filament:', error);
|
||||
res.status(500).json({ error: 'Failed to delete filament' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
9
api/terraform.tfstate
Normal file
9
api/terraform.tfstate
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.5.7",
|
||||
"serial": 1,
|
||||
"lineage": "3a82dae6-d28d-d8bd-893b-3217b2dfad11",
|
||||
"outputs": {},
|
||||
"resources": [],
|
||||
"check_results": null
|
||||
}
|
||||
18
api/vercel.json
Normal file
18
api/vercel.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "server.js",
|
||||
"use": "@vercel/node"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "server.js"
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
|
||||
}
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import axios from 'axios';
|
||||
import { Filament } from '../../../src/types/filament';
|
||||
|
||||
interface FilamentWithId extends Filament {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter();
|
||||
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingFilament, setEditingFilament] = useState<FilamentWithId | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
// Check authentication
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
router.push('/admin');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Fetch filaments
|
||||
const fetchFilaments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/filaments`);
|
||||
setFilaments(response.data);
|
||||
} catch (err) {
|
||||
setError('Greška pri učitavanju filamenata');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilaments();
|
||||
}, []);
|
||||
|
||||
const getAuthHeaders = () => ({
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('authToken')}`
|
||||
}
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Da li ste sigurni da želite obrisati ovaj filament?')) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`${process.env.NEXT_PUBLIC_API_URL}/filaments/${id}`, getAuthHeaders());
|
||||
await fetchFilaments();
|
||||
} catch (err) {
|
||||
alert('Greška pri brisanju filamenta');
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (filament: Partial<FilamentWithId>) => {
|
||||
try {
|
||||
if (filament.id) {
|
||||
// Update existing
|
||||
await axios.put(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/filaments/${filament.id}`,
|
||||
filament,
|
||||
getAuthHeaders()
|
||||
);
|
||||
} else {
|
||||
// Create new
|
||||
await axios.post(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/filaments`,
|
||||
filament,
|
||||
getAuthHeaders()
|
||||
);
|
||||
}
|
||||
|
||||
setEditingFilament(null);
|
||||
setShowAddForm(false);
|
||||
await fetchFilaments();
|
||||
} catch (err) {
|
||||
alert('Greška pri čuvanju filamenta');
|
||||
console.error('Save error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
router.push('/admin');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<header className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Dodaj novi filament
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Odjava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || editingFilament) && (
|
||||
<FilamentForm
|
||||
filament={editingFilament || {}}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setEditingFilament(null);
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filaments Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white dark:bg-gray-800 shadow rounded">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-gray-700">
|
||||
<th className="px-4 py-2 text-left">Brand</th>
|
||||
<th className="px-4 py-2 text-left">Tip</th>
|
||||
<th className="px-4 py-2 text-left">Finish</th>
|
||||
<th className="px-4 py-2 text-left">Boja</th>
|
||||
<th className="px-4 py-2 text-left">Refill</th>
|
||||
<th className="px-4 py-2 text-left">Vakum</th>
|
||||
<th className="px-4 py-2 text-left">Otvoreno</th>
|
||||
<th className="px-4 py-2 text-left">Količina</th>
|
||||
<th className="px-4 py-2 text-left">Cena</th>
|
||||
<th className="px-4 py-2 text-left">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filaments.map((filament) => (
|
||||
<tr key={filament.id} className="border-t dark:border-gray-700">
|
||||
<td className="px-4 py-2">{filament.brand}</td>
|
||||
<td className="px-4 py-2">{filament.tip}</td>
|
||||
<td className="px-4 py-2">{filament.finish}</td>
|
||||
<td className="px-4 py-2">{filament.boja}</td>
|
||||
<td className="px-4 py-2">{filament.refill}</td>
|
||||
<td className="px-4 py-2">{filament.vakum}</td>
|
||||
<td className="px-4 py-2">{filament.otvoreno}</td>
|
||||
<td className="px-4 py-2">{filament.kolicina}</td>
|
||||
<td className="px-4 py-2">{filament.cena}</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => setEditingFilament(filament)}
|
||||
className="text-blue-600 hover:text-blue-800 mr-2"
|
||||
>
|
||||
Izmeni
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(filament.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Obriši
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filament Form Component
|
||||
function FilamentForm({
|
||||
filament,
|
||||
onSave,
|
||||
onCancel
|
||||
}: {
|
||||
filament: Partial<FilamentWithId>,
|
||||
onSave: (filament: Partial<FilamentWithId>) => void,
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
brand: filament.brand || '',
|
||||
tip: filament.tip || '',
|
||||
finish: filament.finish || '',
|
||||
boja: filament.boja || '',
|
||||
refill: filament.refill || '',
|
||||
vakum: filament.vakum || '',
|
||||
otvoreno: filament.otvoreno || '',
|
||||
kolicina: filament.kolicina || '',
|
||||
cena: filament.cena || '',
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
...filament,
|
||||
...formData
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 p-6 bg-white dark:bg-gray-800 rounded shadow">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Brand</label>
|
||||
<input
|
||||
type="text"
|
||||
name="brand"
|
||||
value={formData.brand}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tip</label>
|
||||
<select
|
||||
name="tip"
|
||||
value={formData.tip}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">Izaberi tip</option>
|
||||
<option value="PLA">PLA</option>
|
||||
<option value="PETG">PETG</option>
|
||||
<option value="ABS">ABS</option>
|
||||
<option value="TPU">TPU</option>
|
||||
<option value="Silk PLA">Silk PLA</option>
|
||||
<option value="PLA Matte">PLA Matte</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Finish</label>
|
||||
<select
|
||||
name="finish"
|
||||
value={formData.finish}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">Izaberi finish</option>
|
||||
<option value="Basic">Basic</option>
|
||||
<option value="Matte">Matte</option>
|
||||
<option value="Silk">Silk</option>
|
||||
<option value="Metal">Metal</option>
|
||||
<option value="Glow">Glow</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Boja</label>
|
||||
<input
|
||||
type="text"
|
||||
name="boja"
|
||||
value={formData.boja}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Refill</label>
|
||||
<select
|
||||
name="refill"
|
||||
value={formData.refill}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">Ne</option>
|
||||
<option value="Da">Da</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Vakum</label>
|
||||
<input
|
||||
type="text"
|
||||
name="vakum"
|
||||
value={formData.vakum}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Otvoreno</label>
|
||||
<input
|
||||
type="text"
|
||||
name="otvoreno"
|
||||
value={formData.otvoreno}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Količina</label>
|
||||
<input
|
||||
type="text"
|
||||
name="kolicina"
|
||||
value={formData.kolicina}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Cena</label>
|
||||
<input
|
||||
type="text"
|
||||
name="cena"
|
||||
value={formData.cena}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Sačuvaj
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,37 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="sr">
|
||||
<body>{children}</body>
|
||||
<html lang="sr" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
document.documentElement.classList.add('no-transitions');
|
||||
// Apply dark mode immediately for admin pages
|
||||
if (window.location.pathname.startsWith('/upadaj')) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
// For non-admin pages, check localStorage
|
||||
try {
|
||||
const darkMode = localStorage.getItem('darkMode');
|
||||
if (darkMode === 'true') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
// Remove no-transitions class after a short delay
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
document.documentElement.classList.remove('no-transitions');
|
||||
}, 100);
|
||||
});
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
119
app/page.tsx
119
app/page.tsx
@@ -1,19 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FilamentTable } from '../src/components/FilamentTable';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
||||
import { Filament } from '../src/types/filament';
|
||||
import axios from 'axios';
|
||||
import { filamentService } from '../src/services/api';
|
||||
|
||||
export default function Home() {
|
||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [useV2, setUseV2] = useState(true); // Default to new UI
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
// Removed V1/V2 toggle - now only using V2
|
||||
|
||||
// Initialize dark mode from localStorage after mounting
|
||||
useEffect(() => {
|
||||
@@ -41,22 +40,11 @@ export default function Home() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!apiUrl) {
|
||||
throw new Error('API URL not configured');
|
||||
}
|
||||
const url = `${apiUrl}/filaments`;
|
||||
const headers = useV2 ? { 'X-Accept-Format': 'v2' } : {};
|
||||
const response = await axios.get(url, { headers });
|
||||
setFilaments(response.data);
|
||||
setLastUpdate(new Date());
|
||||
const filaments = await filamentService.getAll();
|
||||
setFilaments(filaments);
|
||||
} catch (err) {
|
||||
console.error('API Error:', err);
|
||||
if (axios.isAxiosError(err)) {
|
||||
setError(`API Error: ${err.response?.status || 'Network'} - ${err.message}`);
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Greška pri učitavanju filamenata');
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Greška pri učitavanju filamenata');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -64,51 +52,47 @@ export default function Home() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilaments();
|
||||
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchFilaments, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleLogoClick = () => {
|
||||
setResetKey(prev => prev + 1);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Filamenteka
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
{lastUpdate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Poslednje ažuriranje: {lastUpdate.toLocaleTimeString('sr-RS')}
|
||||
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<button
|
||||
onClick={handleLogoClick}
|
||||
className="group hover:scale-105 transition-transform duration-200"
|
||||
title="Klikni za reset"
|
||||
>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-orange-600 dark:from-blue-400 dark:to-orange-400 bg-clip-text text-transparent group-hover:from-blue-700 group-hover:to-orange-700 dark:group-hover:from-blue-300 dark:group-hover:to-orange-300 transition-all">
|
||||
Filamenteka
|
||||
</h1>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 text-sm">
|
||||
<div className="flex flex-col sm:flex-row gap-3 text-center">
|
||||
<span className="text-blue-700 dark:text-blue-300 font-medium animate-pulse whitespace-nowrap">
|
||||
Kupovina po gramu dostupna
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchFilaments}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Ažuriranje...' : 'Osveži'}
|
||||
</button>
|
||||
<span className="hidden sm:inline text-gray-400 dark:text-gray-600">•</span>
|
||||
<span className="text-orange-700 dark:text-orange-300 font-medium animate-pulse whitespace-nowrap">
|
||||
Popust za 5+ komada
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mounted && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setUseV2(!useV2)}
|
||||
className="px-4 py-2 bg-blue-200 dark:bg-blue-700 text-blue-800 dark:text-blue-200 rounded hover:bg-blue-300 dark:hover:bg-blue-600 transition-colors"
|
||||
title={useV2 ? 'Stari prikaz' : 'Novi prikaz'}
|
||||
>
|
||||
{useV2 ? 'V2' : 'V1'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? '☀️' : '🌙'}
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="p-2 bg-white/50 dark:bg-gray-700/50 backdrop-blur text-gray-800 dark:text-gray-200 rounded-full hover:bg-white/80 dark:hover:bg-gray-600/80 transition-all duration-200 hover:scale-110 shadow-md ml-2"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? 'Svetla' : 'Tamna'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,19 +100,12 @@ export default function Home() {
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{useV2 ? (
|
||||
<FilamentTableV2
|
||||
filaments={filaments}
|
||||
loading={loading}
|
||||
error={error || undefined}
|
||||
/>
|
||||
) : (
|
||||
<FilamentTable
|
||||
filaments={filaments}
|
||||
loading={loading}
|
||||
error={error || undefined}
|
||||
/>
|
||||
)}
|
||||
<FilamentTableV2
|
||||
key={resetKey}
|
||||
filaments={filaments}
|
||||
loading={loading}
|
||||
error={error || undefined}
|
||||
/>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
390
app/upadaj/colors/page.tsx
Normal file
390
app/upadaj/colors/page.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { colorService } from '../../../src/services/api';
|
||||
import '../../../src/styles/select.css';
|
||||
|
||||
interface Color {
|
||||
id: string;
|
||||
name: string;
|
||||
hex: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export default function ColorsManagement() {
|
||||
const router = useRouter();
|
||||
const [colors, setColors] = useState<Color[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingColor, setEditingColor] = useState<Color | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Initialize dark mode - default to true for admin
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved !== null) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
} else {
|
||||
// Default to dark mode for admin
|
||||
setDarkMode(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [darkMode, mounted]);
|
||||
|
||||
// Check authentication
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
router.push('/upadaj');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Fetch colors
|
||||
const fetchColors = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const colors = await colorService.getAll();
|
||||
setColors(colors.sort((a: Color, b: Color) => a.name.localeCompare(b.name)));
|
||||
} catch (err) {
|
||||
setError('Greška pri učitavanju boja');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchColors();
|
||||
}, []);
|
||||
|
||||
|
||||
const handleSave = async (color: Partial<Color>) => {
|
||||
try {
|
||||
if (color.id) {
|
||||
await colorService.update(color.id, { name: color.name!, hex: color.hex! });
|
||||
} else {
|
||||
await colorService.create({ name: color.name!, hex: color.hex! });
|
||||
}
|
||||
|
||||
setEditingColor(null);
|
||||
setShowAddForm(false);
|
||||
fetchColors();
|
||||
} catch (err) {
|
||||
setError('Greška pri čuvanju boje');
|
||||
console.error('Save error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Da li ste sigurni da želite obrisati ovu boju?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await colorService.delete(id);
|
||||
fetchColors();
|
||||
} catch (err) {
|
||||
setError('Greška pri brisanju boje');
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
router.push('/upadaj');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Učitavanje...</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
||||
<nav className="space-y-2">
|
||||
<a
|
||||
href="/upadaj/dashboard"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Filamenti
|
||||
</a>
|
||||
<a
|
||||
href="/upadaj/colors"
|
||||
className="block px-4 py-2 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded"
|
||||
>
|
||||
Boje
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Upravljanje bojama</h1>
|
||||
<div className="flex gap-4">
|
||||
{!showAddForm && !editingColor && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
Dodaj novu boju
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Nazad na sajt
|
||||
</button>
|
||||
{mounted && (
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? 'Svetla' : 'Tamna'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Odjava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Form (stays at top) */}
|
||||
{showAddForm && (
|
||||
<ColorForm
|
||||
color={{}}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pretraži boje..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors Table */}
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Boja</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Naziv</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Hex kod</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{colors
|
||||
.filter(color =>
|
||||
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.map((color) => (
|
||||
<tr key={color.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div
|
||||
className="w-10 h-10 rounded border-2 border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{color.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{color.hex}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => setEditingColor(color)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 mr-3"
|
||||
>
|
||||
Izmeni
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(color.id)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
Obriši
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingColor && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Izmeni boju</h3>
|
||||
<ColorForm
|
||||
color={editingColor}
|
||||
onSave={(color) => {
|
||||
handleSave(color);
|
||||
setEditingColor(null);
|
||||
}}
|
||||
onCancel={() => setEditingColor(null)}
|
||||
isModal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Color Form Component
|
||||
function ColorForm({
|
||||
color,
|
||||
onSave,
|
||||
onCancel,
|
||||
isModal = false
|
||||
}: {
|
||||
color: Partial<Color>,
|
||||
onSave: (color: Partial<Color>) => void,
|
||||
onCancel: () => void,
|
||||
isModal?: boolean
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: color.name || '',
|
||||
hex: color.hex || '#000000',
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
...color,
|
||||
...formData
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isModal ? "" : "mb-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow transition-colors"}>
|
||||
{!isModal && (
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{color.id ? 'Izmeni boju' : 'Dodaj novu boju'}
|
||||
</h2>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Naziv boje</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="npr. Crvena"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Hex kod boje</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
name="hex"
|
||||
value={formData.hex}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-12 p-1 border-2 border-gray-300 dark:border-gray-600 rounded-md cursor-pointer"
|
||||
style={{ backgroundColor: formData.hex }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="hex"
|
||||
value={formData.hex}
|
||||
onChange={handleChange}
|
||||
required
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
placeholder="#000000"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Sačuvaj
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
597
app/upadaj/dashboard/page.tsx
Normal file
597
app/upadaj/dashboard/page.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { filamentService, colorService } from '../../../src/services/api';
|
||||
import { Filament } from '../../../src/types/filament';
|
||||
import '../../../src/styles/select.css';
|
||||
|
||||
interface FilamentWithId extends Filament {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
bojaHex?: string;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter();
|
||||
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingFilament, setEditingFilament] = useState<FilamentWithId | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
// Initialize dark mode - default to true for admin
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved !== null) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
} else {
|
||||
// Default to dark mode for admin
|
||||
setDarkMode(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [darkMode, mounted]);
|
||||
|
||||
// Check authentication
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
router.push('/upadaj');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Fetch filaments
|
||||
const fetchFilaments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const filaments = await filamentService.getAll();
|
||||
setFilaments(filaments);
|
||||
} catch (err) {
|
||||
setError('Greška pri učitavanju filamenata');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilaments();
|
||||
}, []);
|
||||
|
||||
|
||||
const handleSave = async (filament: Partial<FilamentWithId>) => {
|
||||
try {
|
||||
if (filament.id) {
|
||||
await filamentService.update(filament.id, filament);
|
||||
} else {
|
||||
await filamentService.create(filament);
|
||||
}
|
||||
|
||||
setEditingFilament(null);
|
||||
setShowAddForm(false);
|
||||
fetchFilaments();
|
||||
} catch (err) {
|
||||
setError('Greška pri čuvanju filamenata');
|
||||
console.error('Save error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Da li ste sigurni da želite obrisati ovaj filament?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await filamentService.delete(id);
|
||||
fetchFilaments();
|
||||
} catch (err) {
|
||||
setError('Greška pri brisanju filamenata');
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
router.push('/upadaj');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Učitavanje...</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<div className="flex relative">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="lg:hidden fixed top-4 left-4 z-50 p-2 bg-white dark:bg-gray-800 rounded-md shadow-lg"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sidebarOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`${sidebarOpen ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0 transition-transform duration-300 fixed lg:static w-64 bg-white dark:bg-gray-800 shadow-lg h-screen z-40`}>
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
||||
<nav className="space-y-2">
|
||||
<a
|
||||
href="/upadaj/dashboard"
|
||||
className="block px-4 py-2 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded"
|
||||
>
|
||||
Filamenti
|
||||
</a>
|
||||
<a
|
||||
href="/upadaj/colors"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Boje
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black bg-opacity-50 z-30"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 lg:ml-0">
|
||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 lg:py-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white ml-12 lg:ml-0">Admin Dashboard</h1>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-4 w-full sm:w-auto">
|
||||
{!showAddForm && !editingFilament && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm sm:text-base"
|
||||
>
|
||||
Dodaj novi
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm sm:text-base"
|
||||
>
|
||||
Nazad na sajt
|
||||
</button>
|
||||
{mounted && (
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? 'Svetla' : 'Tamna'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Odjava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || editingFilament) && (
|
||||
<div className="mb-8">
|
||||
<FilamentForm
|
||||
key={editingFilament?.id || 'new'}
|
||||
filament={editingFilament || {}}
|
||||
filaments={filaments}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setEditingFilament(null);
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filaments Table */}
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Brand</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Tip</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Finish</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Boja</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Refill</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Vakum</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Otvoreno</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Količina</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filaments.map((filament) => (
|
||||
<tr key={filament.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{filament.brand}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{filament.tip}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{filament.finish}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{filament.boja}</span>
|
||||
{filament.bojaHex && (
|
||||
<div
|
||||
className="w-4 h-4 rounded border border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: filament.bojaHex }}
|
||||
title={filament.bojaHex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{filament.refill}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{filament.vakum}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{filament.otvoreno}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{filament.kolicina}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{filament.cena}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('Editing filament:', filament);
|
||||
setEditingFilament(filament);
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 mr-3"
|
||||
>
|
||||
Izmeni
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(filament.id)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
Obriši
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filament Form Component
|
||||
function FilamentForm({
|
||||
filament,
|
||||
filaments,
|
||||
onSave,
|
||||
onCancel
|
||||
}: {
|
||||
filament: Partial<FilamentWithId>,
|
||||
filaments: FilamentWithId[],
|
||||
onSave: (filament: Partial<FilamentWithId>) => void,
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
brand: filament.brand || '',
|
||||
tip: filament.tip || '',
|
||||
finish: filament.finish || '',
|
||||
boja: filament.boja || '',
|
||||
bojaHex: filament.bojaHex || '',
|
||||
refill: filament.refill || '',
|
||||
vakum: filament.vakum || '',
|
||||
otvoreno: filament.otvoreno || '',
|
||||
kolicina: filament.kolicina || '',
|
||||
cena: filament.cena || '',
|
||||
});
|
||||
|
||||
const [availableColors, setAvailableColors] = useState<Array<{id: string, name: string, hex: string}>>([]);
|
||||
|
||||
// Load colors from API
|
||||
useEffect(() => {
|
||||
const loadColors = async () => {
|
||||
try {
|
||||
const colors = await colorService.getAll();
|
||||
setAvailableColors(colors.sort((a: any, b: any) => a.name.localeCompare(b.name)));
|
||||
} catch (error) {
|
||||
console.error('Error loading colors:', error);
|
||||
// Fallback to colors from existing filaments
|
||||
const existingColors = [...new Set(filaments.map(f => f.boja).filter(Boolean))];
|
||||
const colorObjects = existingColors.map((color, idx) => ({
|
||||
id: `existing-${idx}`,
|
||||
name: color,
|
||||
hex: filaments.find(f => f.boja === color)?.bojaHex || '#000000'
|
||||
}));
|
||||
setAvailableColors(colorObjects.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
}
|
||||
};
|
||||
|
||||
loadColors();
|
||||
|
||||
// Reload colors when window gets focus (in case user added colors in another tab)
|
||||
const handleFocus = () => loadColors();
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
return () => window.removeEventListener('focus', handleFocus);
|
||||
}, [filaments]);
|
||||
|
||||
// Update form when filament prop changes
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
brand: filament.brand || '',
|
||||
tip: filament.tip || '',
|
||||
finish: filament.finish || '',
|
||||
boja: filament.boja || '',
|
||||
bojaHex: filament.bojaHex || '',
|
||||
refill: filament.refill || '',
|
||||
vakum: filament.vakum || '',
|
||||
otvoreno: filament.otvoreno || '',
|
||||
kolicina: filament.kolicina || '',
|
||||
cena: filament.cena || '',
|
||||
});
|
||||
}, [filament]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
if (name === 'vakum') {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: checked ? 'vakuum' : ''
|
||||
});
|
||||
} else if (name === 'otvoreno') {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: checked ? 'otvorena' : ''
|
||||
});
|
||||
} else if (name === 'refill') {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: checked ? 'Da' : ''
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
...filament,
|
||||
...formData
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow transition-colors">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Brand</label>
|
||||
<select
|
||||
name="brand"
|
||||
value={formData.brand}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Izaberi brand</option>
|
||||
<option value="Bambu Lab">Bambu Lab</option>
|
||||
<option value="PolyMaker">PolyMaker</option>
|
||||
<option value="Prusament">Prusament</option>
|
||||
<option value="SUNLU">SUNLU</option>
|
||||
<option value="eSUN">eSUN</option>
|
||||
<option value="ELEGOO">ELEGOO</option>
|
||||
<option value="GEEETECH">GEEETECH</option>
|
||||
<option value="Creality">Creality</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Tip</label>
|
||||
<select
|
||||
name="tip"
|
||||
value={formData.tip}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Izaberi tip</option>
|
||||
<option value="PLA">PLA</option>
|
||||
<option value="PETG">PETG</option>
|
||||
<option value="ABS">ABS</option>
|
||||
<option value="TPU">TPU</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finish</label>
|
||||
<select
|
||||
name="finish"
|
||||
value={formData.finish}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Izaberi finish</option>
|
||||
<option value="Basic">Basic</option>
|
||||
<option value="Matte">Matte</option>
|
||||
<option value="Silk">Silk</option>
|
||||
<option value="Glow">Glow</option>
|
||||
<option value="Wood">Wood</option>
|
||||
<option value="CF">CF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Boja</label>
|
||||
<select
|
||||
name="boja"
|
||||
value={formData.boja}
|
||||
onChange={(e) => {
|
||||
const selectedColor = availableColors.find(c => c.name === e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
boja: e.target.value,
|
||||
bojaHex: selectedColor?.hex || formData.bojaHex
|
||||
});
|
||||
}}
|
||||
required
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Izaberite boju</option>
|
||||
{availableColors.map(color => (
|
||||
<option key={color.id} value={color.name}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Druga boja...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
{formData.boja && formData.boja !== 'custom' ? `Hex kod za ${formData.boja}` : 'Hex kod boje'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="bojaHex"
|
||||
value={formData.bojaHex || '#000000'}
|
||||
onChange={handleChange}
|
||||
disabled={formData.boja && formData.boja !== 'custom' && availableColors.some(c => c.name === formData.boja)}
|
||||
className="w-full h-10 px-1 py-1 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{formData.bojaHex && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{formData.bojaHex}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Količina</label>
|
||||
<input
|
||||
type="number"
|
||||
name="kolicina"
|
||||
value={formData.kolicina}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cena and Checkboxes on same line */}
|
||||
<div className="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Cena</label>
|
||||
<input
|
||||
type="text"
|
||||
name="cena"
|
||||
value={formData.cena}
|
||||
onChange={handleChange}
|
||||
placeholder="npr. 2500"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes grouped together horizontally */}
|
||||
<div className="flex items-end gap-6">
|
||||
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="refill"
|
||||
checked={formData.refill.toLowerCase() === 'da'}
|
||||
onChange={handleChange}
|
||||
className="w-5 h-5 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span>Refill</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="vakum"
|
||||
checked={formData.vakum.toLowerCase().includes('vakuum')}
|
||||
onChange={handleChange}
|
||||
className="w-5 h-5 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span>Vakuum</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="otvoreno"
|
||||
checked={formData.otvoreno.toLowerCase().includes('otvorena')}
|
||||
onChange={handleChange}
|
||||
className="w-5 h-5 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span>Otvoreno</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Sačuvaj
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import axios from 'axios';
|
||||
import { authService } from '../../src/services/api';
|
||||
|
||||
export default function AdminLogin() {
|
||||
const router = useRouter();
|
||||
@@ -11,23 +11,25 @@ export default function AdminLogin() {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Set dark mode by default
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
const response = await authService.login(username, password);
|
||||
|
||||
// Store token in localStorage
|
||||
localStorage.setItem('authToken', response.data.token);
|
||||
localStorage.setItem('tokenExpiry', String(Date.now() + response.data.expiresIn * 1000));
|
||||
localStorage.setItem('authToken', response.token);
|
||||
localStorage.setItem('tokenExpiry', String(Date.now() + 24 * 60 * 60 * 1000)); // 24 hours
|
||||
|
||||
// Redirect to admin dashboard
|
||||
router.push('/admin/dashboard');
|
||||
router.push('/upadaj/dashboard');
|
||||
} catch (err) {
|
||||
setError('Neispravno korisničko ime ili lozinka');
|
||||
console.error('Login error:', err);
|
||||
68
database/schema.sql
Normal file
68
database/schema.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- Filamenteka PostgreSQL Schema
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Colors table
|
||||
CREATE TABLE colors (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
hex VARCHAR(7) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Filaments table
|
||||
CREATE TABLE filaments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand VARCHAR(100) NOT NULL,
|
||||
tip VARCHAR(50) NOT NULL,
|
||||
finish VARCHAR(50) NOT NULL,
|
||||
boja VARCHAR(100) NOT NULL,
|
||||
boja_hex VARCHAR(7),
|
||||
refill VARCHAR(10),
|
||||
vakum VARCHAR(20),
|
||||
otvoreno VARCHAR(20),
|
||||
kolicina INTEGER DEFAULT 1,
|
||||
cena VARCHAR(50),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_color FOREIGN KEY (boja) REFERENCES colors(name) ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX idx_filaments_brand ON filaments(brand);
|
||||
CREATE INDEX idx_filaments_tip ON filaments(tip);
|
||||
CREATE INDEX idx_filaments_boja ON filaments(boja);
|
||||
CREATE INDEX idx_filaments_created_at ON filaments(created_at);
|
||||
|
||||
-- Create updated_at trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply trigger to filaments table
|
||||
CREATE TRIGGER update_filaments_updated_at BEFORE UPDATE
|
||||
ON filaments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Apply trigger to colors table
|
||||
CREATE TRIGGER update_colors_updated_at BEFORE UPDATE
|
||||
ON colors FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Insert default colors from legacy data
|
||||
INSERT INTO colors (name, hex) VALUES
|
||||
('Crna', '#000000'),
|
||||
('Bela', '#FFFFFF'),
|
||||
('Plava', '#0000FF'),
|
||||
('Crvena', '#FF0000'),
|
||||
('Zelena', '#00FF00'),
|
||||
('Žuta', '#FFFF00'),
|
||||
('Narandžasta', '#FFA500'),
|
||||
('Ljubičasta', '#800080'),
|
||||
('Siva', '#808080'),
|
||||
('Braon', '#A52A2A')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
@@ -1,111 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME;
|
||||
const ADMIN_PASSWORD_HASH = process.env.ADMIN_PASSWORD_HASH;
|
||||
|
||||
// CORS headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token',
|
||||
'Access-Control-Allow-Methods': 'POST,OPTIONS',
|
||||
'Access-Control-Allow-Credentials': 'true'
|
||||
};
|
||||
|
||||
// Helper function to create response
|
||||
const createResponse = (statusCode, body) => ({
|
||||
statusCode,
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Login handler
|
||||
const login = async (event) => {
|
||||
try {
|
||||
const { username, password } = JSON.parse(event.body);
|
||||
|
||||
// Validate credentials
|
||||
if (username !== ADMIN_USERNAME) {
|
||||
return createResponse(401, { error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Compare password with hash
|
||||
const isValid = await bcrypt.compare(password, ADMIN_PASSWORD_HASH);
|
||||
if (!isValid) {
|
||||
return createResponse(401, { error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{ username, role: 'admin' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
return createResponse(200, {
|
||||
token,
|
||||
expiresIn: 86400 // 24 hours in seconds
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return createResponse(500, { error: 'Authentication failed' });
|
||||
}
|
||||
};
|
||||
|
||||
// Verify token (for Lambda authorizer)
|
||||
const verifyToken = async (event) => {
|
||||
try {
|
||||
const token = event.authorizationToken?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
return {
|
||||
principalId: decoded.username,
|
||||
policyDocument: {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Action: 'execute-api:Invoke',
|
||||
Effect: 'Allow',
|
||||
Resource: event.methodArn
|
||||
}
|
||||
]
|
||||
},
|
||||
context: {
|
||||
username: decoded.username,
|
||||
role: decoded.role
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
};
|
||||
|
||||
// Main handler
|
||||
exports.handler = async (event) => {
|
||||
const { httpMethod, resource } = event;
|
||||
|
||||
// Handle CORS preflight
|
||||
if (httpMethod === 'OPTIONS') {
|
||||
return createResponse(200, {});
|
||||
}
|
||||
|
||||
// Handle login
|
||||
if (resource === '/auth/login' && httpMethod === 'POST') {
|
||||
return login(event);
|
||||
}
|
||||
|
||||
// Handle token verification (for Lambda authorizer)
|
||||
if (event.type === 'TOKEN') {
|
||||
return verifyToken(event);
|
||||
}
|
||||
|
||||
return createResponse(404, { error: 'Not found' });
|
||||
};
|
||||
161
lambda/auth/package-lock.json
generated
161
lambda/auth/package-lock.json
generated
@@ -1,161 +0,0 @@
|
||||
{
|
||||
"name": "auth-api",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auth-api",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "auth-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Lambda function for authentication",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
const AWS = require('aws-sdk');
|
||||
const dynamodb = new AWS.DynamoDB.DocumentClient();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const TABLE_NAME = process.env.TABLE_NAME;
|
||||
|
||||
// CORS headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token',
|
||||
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
|
||||
'Access-Control-Allow-Credentials': 'true'
|
||||
};
|
||||
|
||||
// Helper function to create response
|
||||
const createResponse = (statusCode, body) => ({
|
||||
statusCode,
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Helper to transform new structure to legacy format for backwards compatibility
|
||||
const transformToLegacy = (item) => {
|
||||
if (item._legacy) {
|
||||
// New structure - extract legacy fields
|
||||
return {
|
||||
id: item.id,
|
||||
brand: item.brand,
|
||||
tip: item._legacy.tip || item.type || item.material?.base,
|
||||
finish: item._legacy.finish || item.material?.modifier || 'Basic',
|
||||
boja: item._legacy.boja || item.color?.name,
|
||||
refill: item._legacy.refill || (item.condition?.isRefill ? 'Da' : ''),
|
||||
vakum: item._legacy.vakum || (item.condition?.storageCondition === 'vacuum' ? 'vakuum' : ''),
|
||||
otvoreno: item._legacy.otvoreno || (item.condition?.storageCondition === 'opened' ? 'otvorena' : ''),
|
||||
kolicina: item._legacy.kolicina || String(item.inventory?.total || ''),
|
||||
cena: item._legacy.cena || String(item.pricing?.purchasePrice || ''),
|
||||
status: item._legacy.status,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
};
|
||||
}
|
||||
// Already in legacy format
|
||||
return item;
|
||||
};
|
||||
|
||||
// GET all filaments or filter by query params
|
||||
const getFilaments = async (event) => {
|
||||
try {
|
||||
const queryParams = event.queryStringParameters || {};
|
||||
|
||||
let params = {
|
||||
TableName: TABLE_NAME
|
||||
};
|
||||
|
||||
// Support both old and new query parameters
|
||||
const brand = queryParams.brand;
|
||||
const materialBase = queryParams.material || queryParams.tip;
|
||||
const storageCondition = queryParams.storageCondition || queryParams.status;
|
||||
|
||||
// Filter by brand
|
||||
if (brand) {
|
||||
params = {
|
||||
...params,
|
||||
IndexName: 'brand-index',
|
||||
KeyConditionExpression: 'brand = :brand',
|
||||
ExpressionAttributeValues: {
|
||||
':brand': brand
|
||||
}
|
||||
};
|
||||
}
|
||||
// Filter by material (supports old 'tip' param)
|
||||
else if (materialBase) {
|
||||
// Try new index first, fall back to old
|
||||
try {
|
||||
params = {
|
||||
...params,
|
||||
IndexName: 'material-base-index',
|
||||
KeyConditionExpression: '#mb = :mb',
|
||||
ExpressionAttributeNames: {
|
||||
'#mb': 'material.base'
|
||||
},
|
||||
ExpressionAttributeValues: {
|
||||
':mb': materialBase
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
// Fall back to old index
|
||||
params = {
|
||||
...params,
|
||||
IndexName: 'tip-index',
|
||||
KeyConditionExpression: 'tip = :tip',
|
||||
ExpressionAttributeValues: {
|
||||
':tip': materialBase
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// Filter by storage condition
|
||||
else if (storageCondition) {
|
||||
params = {
|
||||
...params,
|
||||
FilterExpression: '#sc = :sc',
|
||||
ExpressionAttributeNames: {
|
||||
'#sc': 'condition.storageCondition'
|
||||
},
|
||||
ExpressionAttributeValues: {
|
||||
':sc': storageCondition
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Execute query or scan
|
||||
let result;
|
||||
if (params.IndexName || params.KeyConditionExpression) {
|
||||
result = await dynamodb.query(params).promise();
|
||||
} else {
|
||||
result = await dynamodb.scan(params).promise();
|
||||
}
|
||||
|
||||
// Check if client supports new format
|
||||
const acceptsNewFormat = event.headers?.['X-Accept-Format'] === 'v2';
|
||||
|
||||
if (acceptsNewFormat) {
|
||||
// Return new format
|
||||
return createResponse(200, result.Items);
|
||||
} else {
|
||||
// Transform to legacy format for backwards compatibility
|
||||
const legacyItems = result.Items.map(transformToLegacy);
|
||||
return createResponse(200, legacyItems);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting filaments:', error);
|
||||
return createResponse(500, { error: 'Failed to fetch filaments' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET single filament by ID
|
||||
const getFilament = async (event) => {
|
||||
try {
|
||||
const { id } = event.pathParameters;
|
||||
|
||||
const params = {
|
||||
TableName: TABLE_NAME,
|
||||
Key: { id }
|
||||
};
|
||||
|
||||
const result = await dynamodb.get(params).promise();
|
||||
|
||||
if (!result.Item) {
|
||||
return createResponse(404, { error: 'Filament not found' });
|
||||
}
|
||||
|
||||
return createResponse(200, result.Item);
|
||||
} catch (error) {
|
||||
console.error('Error getting filament:', error);
|
||||
return createResponse(500, { error: 'Failed to fetch filament' });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to generate SKU
|
||||
const generateSKU = (brand, material, color) => {
|
||||
const brandCode = brand.substring(0, 3).toUpperCase();
|
||||
const materialCode = material.substring(0, 3).toUpperCase();
|
||||
const colorCode = color.substring(0, 3).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 5).toUpperCase();
|
||||
return `${brandCode}-${materialCode}-${colorCode}-${random}`;
|
||||
};
|
||||
|
||||
// POST - Create new filament
|
||||
const createFilament = async (event) => {
|
||||
try {
|
||||
const body = JSON.parse(event.body);
|
||||
const timestamp = new Date().toISOString();
|
||||
const acceptsNewFormat = event.headers?.['X-Accept-Format'] === 'v2';
|
||||
|
||||
let item;
|
||||
|
||||
if (acceptsNewFormat || body.material) {
|
||||
// New format
|
||||
item = {
|
||||
id: uuidv4(),
|
||||
sku: body.sku || generateSKU(body.brand, body.material.base, body.color.name),
|
||||
...body,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
} else {
|
||||
// Legacy format - convert to new structure
|
||||
const material = {
|
||||
base: body.tip || 'PLA',
|
||||
modifier: body.finish !== 'Basic' ? body.finish : null
|
||||
};
|
||||
|
||||
const storageCondition = body.vakum?.toLowerCase().includes('vakuum') ? 'vacuum' :
|
||||
body.otvoreno?.toLowerCase().includes('otvorena') ? 'opened' : 'sealed';
|
||||
|
||||
item = {
|
||||
id: uuidv4(),
|
||||
sku: generateSKU(body.brand, material.base, body.boja),
|
||||
brand: body.brand,
|
||||
type: body.tip || 'PLA',
|
||||
material: material,
|
||||
color: {
|
||||
name: body.boja,
|
||||
hex: null
|
||||
},
|
||||
weight: {
|
||||
value: 1000,
|
||||
unit: 'g'
|
||||
},
|
||||
diameter: 1.75,
|
||||
inventory: {
|
||||
total: parseInt(body.kolicina) || 1,
|
||||
available: storageCondition === 'opened' ? 0 : 1,
|
||||
inUse: 0,
|
||||
locations: {
|
||||
vacuum: storageCondition === 'vacuum' ? 1 : 0,
|
||||
opened: storageCondition === 'opened' ? 1 : 0,
|
||||
printer: 0
|
||||
}
|
||||
},
|
||||
pricing: {
|
||||
purchasePrice: body.cena ? parseFloat(body.cena) : null,
|
||||
currency: 'RSD'
|
||||
},
|
||||
condition: {
|
||||
isRefill: body.refill === 'Da',
|
||||
storageCondition: storageCondition
|
||||
},
|
||||
tags: [],
|
||||
_legacy: {
|
||||
tip: body.tip,
|
||||
finish: body.finish,
|
||||
boja: body.boja,
|
||||
refill: body.refill,
|
||||
vakum: body.vakum,
|
||||
otvoreno: body.otvoreno,
|
||||
kolicina: body.kolicina,
|
||||
cena: body.cena,
|
||||
status: body.status || 'new'
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
}
|
||||
|
||||
const params = {
|
||||
TableName: TABLE_NAME,
|
||||
Item: item
|
||||
};
|
||||
|
||||
await dynamodb.put(params).promise();
|
||||
|
||||
// Return in appropriate format
|
||||
const response = acceptsNewFormat ? item : transformToLegacy(item);
|
||||
return createResponse(201, response);
|
||||
} catch (error) {
|
||||
console.error('Error creating filament:', error);
|
||||
return createResponse(500, { error: 'Failed to create filament' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT - Update filament
|
||||
const updateFilament = async (event) => {
|
||||
try {
|
||||
const { id } = event.pathParameters;
|
||||
const body = JSON.parse(event.body);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Determine status based on vakum and otvoreno fields
|
||||
let status = 'new';
|
||||
if (body.otvoreno && body.otvoreno.toLowerCase().includes('otvorena')) {
|
||||
status = 'opened';
|
||||
} else if (body.refill && body.refill.toLowerCase() === 'da') {
|
||||
status = 'refill';
|
||||
}
|
||||
|
||||
const params = {
|
||||
TableName: TABLE_NAME,
|
||||
Key: { id },
|
||||
UpdateExpression: `SET
|
||||
brand = :brand,
|
||||
tip = :tip,
|
||||
finish = :finish,
|
||||
boja = :boja,
|
||||
refill = :refill,
|
||||
vakum = :vakum,
|
||||
otvoreno = :otvoreno,
|
||||
kolicina = :kolicina,
|
||||
cena = :cena,
|
||||
#status = :status,
|
||||
updatedAt = :updatedAt`,
|
||||
ExpressionAttributeNames: {
|
||||
'#status': 'status'
|
||||
},
|
||||
ExpressionAttributeValues: {
|
||||
':brand': body.brand,
|
||||
':tip': body.tip,
|
||||
':finish': body.finish,
|
||||
':boja': body.boja,
|
||||
':refill': body.refill,
|
||||
':vakum': body.vakum,
|
||||
':otvoreno': body.otvoreno,
|
||||
':kolicina': body.kolicina,
|
||||
':cena': body.cena,
|
||||
':status': status,
|
||||
':updatedAt': timestamp
|
||||
},
|
||||
ReturnValues: 'ALL_NEW'
|
||||
};
|
||||
|
||||
const result = await dynamodb.update(params).promise();
|
||||
return createResponse(200, result.Attributes);
|
||||
} catch (error) {
|
||||
console.error('Error updating filament:', error);
|
||||
return createResponse(500, { error: 'Failed to update filament' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE filament
|
||||
const deleteFilament = async (event) => {
|
||||
try {
|
||||
const { id } = event.pathParameters;
|
||||
|
||||
const params = {
|
||||
TableName: TABLE_NAME,
|
||||
Key: { id }
|
||||
};
|
||||
|
||||
await dynamodb.delete(params).promise();
|
||||
return createResponse(200, { message: 'Filament deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting filament:', error);
|
||||
return createResponse(500, { error: 'Failed to delete filament' });
|
||||
}
|
||||
};
|
||||
|
||||
// Main handler
|
||||
exports.handler = async (event) => {
|
||||
const { httpMethod, resource } = event;
|
||||
|
||||
// Handle CORS preflight
|
||||
if (httpMethod === 'OPTIONS') {
|
||||
return createResponse(200, {});
|
||||
}
|
||||
|
||||
// Route requests
|
||||
if (resource === '/filaments' && httpMethod === 'GET') {
|
||||
return getFilaments(event);
|
||||
} else if (resource === '/filaments' && httpMethod === 'POST') {
|
||||
return createFilament(event);
|
||||
} else if (resource === '/filaments/{id}' && httpMethod === 'GET') {
|
||||
return getFilament(event);
|
||||
} else if (resource === '/filaments/{id}' && httpMethod === 'PUT') {
|
||||
return updateFilament(event);
|
||||
} else if (resource === '/filaments/{id}' && httpMethod === 'DELETE') {
|
||||
return deleteFilament(event);
|
||||
}
|
||||
|
||||
return createResponse(404, { error: 'Not found' });
|
||||
};
|
||||
593
lambda/filaments/package-lock.json
generated
593
lambda/filaments/package-lock.json
generated
@@ -1,593 +0,0 @@
|
||||
{
|
||||
"name": "filaments-api",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "filaments-api",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"possible-typed-array-names": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-sdk": {
|
||||
"version": "2.1692.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1692.0.tgz",
|
||||
"integrity": "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"buffer": "4.9.2",
|
||||
"events": "1.1.1",
|
||||
"ieee754": "1.1.13",
|
||||
"jmespath": "0.16.0",
|
||||
"querystring": "0.2.0",
|
||||
"sax": "1.2.1",
|
||||
"url": "0.10.3",
|
||||
"util": "^0.12.4",
|
||||
"uuid": "8.0.0",
|
||||
"xml2js": "0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-sdk/node_modules/uuid": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz",
|
||||
"integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
|
||||
"integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.0.2",
|
||||
"ieee754": "^1.1.4",
|
||||
"isarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.0",
|
||||
"es-define-property": "^1.0.0",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"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",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"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.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
|
||||
"integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-callable": "^1.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"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.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
|
||||
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
|
||||
"integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.3",
|
||||
"get-proto": "^1.0.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"safe-regex-test": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-typed-array": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"which-typed-array": "^1.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jmespath": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
|
||||
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
|
||||
"integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
"integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
|
||||
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"is-regex": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
|
||||
"integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/url": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz",
|
||||
"integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "1.3.2",
|
||||
"querystring": "0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"is-arguments": "^1.0.4",
|
||||
"is-generator-function": "^1.0.7",
|
||||
"is-typed-array": "^1.1.3",
|
||||
"which-typed-array": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||
"integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"available-typed-arrays": "^1.0.7",
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"for-each": "^0.3.5",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "filaments-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Lambda function for filaments CRUD operations",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs"
|
||||
}
|
||||
52
src/components/AnimatedLogo.tsx
Normal file
52
src/components/AnimatedLogo.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const logoVariants = [
|
||||
{ emoji: '🎨', rotation: 360, scale: 1.2 },
|
||||
{ emoji: '🌈', rotation: -360, scale: 1.1 },
|
||||
{ emoji: '🎯', rotation: 720, scale: 1.3 },
|
||||
{ emoji: '✨', rotation: -720, scale: 1.0 },
|
||||
{ emoji: '🔄', rotation: 360, scale: 1.2 },
|
||||
{ emoji: '🎪', rotation: -360, scale: 1.1 },
|
||||
{ emoji: '🌀', rotation: 1080, scale: 1.2 },
|
||||
{ emoji: '💫', rotation: -1080, scale: 1.0 },
|
||||
{ emoji: '🖨️', rotation: 360, scale: 1.2 },
|
||||
{ emoji: '🧵', rotation: -360, scale: 1.1 },
|
||||
{ emoji: '🎭', rotation: 720, scale: 1.2 },
|
||||
{ emoji: '🎲', rotation: -720, scale: 1.3 },
|
||||
{ emoji: '🔮', rotation: 360, scale: 1.1 },
|
||||
{ emoji: '💎', rotation: -360, scale: 1.2 },
|
||||
{ emoji: '🌟', rotation: 1440, scale: 1.0 },
|
||||
];
|
||||
|
||||
export const AnimatedLogo: React.FC = () => {
|
||||
const [currentLogo, setCurrentLogo] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setCurrentLogo((prev) => (prev + 1) % logoVariants.length);
|
||||
setIsAnimating(false);
|
||||
}, 500);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const logo = logoVariants[currentLogo];
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-block text-4xl transition-all duration-700 ease-in-out"
|
||||
style={{
|
||||
transform: isAnimating
|
||||
? `rotate(${logo.rotation}deg) scale(0)`
|
||||
: `rotate(0deg) scale(${logo.scale})`,
|
||||
opacity: isAnimating ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
{logo.emoji}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -21,60 +21,14 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
onFilterChange,
|
||||
uniqueValues
|
||||
}) => {
|
||||
const quickFilters = [
|
||||
{ id: 'ready', label: 'Spremno za upotrebu', icon: '✅' },
|
||||
{ id: 'lowStock', label: 'Malo na stanju', icon: '⚠️' },
|
||||
{ id: 'refills', label: 'Samo punjenja', icon: '♻️' },
|
||||
{ id: 'sealed', label: 'Zapakovano', icon: '📦' },
|
||||
{ id: 'opened', label: 'Otvoreno', icon: '📂' }
|
||||
];
|
||||
|
||||
const handleQuickFilter = (filterId: string) => {
|
||||
switch (filterId) {
|
||||
case 'ready':
|
||||
onFilterChange({ ...filters, storageCondition: 'vacuum' });
|
||||
break;
|
||||
case 'lowStock':
|
||||
// This would need backend support
|
||||
onFilterChange({ ...filters });
|
||||
break;
|
||||
case 'refills':
|
||||
onFilterChange({ ...filters, isRefill: true });
|
||||
break;
|
||||
case 'sealed':
|
||||
onFilterChange({ ...filters, storageCondition: 'vacuum' });
|
||||
break;
|
||||
case 'opened':
|
||||
onFilterChange({ ...filters, storageCondition: 'opened' });
|
||||
break;
|
||||
}
|
||||
};
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = filters.brand || filters.material || filters.color ||
|
||||
filters.storageCondition || filters.isRefill !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
{/* Quick Filters */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Brzi filteri
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickFilters.map(filter => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => handleQuickFilter(filter.id)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm
|
||||
bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600
|
||||
hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span>{filter.icon}</span>
|
||||
<span>{filter.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
{/* Filters Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 max-w-6xl mx-auto">
|
||||
{/* Brand Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
@@ -83,7 +37,7 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={(e) => onFilterChange({ ...filters, brand: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
@@ -102,7 +56,7 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
<select
|
||||
value={filters.material}
|
||||
onChange={(e) => onFilterChange({ ...filters, material: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
@@ -131,7 +85,7 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
<select
|
||||
value={filters.color}
|
||||
onChange={(e) => onFilterChange({ ...filters, color: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
@@ -142,62 +96,71 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Storage Condition */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Skladištenje
|
||||
</label>
|
||||
<select
|
||||
value={filters.storageCondition}
|
||||
onChange={(e) => onFilterChange({ ...filters, storageCondition: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Sve lokacije</option>
|
||||
<option value="vacuum">Vakuum</option>
|
||||
<option value="sealed">Zapakovano</option>
|
||||
<option value="opened">Otvoreno</option>
|
||||
<option value="desiccant">Sa sušačem</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Checkboxes Section */}
|
||||
<div className="lg:col-span-2 flex items-end">
|
||||
<div className="flex flex-wrap gap-4 mb-2">
|
||||
{/* Refill checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.isRefill === true}
|
||||
onChange={(e) => onFilterChange({
|
||||
...filters,
|
||||
isRefill: e.target.checked ? true : null
|
||||
})}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Refill</span>
|
||||
</label>
|
||||
|
||||
{/* Refill Toggle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tip
|
||||
</label>
|
||||
<select
|
||||
value={filters.isRefill === null ? '' : filters.isRefill ? 'refill' : 'original'}
|
||||
onChange={(e) => onFilterChange({
|
||||
...filters,
|
||||
isRefill: e.target.value === '' ? null : e.target.value === 'refill'
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Svi tipovi</option>
|
||||
<option value="original">Originalno pakovanje</option>
|
||||
<option value="refill">Punjenje</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Storage checkboxes */}
|
||||
<label className="flex items-center gap-2 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.storageCondition === 'vacuum'}
|
||||
onChange={(e) => onFilterChange({
|
||||
...filters,
|
||||
storageCondition: e.target.checked ? 'vacuum' : ''
|
||||
})}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Vakuum</span>
|
||||
</label>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => onFilterChange({
|
||||
brand: '',
|
||||
material: '',
|
||||
storageCondition: '',
|
||||
isRefill: null,
|
||||
color: ''
|
||||
})}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Obriši sve filtere
|
||||
</button>
|
||||
<label className="flex items-center gap-2 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.storageCondition === 'opened'}
|
||||
onChange={(e) => onFilterChange({
|
||||
...filters,
|
||||
storageCondition: e.target.checked ? 'opened' : ''
|
||||
})}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Otvoreno</span>
|
||||
</label>
|
||||
|
||||
{/* Reset button - only show when filters are active */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => onFilterChange({
|
||||
brand: '',
|
||||
material: '',
|
||||
storageCondition: '',
|
||||
isRefill: null,
|
||||
color: ''
|
||||
})}
|
||||
className="flex items-center gap-1.5 ml-6 px-3 py-1.5 text-sm font-medium text-white bg-red-500 dark:bg-red-600 hover:bg-red-600 dark:hover:bg-red-700 rounded-md transition-all duration-200 transform hover:scale-105 shadow-sm hover:shadow-md"
|
||||
title="Reset sve filtere"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -280,7 +280,16 @@ export const FilamentTable: React.FC<FilamentTableProps> = ({ filaments, loading
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
<ColorCell colorName={filament.boja} />
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorCell colorName={filament.boja} />
|
||||
{filament.bojaHex && (
|
||||
<div
|
||||
className="w-4 h-4 rounded border border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: filament.bojaHex }}
|
||||
title={filament.bojaHex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 border-b border-r dark:border-gray-700 text-gray-900 dark:text-gray-100">{filament.refill}</td>
|
||||
<td className="px-4 py-2 border-b border-r dark:border-gray-700 text-gray-900 dark:text-gray-100">{filament.vakum}</td>
|
||||
|
||||
@@ -40,6 +40,9 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
const storageCondition = legacy.vakum?.toLowerCase().includes('vakuum') ? 'vacuum' :
|
||||
legacy.otvoreno?.toLowerCase().includes('otvorena') ? 'opened' : 'sealed';
|
||||
|
||||
const totalQuantity = parseInt(legacy.kolicina) || 1;
|
||||
const availableQuantity = totalQuantity > 0 ? totalQuantity : 0;
|
||||
|
||||
return {
|
||||
id: legacy.id || `legacy-${Math.random().toString(36).substr(2, 9)}`,
|
||||
brand: legacy.brand,
|
||||
@@ -49,12 +52,12 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
weight: { value: 1000, unit: 'g' as const },
|
||||
diameter: 1.75,
|
||||
inventory: {
|
||||
total: parseInt(legacy.kolicina) || 1,
|
||||
available: storageCondition === 'opened' ? 0 : 1,
|
||||
total: totalQuantity,
|
||||
available: availableQuantity,
|
||||
inUse: 0,
|
||||
locations: {
|
||||
vacuum: storageCondition === 'vacuum' ? 1 : 0,
|
||||
opened: storageCondition === 'opened' ? 1 : 0,
|
||||
vacuum: storageCondition === 'vacuum' ? totalQuantity : 0,
|
||||
opened: storageCondition === 'opened' ? totalQuantity : 0,
|
||||
printer: 0
|
||||
}
|
||||
},
|
||||
@@ -83,6 +86,8 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
// Filter and sort filaments
|
||||
const filteredAndSortedFilaments = useMemo(() => {
|
||||
let filtered = normalizedFilaments.filter(filament => {
|
||||
// Only show available filaments
|
||||
if (filament.inventory.available === 0) return false;
|
||||
// Search filter
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
@@ -90,7 +95,7 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
filament.material.base.toLowerCase().includes(searchLower) ||
|
||||
(filament.material.modifier?.toLowerCase().includes(searchLower)) ||
|
||||
filament.color.name.toLowerCase().includes(searchLower) ||
|
||||
(filament.sku?.toLowerCase().includes(searchLower));
|
||||
false; // SKU removed
|
||||
|
||||
// Other filters
|
||||
const matchesBrand = !filters.brand || filament.brand === filters.brand;
|
||||
@@ -138,19 +143,19 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
const summary = {
|
||||
totalSpools: 0,
|
||||
availableSpools: 0,
|
||||
totalWeight: 0,
|
||||
brandsCount: new Set<string>(),
|
||||
lowStock: [] as FilamentV2[]
|
||||
vacuumCount: 0,
|
||||
openedCount: 0,
|
||||
refillCount: 0
|
||||
};
|
||||
|
||||
normalizedFilaments.forEach(f => {
|
||||
summary.totalSpools += f.inventory.total;
|
||||
summary.availableSpools += f.inventory.available;
|
||||
summary.totalWeight += f.inventory.total * f.weight.value;
|
||||
summary.brandsCount.add(f.brand);
|
||||
summary.vacuumCount += f.inventory.locations.vacuum;
|
||||
summary.openedCount += f.inventory.locations.opened;
|
||||
|
||||
if (f.inventory.available <= 1 && f.inventory.total > 0) {
|
||||
summary.lowStock.push(f);
|
||||
if (f.condition.isRefill) {
|
||||
summary.refillCount += f.inventory.total;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,9 +173,9 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Inventory Summary */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupno kalema</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupno filamenta</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{inventorySummary.totalSpools}</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
@@ -178,12 +183,16 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">{inventorySummary.availableSpools}</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupna težina</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{(inventorySummary.totalWeight / 1000).toFixed(1)}kg</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Vakum</div>
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{inventorySummary.vacuumCount}</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Malo na stanju</div>
|
||||
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">{inventorySummary.lowStock.length}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Otvoreno</div>
|
||||
<div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{inventorySummary.openedCount}</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Refill</div>
|
||||
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">{inventorySummary.refillCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +200,7 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pretraži po brendu, materijalu, boji ili SKU..."
|
||||
placeholder="Pretraži po brendu, materijalu, boji..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-blue-500"
|
||||
@@ -208,14 +217,30 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
uniqueValues={uniqueValues}
|
||||
/>
|
||||
|
||||
{/* Icon Legend */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-base font-medium text-gray-700 dark:text-gray-300 mb-3 text-center">Legenda stanja:</h3>
|
||||
<div className="flex justify-center gap-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="transform scale-125">
|
||||
<InventoryBadge type="vacuum" count={1} />
|
||||
</div>
|
||||
<span className="text-gray-600 dark:text-gray-400 text-[15px]">Vakuum pakovanje</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="transform scale-125">
|
||||
<InventoryBadge type="opened" count={1} />
|
||||
</div>
|
||||
<span className="text-gray-600 dark:text-gray-400 text-[15px]">Otvoreno</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th onClick={() => handleSort('sku')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
SKU
|
||||
</th>
|
||||
<th onClick={() => handleSort('brand')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
Brend
|
||||
</th>
|
||||
@@ -226,11 +251,14 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
Boja
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Skladište
|
||||
Stanje
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Težina
|
||||
</th>
|
||||
<th onClick={() => handleSort('pricing.purchasePrice')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
Cena
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
@@ -239,9 +267,6 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredAndSortedFilaments.map(filament => (
|
||||
<tr key={filament.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500 dark:text-gray-400">
|
||||
{filament.sku || filament.id.substring(0, 8)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
{filament.brand}
|
||||
</td>
|
||||
@@ -267,16 +292,27 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{filament.weight.value}{filament.weight.unit}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
{(() => {
|
||||
// PLA Basic pricing logic
|
||||
if (filament.material.base === 'PLA' && !filament.material.modifier) {
|
||||
if (filament.condition.isRefill && filament.condition.storageCondition !== 'opened') {
|
||||
return '3.499 RSD';
|
||||
} else if (!filament.condition.isRefill && filament.condition.storageCondition === 'vacuum') {
|
||||
return '3.999 RSD';
|
||||
}
|
||||
}
|
||||
// Show original price if available
|
||||
return filament.pricing.purchasePrice ?
|
||||
`${filament.pricing.purchasePrice.toLocaleString('sr-RS')} ${filament.pricing.currency}` :
|
||||
'-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
{filament.condition.isRefill && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Punjenje
|
||||
</span>
|
||||
)}
|
||||
{filament.inventory.available === 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Nema na stanju
|
||||
Refill
|
||||
</span>
|
||||
)}
|
||||
{filament.inventory.available === 1 && (
|
||||
@@ -293,7 +329,7 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
Prikazano {filteredAndSortedFilaments.length} od {normalizedFilaments.length} filamenata
|
||||
Prikazano {filteredAndSortedFilaments.length} dostupnih filamenata
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,15 +25,15 @@ export const MaterialBadge: React.FC<MaterialBadgeProps> = ({ base, modifier, cl
|
||||
const getModifierIcon = () => {
|
||||
switch (modifier) {
|
||||
case 'Silk':
|
||||
return '✨';
|
||||
return 'S';
|
||||
case 'Matte':
|
||||
return '🔵';
|
||||
return 'M';
|
||||
case 'Glow':
|
||||
return '💡';
|
||||
return 'G';
|
||||
case 'Wood':
|
||||
return '🪵';
|
||||
return 'W';
|
||||
case 'CF':
|
||||
return '⚫';
|
||||
return 'CF';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export const MaterialBadge: React.FC<MaterialBadgeProps> = ({ base, modifier, cl
|
||||
</span>
|
||||
{modifier && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
{getModifierIcon()} {modifier}
|
||||
{getModifierIcon() && <span className="font-bold">{getModifierIcon()}</span>} {modifier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
86
src/services/api.ts
Normal file
86
src/services/api.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
// Create axios instance with default config
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const authService = {
|
||||
login: async (username: string, password: string) => {
|
||||
const response = await api.post('/login', { username, password });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const colorService = {
|
||||
getAll: async () => {
|
||||
const response = await api.get('/colors');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (color: { name: string; hex: string }) => {
|
||||
const response = await api.post('/colors', color);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, color: { name: string; hex: string }) => {
|
||||
const response = await api.put(`/colors/${id}`, color);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/colors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const filamentService = {
|
||||
getAll: async () => {
|
||||
const response = await api.get('/filaments');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (filament: any) => {
|
||||
const response = await api.post('/filaments', filament);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, filament: any) => {
|
||||
const response = await api.put(`/filaments/${id}`, filament);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/filaments/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -1,3 +1,23 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Prevent white flash on admin pages */
|
||||
@layer base {
|
||||
html {
|
||||
background-color: rgb(249 250 251);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: rgb(17 24 39);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 dark:bg-gray-900 transition-none;
|
||||
}
|
||||
|
||||
/* Disable transitions on page load to prevent flash */
|
||||
.no-transitions * {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,98 @@
|
||||
/* Custom select styling for cross-browser consistency */
|
||||
.custom-select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
/* Remove all native styling */
|
||||
-webkit-appearance: none !important;
|
||||
-moz-appearance: none !important;
|
||||
appearance: none !important;
|
||||
|
||||
/* Custom arrow */
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e") !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: right 0.5rem center !important;
|
||||
background-size: 1.5em 1.5em !important;
|
||||
padding-right: 2.5rem !important;
|
||||
|
||||
/* Ensure consistent rendering */
|
||||
border-radius: 0.375rem !important;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
/* Safari-specific fixes */
|
||||
-webkit-border-radius: 0.375rem !important;
|
||||
-webkit-padding-end: 2.5rem !important;
|
||||
-webkit-padding-start: 0.75rem !important;
|
||||
background-color: field !important;
|
||||
}
|
||||
|
||||
/* Remove Safari's native dropdown arrow */
|
||||
.custom-select::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
/* Additional Safari arrow removal */
|
||||
.custom-select::-webkit-inner-spin-button,
|
||||
.custom-select::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .custom-select {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e") !important;
|
||||
background-color: rgb(55 65 81) !important;
|
||||
}
|
||||
|
||||
/* Safari-specific fixes */
|
||||
@media not all and (min-resolution:.001dpcm) {
|
||||
@supports (-webkit-appearance:none) {
|
||||
.custom-select {
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
/* Focus styles */
|
||||
.custom-select:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Hover styles */
|
||||
.custom-select:hover:not(:disabled) {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Disabled styles */
|
||||
.custom-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Safari-specific overrides */
|
||||
@supports (-webkit-appearance: none) {
|
||||
.custom-select {
|
||||
/* Force removal of native arrow in Safari */
|
||||
background-origin: content-box !important;
|
||||
text-indent: 0.01px;
|
||||
text-overflow: '';
|
||||
}
|
||||
|
||||
/* Fix option styling in Safari */
|
||||
.custom-select option {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.dark .custom-select option {
|
||||
background-color: #1f2937;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional Safari fix for newer versions */
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
select.custom-select {
|
||||
-webkit-appearance: none !important;
|
||||
background-position: right 0.5rem center !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for Safari on iOS */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.custom-select {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface Filament {
|
||||
tip: string;
|
||||
finish: string;
|
||||
boja: string;
|
||||
bojaHex?: string;
|
||||
refill: string;
|
||||
vakum: string;
|
||||
otvoreno: string;
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface LegacyFields {
|
||||
export interface FilamentV2 {
|
||||
// Identifiers
|
||||
id: string;
|
||||
sku?: string;
|
||||
|
||||
// Product Info
|
||||
brand: string;
|
||||
|
||||
162
terraform/alb.tf
Normal file
162
terraform/alb.tf
Normal file
@@ -0,0 +1,162 @@
|
||||
# Application Load Balancer
|
||||
resource "aws_lb" "api" {
|
||||
name = "${var.app_name}-api-alb"
|
||||
internal = false
|
||||
load_balancer_type = "application"
|
||||
security_groups = [aws_security_group.alb.id]
|
||||
subnets = aws_subnet.public[*].id
|
||||
|
||||
enable_deletion_protection = false
|
||||
enable_http2 = true
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-api-alb"
|
||||
}
|
||||
}
|
||||
|
||||
# ALB Security Group
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${var.app_name}-alb-sg"
|
||||
description = "Security group for ALB"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
description = "HTTP from anywhere"
|
||||
}
|
||||
|
||||
ingress {
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
description = "HTTPS from anywhere"
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
description = "Allow all outbound"
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-alb-sg"
|
||||
}
|
||||
}
|
||||
|
||||
# Target Group
|
||||
resource "aws_lb_target_group" "api" {
|
||||
name = "${var.app_name}-api-tg"
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
health_check {
|
||||
enabled = true
|
||||
healthy_threshold = 2
|
||||
interval = 30
|
||||
matcher = "200"
|
||||
path = "/"
|
||||
port = "traffic-port"
|
||||
protocol = "HTTP"
|
||||
timeout = 5
|
||||
unhealthy_threshold = 2
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-api-tg"
|
||||
}
|
||||
}
|
||||
|
||||
# Attach EC2 instance to target group
|
||||
resource "aws_lb_target_group_attachment" "api" {
|
||||
target_group_arn = aws_lb_target_group.api.arn
|
||||
target_id = aws_instance.api.id
|
||||
port = 80
|
||||
}
|
||||
|
||||
# HTTP Listener (redirects to HTTPS)
|
||||
resource "aws_lb_listener" "api_http" {
|
||||
load_balancer_arn = aws_lb.api.arn
|
||||
port = "80"
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "redirect"
|
||||
|
||||
redirect {
|
||||
port = "443"
|
||||
protocol = "HTTPS"
|
||||
status_code = "HTTP_301"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Request ACM certificate
|
||||
resource "aws_acm_certificate" "api" {
|
||||
domain_name = "api.${var.domain_name}"
|
||||
validation_method = "DNS"
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-api-cert"
|
||||
}
|
||||
}
|
||||
|
||||
# Create DNS validation record in Cloudflare
|
||||
resource "cloudflare_record" "api_cert_validation" {
|
||||
for_each = {
|
||||
for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name => {
|
||||
name = dvo.resource_record_name
|
||||
record = dvo.resource_record_value
|
||||
type = dvo.resource_record_type
|
||||
}
|
||||
}
|
||||
|
||||
zone_id = data.cloudflare_zone.domain[0].id
|
||||
name = replace(each.value.name, ".${var.domain_name}.", "")
|
||||
value = trimsuffix(each.value.record, ".")
|
||||
type = each.value.type
|
||||
ttl = 60
|
||||
proxied = false
|
||||
}
|
||||
|
||||
# Certificate validation
|
||||
resource "aws_acm_certificate_validation" "api" {
|
||||
certificate_arn = aws_acm_certificate.api.arn
|
||||
validation_record_fqdns = [for record in cloudflare_record.api_cert_validation : record.hostname]
|
||||
}
|
||||
|
||||
# HTTPS Listener
|
||||
resource "aws_lb_listener" "api_https" {
|
||||
load_balancer_arn = aws_lb.api.arn
|
||||
port = "443"
|
||||
protocol = "HTTPS"
|
||||
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
|
||||
certificate_arn = aws_acm_certificate_validation.api.certificate_arn
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.api.arn
|
||||
}
|
||||
}
|
||||
|
||||
# Output ALB DNS
|
||||
output "alb_dns_name" {
|
||||
value = aws_lb.api.dns_name
|
||||
description = "ALB DNS name"
|
||||
}
|
||||
|
||||
# Output ALB Zone ID (needed for Route53 alias)
|
||||
output "alb_zone_id" {
|
||||
value = aws_lb.api.zone_id
|
||||
description = "ALB Zone ID"
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
# API Gateway REST API
|
||||
resource "aws_api_gateway_rest_api" "api" {
|
||||
name = "${var.app_name}-api"
|
||||
description = "API for ${var.app_name}"
|
||||
}
|
||||
|
||||
# API Gateway Resources
|
||||
resource "aws_api_gateway_resource" "filaments" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
parent_id = aws_api_gateway_rest_api.api.root_resource_id
|
||||
path_part = "filaments"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_resource" "filament" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
parent_id = aws_api_gateway_resource.filaments.id
|
||||
path_part = "{id}"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_resource" "auth" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
parent_id = aws_api_gateway_rest_api.api.root_resource_id
|
||||
path_part = "auth"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_resource" "login" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
parent_id = aws_api_gateway_resource.auth.id
|
||||
path_part = "login"
|
||||
}
|
||||
|
||||
# Lambda Authorizer
|
||||
resource "aws_api_gateway_authorizer" "jwt_authorizer" {
|
||||
name = "${var.app_name}-jwt-authorizer"
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
type = "TOKEN"
|
||||
authorizer_uri = aws_lambda_function.auth_api.invoke_arn
|
||||
authorizer_credentials = aws_iam_role.api_gateway_auth_invocation.arn
|
||||
identity_source = "method.request.header.Authorization"
|
||||
}
|
||||
|
||||
# IAM role for API Gateway to invoke Lambda authorizer
|
||||
resource "aws_iam_role" "api_gateway_auth_invocation" {
|
||||
name = "${var.app_name}-api-gateway-auth-invocation"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "apigateway.amazonaws.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "api_gateway_auth_invocation" {
|
||||
name = "${var.app_name}-api-gateway-auth-invocation"
|
||||
role = aws_iam_role.api_gateway_auth_invocation.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = "lambda:InvokeFunction"
|
||||
Resource = aws_lambda_function.auth_api.arn
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
# Methods for /filaments
|
||||
resource "aws_api_gateway_method" "get_filaments" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filaments.id
|
||||
http_method = "GET"
|
||||
authorization = "NONE"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method" "post_filament" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filaments.id
|
||||
http_method = "POST"
|
||||
authorization = "CUSTOM"
|
||||
authorizer_id = aws_api_gateway_authorizer.jwt_authorizer.id
|
||||
}
|
||||
|
||||
# Methods for /filaments/{id}
|
||||
resource "aws_api_gateway_method" "get_filament" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filament.id
|
||||
http_method = "GET"
|
||||
authorization = "NONE"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method" "put_filament" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filament.id
|
||||
http_method = "PUT"
|
||||
authorization = "CUSTOM"
|
||||
authorizer_id = aws_api_gateway_authorizer.jwt_authorizer.id
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method" "delete_filament" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filament.id
|
||||
http_method = "DELETE"
|
||||
authorization = "CUSTOM"
|
||||
authorizer_id = aws_api_gateway_authorizer.jwt_authorizer.id
|
||||
}
|
||||
|
||||
# Method for /auth/login
|
||||
resource "aws_api_gateway_method" "login" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.login.id
|
||||
http_method = "POST"
|
||||
authorization = "NONE"
|
||||
}
|
||||
|
||||
# OPTIONS methods for CORS
|
||||
resource "aws_api_gateway_method" "options_filaments" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filaments.id
|
||||
http_method = "OPTIONS"
|
||||
authorization = "NONE"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method" "options_filament" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filament.id
|
||||
http_method = "OPTIONS"
|
||||
authorization = "NONE"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method" "options_login" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.login.id
|
||||
http_method = "OPTIONS"
|
||||
authorization = "NONE"
|
||||
}
|
||||
|
||||
# Lambda integrations
|
||||
resource "aws_api_gateway_integration" "filaments_get" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filaments.id
|
||||
http_method = aws_api_gateway_method.get_filaments.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.filaments_api.invoke_arn
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "filaments_post" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filaments.id
|
||||
http_method = aws_api_gateway_method.post_filament.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.filaments_api.invoke_arn
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "filament_get" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filament.id
|
||||
http_method = aws_api_gateway_method.get_filament.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.filaments_api.invoke_arn
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "filament_put" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filament.id
|
||||
http_method = aws_api_gateway_method.put_filament.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.filaments_api.invoke_arn
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "filament_delete" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filament.id
|
||||
http_method = aws_api_gateway_method.delete_filament.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.filaments_api.invoke_arn
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "login" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.login.id
|
||||
http_method = aws_api_gateway_method.login.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.auth_api.invoke_arn
|
||||
}
|
||||
|
||||
# OPTIONS integrations for CORS
|
||||
resource "aws_api_gateway_integration" "options_filaments" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filaments.id
|
||||
http_method = aws_api_gateway_method.options_filaments.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.filaments_api.invoke_arn
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "options_filament" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.filament.id
|
||||
http_method = aws_api_gateway_method.options_filament.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.filaments_api.invoke_arn
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "options_login" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
resource_id = aws_api_gateway_resource.login.id
|
||||
http_method = aws_api_gateway_method.options_login.http_method
|
||||
|
||||
integration_http_method = "POST"
|
||||
type = "AWS_PROXY"
|
||||
uri = aws_lambda_function.auth_api.invoke_arn
|
||||
}
|
||||
|
||||
# Lambda permissions for API Gateway
|
||||
resource "aws_lambda_permission" "api_gateway_filaments" {
|
||||
statement_id = "AllowAPIGatewayInvoke"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = aws_lambda_function.filaments_api.function_name
|
||||
principal = "apigateway.amazonaws.com"
|
||||
source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "api_gateway_auth" {
|
||||
statement_id = "AllowAPIGatewayInvoke"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = aws_lambda_function.auth_api.function_name
|
||||
principal = "apigateway.amazonaws.com"
|
||||
source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
|
||||
}
|
||||
|
||||
# API Gateway Deployment
|
||||
resource "aws_api_gateway_deployment" "api" {
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
|
||||
depends_on = [
|
||||
aws_api_gateway_integration.filaments_get,
|
||||
aws_api_gateway_integration.filaments_post,
|
||||
aws_api_gateway_integration.filament_get,
|
||||
aws_api_gateway_integration.filament_put,
|
||||
aws_api_gateway_integration.filament_delete,
|
||||
aws_api_gateway_integration.login,
|
||||
aws_api_gateway_integration.options_filaments,
|
||||
aws_api_gateway_integration.options_filament,
|
||||
aws_api_gateway_integration.options_login
|
||||
]
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
# API Gateway Stage
|
||||
resource "aws_api_gateway_stage" "api" {
|
||||
deployment_id = aws_api_gateway_deployment.api.id
|
||||
rest_api_id = aws_api_gateway_rest_api.api.id
|
||||
stage_name = var.environment
|
||||
}
|
||||
18
terraform/cloudflare-api.tf
Normal file
18
terraform/cloudflare-api.tf
Normal file
@@ -0,0 +1,18 @@
|
||||
# Cloudflare DNS for API subdomain
|
||||
data "cloudflare_zone" "domain" {
|
||||
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
|
||||
name = var.domain_name
|
||||
}
|
||||
|
||||
# CNAME record for api.filamenteka.rs pointing to ALB
|
||||
resource "cloudflare_record" "api" {
|
||||
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
|
||||
zone_id = data.cloudflare_zone.domain[0].id
|
||||
name = "api"
|
||||
type = "CNAME"
|
||||
value = aws_lb.api.dns_name
|
||||
ttl = 1
|
||||
proxied = false
|
||||
comment = "ALB API endpoint with HTTPS"
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Cloudflare DNS configuration
|
||||
provider "cloudflare" {
|
||||
api_token = var.cloudflare_api_token
|
||||
}
|
||||
|
||||
# Data source to find the zone
|
||||
data "cloudflare_zone" "main" {
|
||||
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
|
||||
name = var.domain_name
|
||||
}
|
||||
|
||||
# Create CNAME record for API subdomain
|
||||
resource "cloudflare_record" "api" {
|
||||
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
|
||||
zone_id = data.cloudflare_zone.main[0].id
|
||||
name = "api"
|
||||
content = replace(replace(aws_api_gateway_stage.api.invoke_url, "https://", ""), "/production", "")
|
||||
type = "CNAME"
|
||||
ttl = 120
|
||||
proxied = false
|
||||
comment = "API Gateway endpoint"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# DynamoDB table for storing filament data
|
||||
resource "aws_dynamodb_table" "filaments" {
|
||||
name = "${var.app_name}-filaments"
|
||||
billing_mode = "PAY_PER_REQUEST"
|
||||
hash_key = "id"
|
||||
|
||||
attribute {
|
||||
name = "id"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
attribute {
|
||||
name = "brand"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
attribute {
|
||||
name = "tip"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
attribute {
|
||||
name = "status"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
# Global secondary index for querying by brand
|
||||
global_secondary_index {
|
||||
name = "brand-index"
|
||||
hash_key = "brand"
|
||||
projection_type = "ALL"
|
||||
}
|
||||
|
||||
# Global secondary index for querying by type
|
||||
global_secondary_index {
|
||||
name = "tip-index"
|
||||
hash_key = "tip"
|
||||
projection_type = "ALL"
|
||||
}
|
||||
|
||||
# Global secondary index for querying by status
|
||||
global_secondary_index {
|
||||
name = "status-index"
|
||||
hash_key = "status"
|
||||
projection_type = "ALL"
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-filaments"
|
||||
Environment = var.environment
|
||||
}
|
||||
}
|
||||
136
terraform/ec2-api.tf
Normal file
136
terraform/ec2-api.tf
Normal file
@@ -0,0 +1,136 @@
|
||||
# Security group for API instance
|
||||
resource "aws_security_group" "api_instance" {
|
||||
name = "${var.app_name}-api-instance-sg"
|
||||
description = "Security group for API EC2 instance"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.alb.id]
|
||||
description = "HTTP from ALB only"
|
||||
}
|
||||
|
||||
|
||||
ingress {
|
||||
from_port = 22
|
||||
to_port = 22
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-api-instance-sg"
|
||||
}
|
||||
}
|
||||
|
||||
# IAM role for EC2 instance
|
||||
resource "aws_iam_role" "api_instance" {
|
||||
name = "${var.app_name}-api-instance-role"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "ec2.amazonaws.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
# IAM policy for ECR access
|
||||
resource "aws_iam_role_policy" "ecr_access" {
|
||||
name = "${var.app_name}-ecr-access"
|
||||
role = aws_iam_role.api_instance.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ecr:GetAuthorizationToken",
|
||||
"ecr:BatchCheckLayerAvailability",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"ecr:BatchGetImage"
|
||||
]
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
# Instance profile
|
||||
resource "aws_iam_instance_profile" "api" {
|
||||
name = "${var.app_name}-api-instance-profile"
|
||||
role = aws_iam_role.api_instance.name
|
||||
}
|
||||
|
||||
# Get latest Amazon Linux 2 AMI
|
||||
data "aws_ami" "amazon_linux_2" {
|
||||
most_recent = true
|
||||
owners = ["amazon"]
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "virtualization-type"
|
||||
values = ["hvm"]
|
||||
}
|
||||
}
|
||||
|
||||
# EC2 instance for API
|
||||
resource "aws_instance" "api" {
|
||||
ami = data.aws_ami.amazon_linux_2.id
|
||||
instance_type = "t3.micro"
|
||||
subnet_id = aws_subnet.public[0].id
|
||||
vpc_security_group_ids = [aws_security_group.api_instance.id]
|
||||
|
||||
associate_public_ip_address = true
|
||||
iam_instance_profile = aws_iam_instance_profile.api.name
|
||||
|
||||
user_data = base64encode(templatefile("${path.module}/user-data.sh", {
|
||||
database_url = "postgresql://${aws_db_instance.filamenteka.username}:${random_password.db_password.result}@${aws_db_instance.filamenteka.endpoint}/${aws_db_instance.filamenteka.db_name}?sslmode=require"
|
||||
jwt_secret = random_password.jwt_secret.result
|
||||
admin_password = var.admin_password
|
||||
ecr_url = aws_ecr_repository.api.repository_url
|
||||
aws_region = var.aws_region
|
||||
}))
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-api-instance"
|
||||
}
|
||||
|
||||
depends_on = [aws_db_instance.filamenteka]
|
||||
}
|
||||
|
||||
# Elastic IP for API instance
|
||||
resource "aws_eip" "api" {
|
||||
instance = aws_instance.api.id
|
||||
domain = "vpc"
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-api-eip"
|
||||
}
|
||||
}
|
||||
|
||||
# Output the API URL
|
||||
output "api_instance_url" {
|
||||
value = "http://${aws_eip.api.public_ip}"
|
||||
description = "API instance URL"
|
||||
}
|
||||
34
terraform/ecr.tf
Normal file
34
terraform/ecr.tf
Normal file
@@ -0,0 +1,34 @@
|
||||
# ECR Repository for API
|
||||
resource "aws_ecr_repository" "api" {
|
||||
name = "${var.app_name}-api"
|
||||
image_tag_mutability = "MUTABLE"
|
||||
|
||||
image_scanning_configuration {
|
||||
scan_on_push = true
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-api"
|
||||
}
|
||||
}
|
||||
|
||||
# ECR Lifecycle Policy
|
||||
resource "aws_ecr_lifecycle_policy" "api" {
|
||||
repository = aws_ecr_repository.api.name
|
||||
|
||||
policy = jsonencode({
|
||||
rules = [{
|
||||
rulePriority = 1
|
||||
description = "Keep last 10 images"
|
||||
selection = {
|
||||
tagStatus = "tagged"
|
||||
tagPrefixList = ["v"]
|
||||
countType = "imageCountMoreThan"
|
||||
countNumber = 10
|
||||
}
|
||||
action = {
|
||||
type = "expire"
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
# IAM role for Lambda functions
|
||||
resource "aws_iam_role" "lambda_role" {
|
||||
name = "${var.app_name}-lambda-role"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "lambda.amazonaws.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
# IAM policy for Lambda to access DynamoDB
|
||||
resource "aws_iam_role_policy" "lambda_dynamodb_policy" {
|
||||
name = "${var.app_name}-lambda-dynamodb-policy"
|
||||
role = aws_iam_role.lambda_role.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:UpdateItem",
|
||||
"dynamodb:DeleteItem",
|
||||
"dynamodb:Scan",
|
||||
"dynamodb:Query"
|
||||
]
|
||||
Resource = [
|
||||
aws_dynamodb_table.filaments.arn,
|
||||
"${aws_dynamodb_table.filaments.arn}/index/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"logs:CreateLogGroup",
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
]
|
||||
Resource = "arn:aws:logs:*:*:*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
# Lambda function for filaments CRUD
|
||||
resource "aws_lambda_function" "filaments_api" {
|
||||
filename = data.archive_file.filaments_lambda_zip.output_path
|
||||
function_name = "${var.app_name}-filaments-api"
|
||||
role = aws_iam_role.lambda_role.arn
|
||||
handler = "index.handler"
|
||||
runtime = "nodejs18.x"
|
||||
timeout = 30
|
||||
memory_size = 256
|
||||
source_code_hash = data.archive_file.filaments_lambda_zip.output_base64sha256
|
||||
|
||||
environment {
|
||||
variables = {
|
||||
TABLE_NAME = aws_dynamodb_table.filaments.name
|
||||
CORS_ORIGIN = "*"
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [aws_iam_role_policy.lambda_dynamodb_policy]
|
||||
}
|
||||
|
||||
# Lambda function for authentication
|
||||
resource "aws_lambda_function" "auth_api" {
|
||||
filename = data.archive_file.auth_lambda_zip.output_path
|
||||
function_name = "${var.app_name}-auth-api"
|
||||
role = aws_iam_role.lambda_role.arn
|
||||
handler = "index.handler"
|
||||
runtime = "nodejs18.x"
|
||||
timeout = 10
|
||||
memory_size = 128
|
||||
source_code_hash = data.archive_file.auth_lambda_zip.output_base64sha256
|
||||
|
||||
environment {
|
||||
variables = {
|
||||
JWT_SECRET = var.jwt_secret
|
||||
ADMIN_USERNAME = var.admin_username
|
||||
ADMIN_PASSWORD_HASH = var.admin_password_hash
|
||||
CORS_ORIGIN = "*"
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [aws_iam_role_policy.lambda_dynamodb_policy]
|
||||
}
|
||||
|
||||
# Archive files for Lambda deployment
|
||||
data "archive_file" "filaments_lambda_zip" {
|
||||
type = "zip"
|
||||
source_dir = "${path.module}/../lambda/filaments"
|
||||
output_path = "${path.module}/../lambda/filaments.zip"
|
||||
}
|
||||
|
||||
data "archive_file" "auth_lambda_zip" {
|
||||
type = "zip"
|
||||
source_dir = "${path.module}/../lambda/auth"
|
||||
output_path = "${path.module}/../lambda/auth.zip"
|
||||
}
|
||||
@@ -16,6 +16,10 @@ provider "aws" {
|
||||
region = "eu-central-1" # Frankfurt
|
||||
}
|
||||
|
||||
provider "cloudflare" {
|
||||
api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided
|
||||
}
|
||||
|
||||
resource "aws_amplify_app" "filamenteka" {
|
||||
name = "filamenteka"
|
||||
repository = var.github_repository
|
||||
@@ -49,7 +53,7 @@ resource "aws_amplify_app" "filamenteka" {
|
||||
|
||||
# Environment variables
|
||||
environment_variables = {
|
||||
NEXT_PUBLIC_API_URL = aws_api_gateway_stage.api.invoke_url
|
||||
NEXT_PUBLIC_API_URL = "https://api.filamenteka.rs/api" # Using Cloudflare proxied subdomain
|
||||
}
|
||||
|
||||
# Custom rules for single-page app
|
||||
|
||||
@@ -18,17 +18,28 @@ output "custom_domain_url" {
|
||||
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
|
||||
}
|
||||
|
||||
output "api_url" {
|
||||
description = "API Gateway URL"
|
||||
value = aws_api_gateway_stage.api.invoke_url
|
||||
output "rds_endpoint" {
|
||||
value = aws_db_instance.filamenteka.endpoint
|
||||
description = "RDS instance endpoint"
|
||||
}
|
||||
|
||||
output "dynamodb_table_name" {
|
||||
description = "DynamoDB table name"
|
||||
value = aws_dynamodb_table.filaments.name
|
||||
output "rds_database_name" {
|
||||
value = aws_db_instance.filamenteka.db_name
|
||||
description = "Database name"
|
||||
}
|
||||
|
||||
output "api_custom_url" {
|
||||
description = "Custom API URL via Cloudflare"
|
||||
value = var.domain_name != "" && var.cloudflare_api_token != "" ? "https://api.${var.domain_name}" : "Use api_url output instead"
|
||||
output "rds_username" {
|
||||
value = aws_db_instance.filamenteka.username
|
||||
description = "Database username"
|
||||
}
|
||||
|
||||
output "rds_password_secret_arn" {
|
||||
value = aws_secretsmanager_secret.db_credentials.arn
|
||||
description = "ARN of the secret containing the database password"
|
||||
}
|
||||
|
||||
output "database_url" {
|
||||
value = "postgresql://${aws_db_instance.filamenteka.username}:[PASSWORD]@${aws_db_instance.filamenteka.endpoint}/${aws_db_instance.filamenteka.db_name}"
|
||||
description = "Database connection URL (replace [PASSWORD] with actual password from Secrets Manager)"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
98
terraform/rds.tf
Normal file
98
terraform/rds.tf
Normal file
@@ -0,0 +1,98 @@
|
||||
# RDS PostgreSQL Database
|
||||
resource "aws_db_subnet_group" "filamenteka" {
|
||||
name = "${var.app_name}-db-subnet-group"
|
||||
subnet_ids = aws_subnet.public[*].id
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-db-subnet-group"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "rds" {
|
||||
name = "${var.app_name}-rds-sg"
|
||||
description = "Security group for RDS database"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
|
||||
# Allow access from your local IP for development
|
||||
# IMPORTANT: Replace with your actual IP address
|
||||
ingress {
|
||||
from_port = 5432
|
||||
to_port = 5432
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"] # WARNING: This allows access from anywhere. Replace with your IP!
|
||||
description = "Development access - RESTRICT THIS IN PRODUCTION"
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-rds-sg"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_db_instance" "filamenteka" {
|
||||
identifier = var.app_name
|
||||
engine = "postgres"
|
||||
engine_version = "15"
|
||||
instance_class = "db.t3.micro"
|
||||
|
||||
allocated_storage = 20
|
||||
max_allocated_storage = 100
|
||||
storage_type = "gp3"
|
||||
storage_encrypted = true
|
||||
|
||||
db_name = "filamenteka"
|
||||
username = "filamenteka_admin"
|
||||
password = random_password.db_password.result
|
||||
|
||||
# Make it publicly accessible for development
|
||||
publicly_accessible = true
|
||||
|
||||
vpc_security_group_ids = [aws_security_group.rds.id]
|
||||
db_subnet_group_name = aws_db_subnet_group.filamenteka.name
|
||||
|
||||
backup_retention_period = 7
|
||||
backup_window = "03:00-04:00"
|
||||
maintenance_window = "sun:04:00-sun:05:00"
|
||||
|
||||
deletion_protection = false # Set to true in production
|
||||
skip_final_snapshot = true # Set to false in production
|
||||
|
||||
enabled_cloudwatch_logs_exports = ["postgresql"]
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-db"
|
||||
}
|
||||
}
|
||||
|
||||
resource "random_password" "db_password" {
|
||||
length = 32
|
||||
special = false # RDS doesn't allow certain special characters
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "db_credentials" {
|
||||
name = "${var.app_name}-db-credentials"
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "db_credentials" {
|
||||
secret_id = aws_secretsmanager_secret.db_credentials.id
|
||||
secret_string = jsonencode({
|
||||
username = aws_db_instance.filamenteka.username
|
||||
password = random_password.db_password.result
|
||||
host = aws_db_instance.filamenteka.endpoint
|
||||
port = aws_db_instance.filamenteka.port
|
||||
database = aws_db_instance.filamenteka.db_name
|
||||
})
|
||||
}
|
||||
|
||||
# Random password for JWT
|
||||
resource "random_password" "jwt_secret" {
|
||||
length = 64
|
||||
special = false
|
||||
}
|
||||
68
terraform/user-data.sh
Normal file
68
terraform/user-data.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update system
|
||||
yum update -y
|
||||
|
||||
# Install Docker
|
||||
amazon-linux-extras install docker -y
|
||||
service docker start
|
||||
usermod -a -G docker ec2-user
|
||||
chkconfig docker on
|
||||
|
||||
# Install docker-compose
|
||||
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
# Configure AWS CLI
|
||||
aws configure set region ${aws_region}
|
||||
|
||||
# Login to ECR
|
||||
aws ecr get-login-password --region ${aws_region} | docker login --username AWS --password-stdin ${ecr_url}
|
||||
|
||||
# Create environment file
|
||||
cat > /home/ec2-user/.env <<EOF
|
||||
DATABASE_URL=${database_url}
|
||||
JWT_SECRET=${jwt_secret}
|
||||
ADMIN_PASSWORD=${admin_password}
|
||||
NODE_ENV=production
|
||||
PORT=80
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
EOF
|
||||
|
||||
# Create docker-compose file
|
||||
cat > /home/ec2-user/docker-compose.yml <<EOF
|
||||
version: '3.8'
|
||||
services:
|
||||
api:
|
||||
image: ${ecr_url}:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
env_file: .env
|
||||
restart: always
|
||||
EOF
|
||||
|
||||
# Start the API
|
||||
cd /home/ec2-user
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
|
||||
# Setup auto-restart on reboot
|
||||
cat > /etc/systemd/system/api.service <<EOF
|
||||
[Unit]
|
||||
Description=Filamenteka API
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/home/ec2-user
|
||||
ExecStart=/usr/local/bin/docker-compose up -d
|
||||
ExecStop=/usr/local/bin/docker-compose down
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl enable api.service
|
||||
@@ -27,21 +27,10 @@ variable "app_name" {
|
||||
default = "filamenteka"
|
||||
}
|
||||
|
||||
variable "jwt_secret" {
|
||||
description = "JWT secret for authentication"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "admin_username" {
|
||||
description = "Admin username"
|
||||
type = string
|
||||
default = "admin"
|
||||
}
|
||||
|
||||
variable "admin_password_hash" {
|
||||
description = "BCrypt hash of admin password"
|
||||
variable "admin_password" {
|
||||
description = "Admin password for the application"
|
||||
type = string
|
||||
default = "admin123"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
@@ -51,3 +40,9 @@ variable "cloudflare_api_token" {
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "aws_region" {
|
||||
description = "AWS region"
|
||||
type = string
|
||||
default = "eu-central-1"
|
||||
}
|
||||
58
terraform/vpc.tf
Normal file
58
terraform/vpc.tf
Normal file
@@ -0,0 +1,58 @@
|
||||
# VPC Configuration
|
||||
resource "aws_vpc" "main" {
|
||||
cidr_block = "10.0.0.0/16"
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
# Internet Gateway
|
||||
resource "aws_internet_gateway" "main" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-igw"
|
||||
}
|
||||
}
|
||||
|
||||
# Public Subnets for RDS (needs at least 2 for subnet group)
|
||||
resource "aws_subnet" "public" {
|
||||
count = 2
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.0.${count.index + 1}.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-public-subnet-${count.index + 1}"
|
||||
}
|
||||
}
|
||||
|
||||
# Route Table for Public Subnets
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.main.id
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.app_name}-public-rt"
|
||||
}
|
||||
}
|
||||
|
||||
# Associate Public Subnets with Route Table
|
||||
resource "aws_route_table_association" "public" {
|
||||
count = 2
|
||||
subnet_id = aws_subnet.public[count.index].id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
# Data source for availability zones
|
||||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
Reference in New Issue
Block a user