Initial Filamenteka setup - Bambu Lab filament tracker

- React + TypeScript frontend with automatic color coding
- Confluence API integration for data sync
- AWS Amplify deployment with Terraform
- Support for all Bambu Lab filament colors including gradients
This commit is contained in:
DaX
2025-06-17 22:39:35 +02:00
parent 8cc137864b
commit c394d94bb0
23 changed files with 1090 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Testing
coverage/
# Production
dist/
build/
# Misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Editor directories and files
.idea
.vscode
*.swp
*.swo
*~
# Terraform
terraform/.terraform/
terraform/*.tfstate
terraform/*.tfstate.*
terraform/*.tfvars
terraform/.terraform.lock.hcl
terraform/crash.log
terraform/crash.*.log
terraform/*.tfplan
terraform/override.tf
terraform/override.tf.json
terraform/*_override.tf
terraform/*_override.tf.json

153
README.md
View File

@@ -1,2 +1,155 @@
# Filamenteka
A web application for tracking Bambu Lab filament inventory with automatic color coding, synced from Confluence documentation.
## Features
- 🎨 **Automatic Color Coding** - Table rows are automatically colored based on filament colors
- 🔄 **Confluence Sync** - Pulls filament data from Confluence table every 5 minutes
- 🔍 **Search & Filter** - Quick search across all filament properties
- 📊 **Sortable Columns** - Click headers to sort by any column
- 🌈 **Gradient Support** - Special handling for gradient filaments like Cotton Candy Cloud
- 📱 **Responsive Design** - Works on desktop and mobile devices
## Technology Stack
- **Frontend**: React + TypeScript + Tailwind CSS
- **Backend**: API routes for Confluence integration
- **Infrastructure**: AWS Amplify (Frankfurt region)
- **IaC**: Terraform
## Prerequisites
- Node.js 18+ and npm
- AWS Account
- Terraform 1.0+
- GitHub account
- Confluence account with API access
## Setup Instructions
### 1. Clone the Repository
```bash
git clone https://github.com/yourusername/filamenteka.git
cd filamenteka
```
### 2. Install Dependencies
```bash
npm install
```
### 3. Configure Confluence Access
Create a Confluence API token:
1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
2. Create a new API token
3. Note your Confluence domain and the page ID containing your filament table
### 4. Deploy with Terraform
```bash
cd terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your values
terraform init
terraform plan
terraform apply
```
### 5. Environment Variables
The following environment variables are needed:
- `CONFLUENCE_API_URL` - Your Confluence instance URL (e.g., https://your-domain.atlassian.net)
- `CONFLUENCE_TOKEN` - Your Confluence API token
- `CONFLUENCE_PAGE_ID` - The ID of the Confluence page containing the filament table
## Local Development
```bash
# Create .env file for local development
cat > .env << EOF
CONFLUENCE_API_URL=https://your-domain.atlassian.net
CONFLUENCE_TOKEN=your_api_token
CONFLUENCE_PAGE_ID=your_page_id
EOF
# Run development server
npm run dev
```
Visit http://localhost:5173 to see the app.
## Table Format
Your Confluence table should have these columns:
- **Brand** - Manufacturer (e.g., BambuLab)
- **Tip** - Material type (e.g., PLA, PETG, ABS)
- **Finish** - Finish type (e.g., Basic, Matte, Silk)
- **Boja** - Color name (e.g., Mistletoe Green, Hot Pink)
- **Refill** - Whether it's a refill spool
- **Vakum** - Vacuum sealed status
- **Otvoreno** - Opened status
- **Količina** - Quantity
- **Cena** - Price
## Color Mapping
The app includes mappings for common Bambu Lab colors:
- Basic colors: Red, Blue, Green, Yellow, etc.
- Special colors: Mistletoe Green, Indigo Purple, Hot Pink, etc.
- Gradient filaments: Cotton Candy Cloud
- Matte finishes: Scarlet Red, Marine Blue, etc.
Unknown colors default to light gray.
## Deployment
Push to the main branch to trigger automatic deployment:
```bash
git add .
git commit -m "Update filament colors"
git push origin main
```
Amplify will automatically build and deploy your changes.
## Adding New Colors
To add new color mappings, edit `src/data/bambuLabColors.ts`:
```typescript
export const bambuLabColors: Record<string, ColorMapping> = {
// ... existing colors
'New Color Name': { hex: '#HEXCODE' },
};
```
## Troubleshooting
### Confluence Connection Issues
- Verify your API token is valid
- Check the page ID is correct
- Ensure your Confluence user has read access to the page
### Color Not Showing
- Check if the color name in Confluence matches exactly
- Add the color mapping to `bambuLabColors.ts`
- Colors are case-insensitive but spelling must match
## License
MIT
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request

16
amplify.yml Normal file
View File

@@ -0,0 +1,16 @@
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci
build:
commands:
- npm run build
artifacts:
baseDirectory: dist
files:
- '**/*'
cache:
paths:
- node_modules/**/*

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Filamenteka - Bambu Lab Filament Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "filamenteka",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev:server": "node server.js",
"dev:all": "npm run dev:server & npm run dev",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.2",
"express": "^4.18.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.0",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

