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', () => {
|
it('should use NEXT_PUBLIC_API_URL in all components', () => {
|
||||||
const pagePath = join(process.cwd(), 'app', 'page.tsx');
|
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 pageContent = readFileSync(pagePath, 'utf-8');
|
||||||
const adminContent = readFileSync(adminPath, '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
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="sr">
|
<html lang="sr" suppressHydrationWarning>
|
||||||
<body>{children}</body>
|
<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>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
119
app/page.tsx
119
app/page.tsx
@@ -1,19 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { FilamentTable } from '../src/components/FilamentTable';
|
|
||||||
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
||||||
import { Filament } from '../src/types/filament';
|
import { Filament } from '../src/types/filament';
|
||||||
import axios from 'axios';
|
import { filamentService } from '../src/services/api';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
|
||||||
const [darkMode, setDarkMode] = useState(false);
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
const [mounted, setMounted] = 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
|
// Initialize dark mode from localStorage after mounting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,22 +40,11 @@ export default function Home() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
const filaments = await filamentService.getAll();
|
||||||
if (!apiUrl) {
|
setFilaments(filaments);
|
||||||
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());
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('API Error:', err);
|
console.error('API Error:', err);
|
||||||
if (axios.isAxiosError(err)) {
|
setError(err instanceof Error ? err.message : 'Greška pri učitavanju filamenata');
|
||||||
setError(`API Error: ${err.response?.status || 'Network'} - ${err.message}`);
|
|
||||||
} else {
|
|
||||||
setError(err instanceof Error ? err.message : 'Greška pri učitavanju filamenata');
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -64,51 +52,47 @@ export default function Home() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFilaments();
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
<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">
|
<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-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
<button
|
||||||
Filamenteka
|
onClick={handleLogoClick}
|
||||||
</h1>
|
className="group hover:scale-105 transition-transform duration-200"
|
||||||
<div className="flex items-center gap-4">
|
title="Klikni za reset"
|
||||||
{lastUpdate && (
|
>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<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">
|
||||||
Poslednje ažuriranje: {lastUpdate.toLocaleTimeString('sr-RS')}
|
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>
|
</span>
|
||||||
)}
|
<span className="hidden sm:inline text-gray-400 dark:text-gray-600">•</span>
|
||||||
<button
|
<span className="text-orange-700 dark:text-orange-300 font-medium animate-pulse whitespace-nowrap">
|
||||||
onClick={fetchFilaments}
|
Popust za 5+ komada
|
||||||
disabled={loading}
|
</span>
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
</div>
|
||||||
>
|
|
||||||
{loading ? 'Ažuriranje...' : 'Osveži'}
|
|
||||||
</button>
|
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<>
|
<button
|
||||||
<button
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
onClick={() => setUseV2(!useV2)}
|
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"
|
||||||
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={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||||
title={useV2 ? 'Stari prikaz' : 'Novi prikaz'}
|
>
|
||||||
>
|
{darkMode ? 'Svetla' : 'Tamna'}
|
||||||
{useV2 ? 'V2' : 'V1'}
|
</button>
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,19 +100,12 @@ export default function Home() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{useV2 ? (
|
<FilamentTableV2
|
||||||
<FilamentTableV2
|
key={resetKey}
|
||||||
filaments={filaments}
|
filaments={filaments}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error || undefined}
|
error={error || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<FilamentTable
|
|
||||||
filaments={filaments}
|
|
||||||
loading={loading}
|
|
||||||
error={error || undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import axios from 'axios';
|
import { authService } from '../../src/services/api';
|
||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -11,23 +11,25 @@ export default function AdminLogin() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Set dark mode by default
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
|
const response = await authService.login(username, password);
|
||||||
username,
|
|
||||||
password
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store token in localStorage
|
// Store token in localStorage
|
||||||
localStorage.setItem('authToken', response.data.token);
|
localStorage.setItem('authToken', response.token);
|
||||||
localStorage.setItem('tokenExpiry', String(Date.now() + response.data.expiresIn * 1000));
|
localStorage.setItem('tokenExpiry', String(Date.now() + 24 * 60 * 60 * 1000)); // 24 hours
|
||||||
|
|
||||||
// Redirect to admin dashboard
|
// Redirect to admin dashboard
|
||||||
router.push('/admin/dashboard');
|
router.push('/upadaj/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Neispravno korisničko ime ili lozinka');
|
setError('Neispravno korisničko ime ili lozinka');
|
||||||
console.error('Login error:', err);
|
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,
|
onFilterChange,
|
||||||
uniqueValues
|
uniqueValues
|
||||||
}) => {
|
}) => {
|
||||||
const quickFilters = [
|
// Check if any filters are active
|
||||||
{ id: 'ready', label: 'Spremno za upotrebu', icon: '✅' },
|
const hasActiveFilters = filters.brand || filters.material || filters.color ||
|
||||||
{ id: 'lowStock', label: 'Malo na stanju', icon: '⚠️' },
|
filters.storageCondition || filters.isRefill !== null;
|
||||||
{ 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
{/* Quick Filters */}
|
{/* Filters Grid */}
|
||||||
<div>
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 max-w-6xl mx-auto">
|
||||||
<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">
|
|
||||||
{/* Brand Filter */}
|
{/* Brand Filter */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<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
|
<select
|
||||||
value={filters.brand}
|
value={filters.brand}
|
||||||
onChange={(e) => onFilterChange({ ...filters, brand: e.target.value })}
|
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
|
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"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
@@ -102,7 +56,7 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
<select
|
<select
|
||||||
value={filters.material}
|
value={filters.material}
|
||||||
onChange={(e) => onFilterChange({ ...filters, material: e.target.value })}
|
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
|
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"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
@@ -131,7 +85,7 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
<select
|
<select
|
||||||
value={filters.color}
|
value={filters.color}
|
||||||
onChange={(e) => onFilterChange({ ...filters, color: e.target.value })}
|
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
|
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"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
@@ -142,62 +96,71 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Storage Condition */}
|
{/* Checkboxes Section */}
|
||||||
<div>
|
<div className="lg:col-span-2 flex items-end">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<div className="flex flex-wrap gap-4 mb-2">
|
||||||
Skladištenje
|
{/* Refill checkbox */}
|
||||||
</label>
|
<label className="flex items-center gap-2 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400">
|
||||||
<select
|
<input
|
||||||
value={filters.storageCondition}
|
type="checkbox"
|
||||||
onChange={(e) => onFilterChange({ ...filters, storageCondition: e.target.value })}
|
checked={filters.isRefill === true}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
onChange={(e) => onFilterChange({
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
...filters,
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
isRefill: e.target.checked ? true : null
|
||||||
>
|
})}
|
||||||
<option value="">Sve lokacije</option>
|
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"
|
||||||
<option value="vacuum">Vakuum</option>
|
/>
|
||||||
<option value="sealed">Zapakovano</option>
|
<span className="text-sm text-gray-700 dark:text-gray-300">Refill</span>
|
||||||
<option value="opened">Otvoreno</option>
|
</label>
|
||||||
<option value="desiccant">Sa sušačem</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Refill Toggle */}
|
{/* Storage checkboxes */}
|
||||||
<div>
|
<label className="flex items-center gap-2 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<input
|
||||||
Tip
|
type="checkbox"
|
||||||
</label>
|
checked={filters.storageCondition === 'vacuum'}
|
||||||
<select
|
onChange={(e) => onFilterChange({
|
||||||
value={filters.isRefill === null ? '' : filters.isRefill ? 'refill' : 'original'}
|
...filters,
|
||||||
onChange={(e) => onFilterChange({
|
storageCondition: e.target.checked ? 'vacuum' : ''
|
||||||
...filters,
|
})}
|
||||||
isRefill: e.target.value === '' ? null : e.target.value === 'refill'
|
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"
|
||||||
})}
|
/>
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
<span className="text-sm text-gray-700 dark:text-gray-300">Vakuum</span>
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
</label>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Clear Filters */}
|
<label className="flex items-center gap-2 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400">
|
||||||
<div className="flex justify-end">
|
<input
|
||||||
<button
|
type="checkbox"
|
||||||
onClick={() => onFilterChange({
|
checked={filters.storageCondition === 'opened'}
|
||||||
brand: '',
|
onChange={(e) => onFilterChange({
|
||||||
material: '',
|
...filters,
|
||||||
storageCondition: '',
|
storageCondition: e.target.checked ? 'opened' : ''
|
||||||
isRefill: null,
|
})}
|
||||||
color: ''
|
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"
|
||||||
})}
|
/>
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
<span className="text-sm text-gray-700 dark:text-gray-300">Otvoreno</span>
|
||||||
>
|
</label>
|
||||||
Obriši sve filtere
|
|
||||||
</button>
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -280,7 +280,16 @@ export const FilamentTable: React.FC<FilamentTableProps> = ({ filaments, loading
|
|||||||
color: textColor
|
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>
|
||||||
<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.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>
|
<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' :
|
const storageCondition = legacy.vakum?.toLowerCase().includes('vakuum') ? 'vacuum' :
|
||||||
legacy.otvoreno?.toLowerCase().includes('otvorena') ? 'opened' : 'sealed';
|
legacy.otvoreno?.toLowerCase().includes('otvorena') ? 'opened' : 'sealed';
|
||||||
|
|
||||||
|
const totalQuantity = parseInt(legacy.kolicina) || 1;
|
||||||
|
const availableQuantity = totalQuantity > 0 ? totalQuantity : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: legacy.id || `legacy-${Math.random().toString(36).substr(2, 9)}`,
|
id: legacy.id || `legacy-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
brand: legacy.brand,
|
brand: legacy.brand,
|
||||||
@@ -49,12 +52,12 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
|||||||
weight: { value: 1000, unit: 'g' as const },
|
weight: { value: 1000, unit: 'g' as const },
|
||||||
diameter: 1.75,
|
diameter: 1.75,
|
||||||
inventory: {
|
inventory: {
|
||||||
total: parseInt(legacy.kolicina) || 1,
|
total: totalQuantity,
|
||||||
available: storageCondition === 'opened' ? 0 : 1,
|
available: availableQuantity,
|
||||||
inUse: 0,
|
inUse: 0,
|
||||||
locations: {
|
locations: {
|
||||||
vacuum: storageCondition === 'vacuum' ? 1 : 0,
|
vacuum: storageCondition === 'vacuum' ? totalQuantity : 0,
|
||||||
opened: storageCondition === 'opened' ? 1 : 0,
|
opened: storageCondition === 'opened' ? totalQuantity : 0,
|
||||||
printer: 0
|
printer: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -83,6 +86,8 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
|||||||
// Filter and sort filaments
|
// Filter and sort filaments
|
||||||
const filteredAndSortedFilaments = useMemo(() => {
|
const filteredAndSortedFilaments = useMemo(() => {
|
||||||
let filtered = normalizedFilaments.filter(filament => {
|
let filtered = normalizedFilaments.filter(filament => {
|
||||||
|
// Only show available filaments
|
||||||
|
if (filament.inventory.available === 0) return false;
|
||||||
// Search filter
|
// Search filter
|
||||||
const searchLower = searchTerm.toLowerCase();
|
const searchLower = searchTerm.toLowerCase();
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
@@ -90,7 +95,7 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
|||||||
filament.material.base.toLowerCase().includes(searchLower) ||
|
filament.material.base.toLowerCase().includes(searchLower) ||
|
||||||
(filament.material.modifier?.toLowerCase().includes(searchLower)) ||
|
(filament.material.modifier?.toLowerCase().includes(searchLower)) ||
|
||||||
filament.color.name.toLowerCase().includes(searchLower) ||
|
filament.color.name.toLowerCase().includes(searchLower) ||
|
||||||
(filament.sku?.toLowerCase().includes(searchLower));
|
false; // SKU removed
|
||||||
|
|
||||||
// Other filters
|
// Other filters
|
||||||
const matchesBrand = !filters.brand || filament.brand === filters.brand;
|
const matchesBrand = !filters.brand || filament.brand === filters.brand;
|
||||||
@@ -138,19 +143,19 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
|||||||
const summary = {
|
const summary = {
|
||||||
totalSpools: 0,
|
totalSpools: 0,
|
||||||
availableSpools: 0,
|
availableSpools: 0,
|
||||||
totalWeight: 0,
|
vacuumCount: 0,
|
||||||
brandsCount: new Set<string>(),
|
openedCount: 0,
|
||||||
lowStock: [] as FilamentV2[]
|
refillCount: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
normalizedFilaments.forEach(f => {
|
normalizedFilaments.forEach(f => {
|
||||||
summary.totalSpools += f.inventory.total;
|
summary.totalSpools += f.inventory.total;
|
||||||
summary.availableSpools += f.inventory.available;
|
summary.availableSpools += f.inventory.available;
|
||||||
summary.totalWeight += f.inventory.total * f.weight.value;
|
summary.vacuumCount += f.inventory.locations.vacuum;
|
||||||
summary.brandsCount.add(f.brand);
|
summary.openedCount += f.inventory.locations.opened;
|
||||||
|
|
||||||
if (f.inventory.available <= 1 && f.inventory.total > 0) {
|
if (f.condition.isRefill) {
|
||||||
summary.lowStock.push(f);
|
summary.refillCount += f.inventory.total;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,9 +173,9 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Inventory Summary */}
|
{/* 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="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 className="text-2xl font-bold text-gray-900 dark:text-white">{inventorySummary.totalSpools}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
<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 className="text-2xl font-bold text-green-600 dark:text-green-400">{inventorySummary.availableSpools}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
<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-sm text-gray-500 dark:text-gray-400">Vakum</div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{(inventorySummary.totalWeight / 1000).toFixed(1)}kg</div>
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{inventorySummary.vacuumCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
<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-sm text-gray-500 dark:text-gray-400">Otvoreno</div>
|
||||||
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">{inventorySummary.lowStock.length}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,7 +200,7 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Pretraži po brendu, materijalu, boji ili SKU..."
|
placeholder="Pretraži po brendu, materijalu, boji..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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"
|
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}
|
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 */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
<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">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||||
<tr>
|
<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">
|
<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
|
Brend
|
||||||
</th>
|
</th>
|
||||||
@@ -226,11 +251,14 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
|||||||
Boja
|
Boja
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<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>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Težina
|
Težina
|
||||||
</th>
|
</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">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</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">
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{filteredAndSortedFilaments.map(filament => (
|
{filteredAndSortedFilaments.map(filament => (
|
||||||
<tr key={filament.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<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">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{filament.brand}
|
{filament.brand}
|
||||||
</td>
|
</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">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
{filament.weight.value}{filament.weight.unit}
|
{filament.weight.value}{filament.weight.unit}
|
||||||
</td>
|
</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">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{filament.condition.isRefill && (
|
{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">
|
<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
|
Refill
|
||||||
</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
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{filament.inventory.available === 1 && (
|
{filament.inventory.available === 1 && (
|
||||||
@@ -293,7 +329,7 @@ export const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments, loa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ export const MaterialBadge: React.FC<MaterialBadgeProps> = ({ base, modifier, cl
|
|||||||
const getModifierIcon = () => {
|
const getModifierIcon = () => {
|
||||||
switch (modifier) {
|
switch (modifier) {
|
||||||
case 'Silk':
|
case 'Silk':
|
||||||
return '✨';
|
return 'S';
|
||||||
case 'Matte':
|
case 'Matte':
|
||||||
return '🔵';
|
return 'M';
|
||||||
case 'Glow':
|
case 'Glow':
|
||||||
return '💡';
|
return 'G';
|
||||||
case 'Wood':
|
case 'Wood':
|
||||||
return '🪵';
|
return 'W';
|
||||||
case 'CF':
|
case 'CF':
|
||||||
return '⚫';
|
return 'CF';
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export const MaterialBadge: React.FC<MaterialBadgeProps> = ({ base, modifier, cl
|
|||||||
</span>
|
</span>
|
||||||
{modifier && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 styling for cross-browser consistency */
|
||||||
.custom-select {
|
.custom-select {
|
||||||
-webkit-appearance: none;
|
/* Remove all native styling */
|
||||||
-moz-appearance: none;
|
-webkit-appearance: none !important;
|
||||||
appearance: none;
|
-moz-appearance: none !important;
|
||||||
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");
|
appearance: none !important;
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 0.5rem center;
|
/* Custom arrow */
|
||||||
background-size: 1.5em 1.5em;
|
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;
|
||||||
padding-right: 2.5rem;
|
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 {
|
.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 */
|
/* Focus styles */
|
||||||
@media not all and (min-resolution:.001dpcm) {
|
.custom-select:focus {
|
||||||
@supports (-webkit-appearance:none) {
|
outline: 2px solid transparent;
|
||||||
.custom-select {
|
outline-offset: 2px;
|
||||||
padding-right: 2.5rem;
|
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;
|
tip: string;
|
||||||
finish: string;
|
finish: string;
|
||||||
boja: string;
|
boja: string;
|
||||||
|
bojaHex?: string;
|
||||||
refill: string;
|
refill: string;
|
||||||
vakum: string;
|
vakum: string;
|
||||||
otvoreno: string;
|
otvoreno: string;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export interface LegacyFields {
|
|||||||
export interface FilamentV2 {
|
export interface FilamentV2 {
|
||||||
// Identifiers
|
// Identifiers
|
||||||
id: string;
|
id: string;
|
||||||
sku?: string;
|
|
||||||
|
|
||||||
// Product Info
|
// Product Info
|
||||||
brand: string;
|
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
|
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" {
|
resource "aws_amplify_app" "filamenteka" {
|
||||||
name = "filamenteka"
|
name = "filamenteka"
|
||||||
repository = var.github_repository
|
repository = var.github_repository
|
||||||
@@ -49,7 +53,7 @@ resource "aws_amplify_app" "filamenteka" {
|
|||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
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
|
# Custom rules for single-page app
|
||||||
|
|||||||
@@ -18,17 +18,28 @@ output "custom_domain_url" {
|
|||||||
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
|
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
output "api_url" {
|
output "rds_endpoint" {
|
||||||
description = "API Gateway URL"
|
value = aws_db_instance.filamenteka.endpoint
|
||||||
value = aws_api_gateway_stage.api.invoke_url
|
description = "RDS instance endpoint"
|
||||||
}
|
}
|
||||||
|
|
||||||
output "dynamodb_table_name" {
|
output "rds_database_name" {
|
||||||
description = "DynamoDB table name"
|
value = aws_db_instance.filamenteka.db_name
|
||||||
value = aws_dynamodb_table.filaments.name
|
description = "Database name"
|
||||||
}
|
}
|
||||||
|
|
||||||
output "api_custom_url" {
|
output "rds_username" {
|
||||||
description = "Custom API URL via Cloudflare"
|
value = aws_db_instance.filamenteka.username
|
||||||
value = var.domain_name != "" && var.cloudflare_api_token != "" ? "https://api.${var.domain_name}" : "Use api_url output instead"
|
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"
|
default = "filamenteka"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "jwt_secret" {
|
variable "admin_password" {
|
||||||
description = "JWT secret for authentication"
|
description = "Admin password for the application"
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "admin_username" {
|
|
||||||
description = "Admin username"
|
|
||||||
type = string
|
|
||||||
default = "admin"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "admin_password_hash" {
|
|
||||||
description = "BCrypt hash of admin password"
|
|
||||||
type = string
|
type = string
|
||||||
|
default = "admin123"
|
||||||
sensitive = true
|
sensitive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +40,9 @@ variable "cloudflare_api_token" {
|
|||||||
default = ""
|
default = ""
|
||||||
sensitive = true
|
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