Remove decorative icons and update CORS configuration

This commit is contained in:
DaX
2025-06-20 13:05:36 +02:00
parent 18110ab159
commit 62a4891112
51 changed files with 4284 additions and 2385 deletions

101
DEPLOYMENT.md Normal file
View 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

View 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');
});
});

View 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"');
});
});

View File

@@ -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');

View 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
View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
.env
.env.example
README.md
.git
.gitignore

14
api/.env.example Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
api/package.json Normal file
View 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
View 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
View 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
View 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"
}
}

View File

@@ -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>
);
}

View File

@@ -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>
) )
} }

View File

@@ -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
View 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>
);
}

View 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>
);
}

View File

@@ -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
View 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;

View File

@@ -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' });
};

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}

View File

@@ -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' });
};

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}

View 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>
);
};

View File

@@ -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,63 +96,72 @@ 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"
> <label className="flex items-center gap-2 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400">
<option value="">Svi tipovi</option> <input
<option value="original">Originalno pakovanje</option> type="checkbox"
<option value="refill">Punjenje</option> checked={filters.storageCondition === 'opened'}
</select> onChange={(e) => onFilterChange({
...filters,
storageCondition: e.target.checked ? 'opened' : ''
})}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Otvoreno</span>
</label>
{/* Reset button - only show when filters are active */}
{hasActiveFilters && (
<button
onClick={() => onFilterChange({
brand: '',
material: '',
storageCondition: '',
isRefill: null,
color: ''
})}
className="flex items-center gap-1.5 ml-6 px-3 py-1.5 text-sm font-medium text-white bg-red-500 dark:bg-red-600 hover:bg-red-600 dark:hover:bg-red-700 rounded-md transition-all duration-200 transform hover:scale-105 shadow-sm hover:shadow-md"
title="Reset sve filtere"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Reset</span>
</button>
)}
</div>
</div> </div>
</div> </div>
{/* Clear Filters */}
<div className="flex justify-end">
<button
onClick={() => onFilterChange({
brand: '',
material: '',
storageCondition: '',
isRefill: null,
color: ''
})}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Obriši sve filtere
</button>
</div>
</div> </div>
); );
}; };

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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
View 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;

View File

@@ -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;
}
}

View File

@@ -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;
} }
} }

View File

@@ -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;

View File

@@ -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
View 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"
}

View File

@@ -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
}

View 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"
}

View File

@@ -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"
}

View File

@@ -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
View 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
View 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"
}
}]
})
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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
View 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
View 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

View File

@@ -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
} }
@@ -50,4 +39,10 @@ variable "cloudflare_api_token" {
type = string type = string
default = "" default = ""
sensitive = true sensitive = true
}
variable "aws_region" {
description = "AWS region"
type = string
default = "eu-central-1"
} }

58
terraform/vpc.tf Normal file
View 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"
}