30
server.js Normal file
View File

@@ -0,0 +1,30 @@
import express from 'express';
import { handler } from './src/pages/api/filaments.js';
const app = express();
const port = 3000;
app.get('/api/filaments', async (req, res) => {
const event = {
httpMethod: 'GET',
headers: req.headers,
queryStringParameters: req.query
};
try {
const response = await handler(event);
res.status(response.statusCode);
Object.entries(response.headers || {}).forEach(([key, value]) => {
res.setHeader(key, value);
});
res.send(response.body);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(port, () => {
console.log(`API server running at http://localhost:${port}`);
});

84
src/App.tsx Normal file
View File

@@ -0,0 +1,84 @@
import React, { useState, useEffect } from 'react';
import { FilamentTable } from './components/FilamentTable';
import { Filament } from './types/filament';
import axios from 'axios';
function App() {
const [filaments, setFilaments] = useState<Filament[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const fetchFilaments = async () => {
try {
setLoading(true);
setError(null);
const response = await axios.get('/api/filaments');
setFilaments(response.data);
setLastUpdate(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch filaments');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFilaments();
// Refresh every 5 minutes
const interval = setInterval(fetchFilaments, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white 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">
Filamenteka
</h1>
<div className="flex items-center gap-4">
{lastUpdate && (
<span className="text-sm text-gray-500">
Last updated: {lastUpdate.toLocaleTimeString()}
</span>
)}
<button
onClick={fetchFilaments}
disabled={loading}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
<p className="mt-2 text-gray-600">
Bambu Lab filament inventory tracker synced with Confluence
</p>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<FilamentTable
filaments={filaments}
loading={loading}
error={error || undefined}
/>
</main>
<footer className="bg-white border-t mt-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<p className="text-center text-sm text-gray-500">
Filamenteka - Automatically color-coded filament tracking
</p>
</div>
</footer>
</div>
);
}
export default App;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { getFilamentColor, getColorStyle, getContrastColor } from '../data/bambuLabColors';
interface ColorCellProps {
colorName: string;
}
export const ColorCell: React.FC<ColorCellProps> = ({ colorName }) => {
const colorMapping = getFilamentColor(colorName);
const style = getColorStyle(colorMapping);
const textColor = Array.isArray(colorMapping.hex)
? '#000000'
: getContrastColor(colorMapping.hex);
return (
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded border border-gray-300"
style={style}
title={Array.isArray(colorMapping.hex) ? colorMapping.hex.join(' - ') : colorMapping.hex}
/>
<span style={{ color: textColor }}>{colorName}</span>
</div>
);
};

View File

@@ -0,0 +1,147 @@
import React, { useState, useMemo } from 'react';
import { Filament } from '../types/filament';
import { ColorCell } from './ColorCell';
import { getFilamentColor, getColorStyle } from '../data/bambuLabColors';
interface FilamentTableProps {
filaments: Filament[];
loading?: boolean;
error?: string;
}
export const FilamentTable: React.FC<FilamentTableProps> = ({ filaments, loading, error }) => {
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState<keyof Filament>('boja');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const filteredAndSortedFilaments = useMemo(() => {
let filtered = filaments.filter(filament =>
Object.values(filament).some(value =>
value.toLowerCase().includes(searchTerm.toLowerCase())
)
);
filtered.sort((a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
if (sortOrder === 'asc') {
return aValue.localeCompare(bValue);
} else {
return bValue.localeCompare(aValue);
}
});
return filtered;
}, [filaments, searchTerm, sortField, sortOrder]);
const handleSort = (field: keyof Filament) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
Error: {error}
</div>
);
}
return (
<div className="w-full">
<div className="mb-4">
<input
type="text"
placeholder="Search filaments..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-300">
<thead>
<tr className="bg-gray-100">
<th
className="px-4 py-2 border-b cursor-pointer hover:bg-gray-200"
onClick={() => handleSort('brand')}
>
Brand {sortField === 'brand' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th
className="px-4 py-2 border-b cursor-pointer hover:bg-gray-200"
onClick={() => handleSort('tip')}
>
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th
className="px-4 py-2 border-b cursor-pointer hover:bg-gray-200"
onClick={() => handleSort('finish')}
>
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th
className="px-4 py-2 border-b cursor-pointer hover:bg-gray-200"
onClick={() => handleSort('boja')}
>
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th className="px-4 py-2 border-b">Refill</th>
<th className="px-4 py-2 border-b">Vakum</th>
<th className="px-4 py-2 border-b">Otvoreno</th>
<th className="px-4 py-2 border-b">Količina</th>
<th className="px-4 py-2 border-b">Cena</th>
</tr>
</thead>
<tbody>
{filteredAndSortedFilaments.map((filament, index) => {
const colorMapping = getFilamentColor(filament.boja);
const rowStyle = getColorStyle(colorMapping);
return (
<tr
key={index}
className="hover:opacity-90 transition-opacity"
style={{
...rowStyle,
opacity: 0.8
}}
>
<td className="px-4 py-2 border-b">{filament.brand}</td>
<td className="px-4 py-2 border-b">{filament.tip}</td>
<td className="px-4 py-2 border-b">{filament.finish}</td>
<td className="px-4 py-2 border-b">
<ColorCell colorName={filament.boja} />
</td>
<td className="px-4 py-2 border-b">{filament.refill}</td>
<td className="px-4 py-2 border-b">{filament.vakum}</td>
<td className="px-4 py-2 border-b">{filament.otvoreno}</td>
<td className="px-4 py-2 border-b">{filament.kolicina}</td>
<td className="px-4 py-2 border-b">{filament.cena}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-4 text-sm text-gray-600">
Showing {filteredAndSortedFilaments.length} of {filaments.length} filaments
</div>
</div>
);
};

101
src/data/bambuLabColors.ts Normal file
View File

@@ -0,0 +1,101 @@
export interface ColorMapping {
hex: string | string[];
isGradient?: boolean;
}
export const bambuLabColors: Record<string, ColorMapping> = {
// PLA Basic Colors
'Mistletoe Green': { hex: '#4F6359' },
'Indigo Purple': { hex: '#482960' },
'Black': { hex: '#000000' },
'Jade White': { hex: '#F5F5F5' },
'Gray': { hex: '#8C9091' },
'Grey': { hex: '#8C9091' },
'Red': { hex: '#C33F45' },
'Hot Pink': { hex: '#F5547C' },
'Cocoa Brown': { hex: '#6F5034' },
'White': { hex: '#FFFFFF' },
'Cotton Candy Cloud': { hex: ['#E7C1D5', '#8EC9E9'], isGradient: true },
'Sunflower Yellow': { hex: '#FEC600' },
'Yellow': { hex: '#FFD700' },
'Magenta': { hex: '#FF00FF' },
'Beige': { hex: '#F5DEB3' },
'Cyan': { hex: '#00FFFF' },
// PLA Matte Colors
'Scarlet Red': { hex: '#FF2400' },
'Mandarin Orange': { hex: '#FF8C00' },
'Marine Blue': { hex: '#000080' },
'Charcoal': { hex: '#36454F' },
'Ivory White': { hex: '#FFFFF0' },
// Additional colors from filamentcolors.xyz
'Orange': { hex: '#FF7146' },
'Blue': { hex: '#4F9CCC' },
'Green': { hex: '#4F6359' },
'Dark Green': { hex: '#656A4D' },
'Alpine Green': { hex: '#4F6359' },
'Dark Gray': { hex: '#616364' },
'Dark Grey': { hex: '#616364' },
'Blue Gray': { hex: '#647988' },
'Blue Grey': { hex: '#647988' },
'Translucent Orange': { hex: '#EF8E5B' },
// Default fallback
'Unknown': { hex: '#CCCCCC' }
};
export function getFilamentColor(colorName: string): ColorMapping {
// First try exact match
if (bambuLabColors[colorName]) {
return bambuLabColors[colorName];
}
// Try case-insensitive match
const lowerColorName = colorName.toLowerCase();
const match = Object.keys(bambuLabColors).find(
key => key.toLowerCase() === lowerColorName
);
if (match) {
return bambuLabColors[match];
}
// Try partial match (e.g., "PLA Red" matches "Red")
const partialMatch = Object.keys(bambuLabColors).find(
key => colorName.includes(key) || key.includes(colorName)
);
if (partialMatch) {
return bambuLabColors[partialMatch];
}
// Return default unknown color
return bambuLabColors['Unknown'];
}
export function getColorStyle(colorMapping: ColorMapping): React.CSSProperties {
if (colorMapping.isGradient && Array.isArray(colorMapping.hex)) {
return {
background: `linear-gradient(90deg, ${colorMapping.hex[0]} 0%, ${colorMapping.hex[1]} 100%)`
};
}
return {
backgroundColor: Array.isArray(colorMapping.hex) ? colorMapping.hex[0] : colorMapping.hex
};
}
export function getContrastColor(hexColor: string): string {
// Convert hex to RGB
const hex = hexColor.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
// Calculate relative luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Return black or white based on luminance
return luminance > 0.5 ? '#000000' : '#FFFFFF';
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

163
src/pages/api/filaments.ts Normal file
View File

@@ -0,0 +1,163 @@
import axios from 'axios';
import type { Filament } from '../../types/filament';
// Mock data for development - replace with actual Confluence API integration
const mockFilaments: Filament[] = [
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Mistletoe Green", refill: "", vakum: "vakuum x1", otvoreno: "otvorena x1", kolicina: "2", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Indigo Purple", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Black", refill: "", vakum: "", otvoreno: "2x otvorena", kolicina: "2", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Black", refill: "Da", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Jade White", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Gray", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Red", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Hot Pink", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Cocoa Brown", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "White", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Cotton Candy Cloud", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Sunflower Yellow", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Yellow", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Magenta", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Beige", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Basic", boja: "Cyan", refill: "", vakum: "vakuum", otvoreno: "", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Scarlet Red", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Mandarin Orange", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Marine Blue", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Charcoal", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" },
{ brand: "BambuLab", tip: "PLA", finish: "Matte", boja: "Ivory White", refill: "", vakum: "", otvoreno: "otvorena", kolicina: "", cena: "" }
];
interface ConfluencePageContent {
body: {
storage?: {
value: string;
};
view?: {
value: string;
};
};
}
async function fetchFromConfluence(): Promise<Filament[]> {
const confluenceUrl = process.env.CONFLUENCE_API_URL;
const confluenceToken = process.env.CONFLUENCE_TOKEN;
const pageId = process.env.CONFLUENCE_PAGE_ID;
if (!confluenceUrl || !confluenceToken || !pageId) {
console.warn('Confluence configuration missing, using mock data');
return mockFilaments;
}
try {
const response = await axios.get<ConfluencePageContent>(
`${confluenceUrl}/rest/api/content/${pageId}?expand=body.storage`,
{
headers: {
'Authorization': `Bearer ${confluenceToken}`,
'Accept': 'application/json'
}
}
);
const htmlContent = response.data.body.storage?.value || '';
return parseConfluenceTable(htmlContent);
} catch (error) {
console.error('Failed to fetch from Confluence:', error);
return mockFilaments;
}
}
function parseConfluenceTable(html: string): Filament[] {
// Simple HTML table parser - in production, use a proper HTML parser like cheerio
const filaments: Filament[] = [];
// Extract table rows using regex (simplified for example)
const tableMatch = html.match(/<table[^>]*>([\s\S]*?)<\/table>/);
if (!tableMatch) return mockFilaments;
const rowMatches = tableMatch[1].matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g);
let isHeaderRow = true;
for (const rowMatch of rowMatches) {
if (isHeaderRow) {
isHeaderRow = false;
continue;
}
const cellMatches = [...rowMatch[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)];
if (cellMatches.length >= 9) {
filaments.push({
brand: stripHtml(cellMatches[0][1]),
tip: stripHtml(cellMatches[1][1]),
finish: stripHtml(cellMatches[2][1]),
boja: stripHtml(cellMatches[3][1]),
refill: stripHtml(cellMatches[4][1]),
vakum: stripHtml(cellMatches[5][1]),
otvoreno: stripHtml(cellMatches[6][1]),
kolicina: stripHtml(cellMatches[7][1]),
cena: stripHtml(cellMatches[8][1])
});
}
}
return filaments.length > 0 ? filaments : mockFilaments;
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim();
}
export async function handler(event: any) {
// For AWS Amplify
if (event.httpMethod !== 'GET') {
return {
statusCode: 405,
body: JSON.stringify({ error: 'Method not allowed' })
};
}
try {
const filaments = await fetchFromConfluence();
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=300' // 5 minutes cache
},
body: JSON.stringify(filaments)
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
error: 'Failed to fetch filaments',
message: error instanceof Error ? error.message : 'Unknown error'
})
};
}
}
// For local development with Vite
export default async function(req: Request): Promise<Response> {
if (req.method !== 'GET') {
return new Response('Method not allowed', { status: 405 });
}
try {
const filaments = await fetchFromConfluence();
return new Response(JSON.stringify(filaments), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=300'
}
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Failed to fetch filaments',
message: error instanceof Error ? error.message : 'Unknown error'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}

3
src/styles/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

11
src/types/filament.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface Filament {
brand: string;
tip: string;
finish: string;
boja: string;
refill: string;
vakum: string;
otvoreno: string;
kolicina: string;
cena: string;
}

11
tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

117
terraform/main.tf Normal file
View File

@@ -0,0 +1,117 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = "eu-central-1" # Frankfurt
}
resource "aws_amplify_app" "filamenteka" {
name = "filamenteka"
repository = var.github_repository
# GitHub access token for private repos
access_token = var.github_token
# Build settings
build_spec = <<-EOT
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci
build:
commands:
- npm run build
artifacts:
baseDirectory: dist
files:
- '**/*'
cache:
paths:
- node_modules/**/*
EOT
# Environment variables
environment_variables = {
CONFLUENCE_API_URL = var.confluence_api_url
CONFLUENCE_TOKEN = var.confluence_token
CONFLUENCE_PAGE_ID = var.confluence_page_id
}
# Custom rules for single-page app
custom_rule {
source = "/<*>"
status = "404"
target = "/index.html"
}
# Enable branch auto build
enable_branch_auto_build = true
tags = {
Name = "Filamenteka"
Environment = var.environment
}
}
# Main branch
resource "aws_amplify_branch" "main" {
app_id = aws_amplify_app.filamenteka.id
branch_name = "main"
# Enable auto build
enable_auto_build = true
# Environment variables specific to this branch (optional)
environment_variables = {}
stage = "PRODUCTION"
tags = {
Name = "Filamenteka-main"
Environment = var.environment
}
}
# Development branch (optional)
resource "aws_amplify_branch" "dev" {
app_id = aws_amplify_app.filamenteka.id
branch_name = "dev"
enable_auto_build = true
stage = "DEVELOPMENT"
tags = {
Name = "Filamenteka-dev"
Environment = "development"
}
}
# Custom domain (optional)
resource "aws_amplify_domain_association" "filamenteka" {
count = var.domain_name != "" ? 1 : 0
app_id = aws_amplify_app.filamenteka.id
domain_name = var.domain_name
# Map main branch to root domain
sub_domain {
branch_name = aws_amplify_branch.main.branch_name
prefix = ""
}
# Map dev branch to dev subdomain
sub_domain {
branch_name = aws_amplify_branch.dev.branch_name
prefix = "dev"
}
}

25
terraform/outputs.tf Normal file
View File

@@ -0,0 +1,25 @@
output "app_id" {
description = "The ID of the Amplify app"
value = aws_amplify_app.filamenteka.id
}
output "app_url" {
description = "The default URL of the Amplify app"
value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
}
output "dev_url" {
description = "The development branch URL"
value = "https://dev.${aws_amplify_app.filamenteka.default_domain}"
}
output "custom_domain_url" {
description = "The custom domain URL (if configured)"
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
}
output "github_webhook_url" {
description = "The webhook URL for GitHub"
value = aws_amplify_app.filamenteka.production_branch[0].webhook_url
sensitive = true
}

View File

@@ -0,0 +1,11 @@
# Copy this file to terraform.tfvars and fill in your values
github_repository = "https://github.com/yourusername/filamenteka"
github_token = "ghp_your_github_token_here"
confluence_api_url = "https://your-domain.atlassian.net"
confluence_token = "your_confluence_api_token"
confluence_page_id = "your_confluence_page_id"
# Optional: Custom domain
# domain_name = "filamenteka.yourdomain.com"

38
terraform/variables.tf Normal file
View File

@@ -0,0 +1,38 @@
variable "github_repository" {
description = "GitHub repository URL"
type = string
}
variable "github_token" {
description = "GitHub personal access token for Amplify"
type = string
sensitive = true
}
variable "confluence_api_url" {
description = "Confluence API base URL"
type = string
}
variable "confluence_token" {
description = "Confluence API token"
type = string
sensitive = true
}
variable "confluence_page_id" {
description = "Confluence page ID containing the filament table"
type = string
}
variable "domain_name" {
description = "Custom domain name (optional)"
type = string
default = ""
}
variable "environment" {
description = "Environment name"
type = string
default = "production"
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})