diff --git a/.forgejo/workflows/docker-build.yml b/.forgejo/workflows/docker-build.yml new file mode 100644 index 0000000..46f554d --- /dev/null +++ b/.forgejo/workflows/docker-build.yml @@ -0,0 +1,44 @@ +name: Build and Push Docker Image + +on: + push: + tags: [v*] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Codeberg Container Registry + uses: docker/login-action@v3 + with: + registry: codeberg.org + username: ${{ secrets.CODEBERG_USERNAME }} + password: ${{ secrets.CODEBERG_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: codeberg.org/lvl0/incr + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..9d46aca --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,84 @@ +# Multi-stage build for Laravel + React application +FROM node:20-alpine AS frontend-builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install Node dependencies +RUN npm ci --only=production + +# Copy frontend source +COPY resources/ resources/ +COPY public/ public/ +COPY vite.config.ts ./ +COPY tsconfig.json ./ +COPY components.json ./ +COPY eslint.config.js ./ + +# Build frontend assets +RUN npm run build + +# PHP runtime stage +FROM php:8.2-fpm-alpine + +# Install system dependencies +RUN apk add --no-cache \ + git \ + curl \ + libpng-dev \ + libxml2-dev \ + zip \ + unzip \ + oniguruma-dev \ + mysql-client + +# Install PHP extensions +RUN docker-php-ext-install \ + pdo_mysql \ + mbstring \ + exif \ + pcntl \ + bcmath \ + gd + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /var/www/html + +# Copy application code first +COPY . . + +# Install PHP dependencies after copying all files +RUN composer install --no-dev --optimize-autoloader --no-interaction + +# Copy built frontend assets from builder stage +COPY --from=frontend-builder /app/public/build/ ./public/build/ + +# Set proper permissions +RUN chown -R www-data:www-data /var/www/html \ + && chmod -R 755 /var/www/html/storage \ + && chmod -R 755 /var/www/html/bootstrap/cache + +# Create a startup script +RUN echo '#!/bin/sh' > /usr/local/bin/start-app \ + && echo 'cp -r /var/www/html/public/. /var/www/html/public_shared/ 2>/dev/null || true' >> /usr/local/bin/start-app \ + && echo 'php artisan config:cache' >> /usr/local/bin/start-app \ + && echo 'php artisan route:cache' >> /usr/local/bin/start-app \ + && echo 'php artisan view:cache' >> /usr/local/bin/start-app \ + && echo 'php artisan migrate --force' >> /usr/local/bin/start-app \ + && echo 'php-fpm' >> /usr/local/bin/start-app \ + && chmod +x /usr/local/bin/start-app + +# Expose port 9000 for PHP-FPM +EXPOSE 9000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD php artisan --version || exit 1 + +# Start the application +CMD ["/usr/local/bin/start-app"] \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..a133062 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,65 @@ +version: '3.8' + +services: + app: + image: codeberg.org/lvl0/incr:v0.1.0-alpha-1 + # build: + # context: ../ + # dockerfile: docker/Dockerfile + container_name: incr-app + restart: unless-stopped + working_dir: /var/www/html + environment: + - APP_ENV=production + - APP_DEBUG=false + - DB_CONNECTION=mysql + - DB_HOST=db + - DB_PORT=3306 + - DB_DATABASE=incr + - DB_USERNAME=incr_user + - DB_PASSWORD=incr_password + volumes: + - ../storage:/var/www/html/storage + - ../public:/var/www/html/public + depends_on: + - db + networks: + - incr-network + + db: + image: mysql:8.0 + container_name: incr-db + restart: unless-stopped + environment: + - MYSQL_DATABASE=incr + - MYSQL_USER=incr_user + - MYSQL_PASSWORD=incr_password + - MYSQL_ROOT_PASSWORD=root_password + volumes: + - db_data:/var/lib/mysql + ports: + - "3306:3306" + networks: + - incr-network + + nginx: + image: nginx:alpine + container_name: incr-nginx + restart: unless-stopped + ports: + - "80:80" + volumes: + - ../public:/var/www/html/public:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - app + networks: + - incr-network + +networks: + incr-network: + driver: bridge + +volumes: + db_data: + driver: local \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..3877706 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + root /var/www/html/public; + index index.php index.html; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.ht { + deny all; + } + + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 912a683..1760079 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -16,6 +16,15 @@ .font-digital { font-family: '7Segment', monospace; } +.glow-red { + box-shadow: 0 0 20px rgba(239, 68, 68, 0.4); + transition: box-shadow 300ms ease; +} + +.glow-red:hover { + box-shadow: 0 0 25px rgba(239, 68, 68, 0.6); +} + @custom-variant dark (&:is(.dark *)); @theme { diff --git a/resources/js/components/Display/InlineForm.tsx b/resources/js/components/Display/InlineForm.tsx index 9d6b693..7dd4ca8 100644 --- a/resources/js/components/Display/InlineForm.tsx +++ b/resources/js/components/Display/InlineForm.tsx @@ -2,7 +2,7 @@ import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm'; import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm'; import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm'; import { cn } from '@/lib/utils'; -import { X } from 'lucide-react'; +import ComponentTitle from '@/components/ui/ComponentTitle'; interface InlineFormProps { type: 'purchase' | 'milestone' | 'price' | null; @@ -13,66 +13,58 @@ interface InlineFormProps { className?: string; } -export default function InlineForm({ - type, - onClose, +export default function InlineForm({ + type, + onClose, onPurchaseSuccess, onMilestoneSuccess, onPriceSuccess, - className + className }: InlineFormProps) { if (!type) return null; const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE'; return ( -
{/* Header */} -
-

- {title} -

- -
+
- {/* Form Content */} -
- {type === 'purchase' ? ( - { - if (onPurchaseSuccess) onPurchaseSuccess(); - onClose(); - }} - /> - ) : type === 'milestone' ? ( - { - if (onMilestoneSuccess) onMilestoneSuccess(); - onClose(); - }} - /> - ) : ( - { - if (onPriceSuccess) onPriceSuccess(); - onClose(); - }} - /> - )} + {/* Form Content */} +
+ {type === 'purchase' ? ( + { + if (onPurchaseSuccess) onPurchaseSuccess(); + onClose(); + }} + onCancel={onClose} + /> + ) : type === 'milestone' ? ( + { + if (onMilestoneSuccess) onMilestoneSuccess(); + onClose(); + }} + onCancel={onClose} + /> + ) : ( + { + if (onPriceSuccess) onPriceSuccess(); + onClose(); + }} + onCancel={onClose} + /> + )} +
); -} \ No newline at end of file +} diff --git a/resources/js/components/Display/ProgressBar.tsx b/resources/js/components/Display/ProgressBar.tsx index 05fdf7e..4343cb5 100644 --- a/resources/js/components/Display/ProgressBar.tsx +++ b/resources/js/components/Display/ProgressBar.tsx @@ -43,7 +43,7 @@ export default function ProgressBar({ {/* Progress Bar Container */}
{/* Old-school progress bar with overlaid text */} -
+
{/* Inner container */}
{/* Progress fill */} diff --git a/resources/js/components/Display/StatsBox.tsx b/resources/js/components/Display/StatsBox.tsx index eafa4aa..4d5a59f 100644 --- a/resources/js/components/Display/StatsBox.tsx +++ b/resources/js/components/Display/StatsBox.tsx @@ -1,6 +1,7 @@ import { cn } from '@/lib/utils'; import { Plus, ChevronRight } from 'lucide-react'; import { useState } from 'react'; +import ComponentTitle from '@/components/ui/ComponentTitle'; interface Milestone { target: number; @@ -69,12 +70,11 @@ export default function StatsBox({ className )} > -
+
{/* STATS Title and Current Price */}
-

- STATS -

+ Stats +
{stats.currentPrice && (
@@ -185,8 +185,8 @@ export default function StatsBox({ key={index} className={cn( isSelectedMilestone - ? "text-red-500 font-bold" - : "bg-red-500 text-black" + ? "bg-red-500 text-black" + : "text-red-500 font-bold" )} > diff --git a/resources/js/components/Milestones/AddMilestoneForm.tsx b/resources/js/components/Milestones/AddMilestoneForm.tsx index 629831f..8433bf0 100644 --- a/resources/js/components/Milestones/AddMilestoneForm.tsx +++ b/resources/js/components/Milestones/AddMilestoneForm.tsx @@ -5,6 +5,7 @@ import InputError from '@/components/InputError'; import { useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler } from 'react'; +import ComponentTitle from '@/components/ui/ComponentTitle'; interface MilestoneFormData { target: string; @@ -14,9 +15,10 @@ interface MilestoneFormData { interface AddMilestoneFormProps { onSuccess?: () => void; + onCancel?: () => void; } -export default function AddMilestoneForm({ onSuccess }: AddMilestoneFormProps) { +export default function AddMilestoneForm({ onSuccess, onCancel }: AddMilestoneFormProps) { const { data, setData, post, processing, errors, reset } = useForm({ target: '', description: '', @@ -36,11 +38,12 @@ export default function AddMilestoneForm({ onSuccess }: AddMilestoneFormProps) { }; return ( -
+
+ ADD MILESTONE
- + setData('target', e.target.value)} - className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" + className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red" />
- + setData('description', e.target.value)} - className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" + className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red" />
- +
+ + {onCancel && ( + + )} +
); -} \ No newline at end of file +} diff --git a/resources/js/components/Pricing/UpdatePriceForm.tsx b/resources/js/components/Pricing/UpdatePriceForm.tsx index bd18cb9..fe99e3b 100644 --- a/resources/js/components/Pricing/UpdatePriceForm.tsx +++ b/resources/js/components/Pricing/UpdatePriceForm.tsx @@ -1,11 +1,11 @@ import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import InputError from '@/components/InputError'; import { useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler } from 'react'; +import ComponentTitle from '@/components/ui/ComponentTitle'; interface PriceUpdateFormData { date: string; @@ -17,9 +17,10 @@ interface UpdatePriceFormProps { currentPrice?: number; className?: string; onSuccess?: () => void; + onCancel?: () => void; } -export default function UpdatePriceForm({ currentPrice, className, onSuccess }: UpdatePriceFormProps) { +export default function UpdatePriceForm({ currentPrice, className, onSuccess, onCancel }: UpdatePriceFormProps) { const { data, setData, post, processing, errors } = useForm({ date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format price: currentPrice?.toString() || '', @@ -38,31 +39,30 @@ export default function UpdatePriceForm({ currentPrice, className, onSuccess }: }; return ( - - - Update Asset Price +
+
+ UPDATE PRICE {currentPrice && ( -

- Current price: €{currentPrice.toFixed(4)} +

+ [CURRENT] €{currentPrice.toFixed(4)}

)} - -
- + setData('date', e.target.value)} max={new Date().toISOString().split('T')[0]} + className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none transition-all glow-red" />
- + setData('price', e.target.value)} + className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all" /> -

- Price per unit/share of the asset +

+ [UNIT] price per share

- +
+ + {onCancel && ( + + )} +
-
- +
+
); } diff --git a/resources/js/components/Transactions/AddPurchaseForm.tsx b/resources/js/components/Transactions/AddPurchaseForm.tsx index 68732b2..c360177 100644 --- a/resources/js/components/Transactions/AddPurchaseForm.tsx +++ b/resources/js/components/Transactions/AddPurchaseForm.tsx @@ -5,6 +5,7 @@ import InputError from '@/components/InputError'; import { useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler, useEffect } from 'react'; +import ComponentTitle from '@/components/ui/ComponentTitle'; interface PurchaseFormData { date: string; @@ -16,9 +17,10 @@ interface PurchaseFormData { interface AddPurchaseFormProps { onSuccess?: () => void; + onCancel?: () => void; } -export default function AddPurchaseForm({ onSuccess }: AddPurchaseFormProps) { +export default function AddPurchaseForm({ onSuccess, onCancel }: AddPurchaseFormProps) { const { data, setData, post, processing, errors, reset } = useForm({ date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format shares: '', @@ -31,7 +33,7 @@ export default function AddPurchaseForm({ onSuccess }: AddPurchaseFormProps) { if (data.shares && data.price_per_share) { const shares = parseFloat(data.shares); const pricePerShare = parseFloat(data.price_per_share); - + if (!isNaN(shares) && !isNaN(pricePerShare)) { const totalCost = (shares * pricePerShare).toFixed(2); setData('total_cost', totalCost); @@ -54,24 +56,25 @@ export default function AddPurchaseForm({ onSuccess }: AddPurchaseFormProps) { }; return ( -
+
+ ADD PURCHASE
- + setData('date', e.target.value)} max={new Date().toISOString().split('T')[0]} - className="bg-black border-red-500/30 text-red-400 focus:border-red-400" + className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none transition-all glow-red" />
- + setData('shares', e.target.value)} - className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" + className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red" />
- + setData('price_per_share', e.target.value)} - className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" + className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red" />
- + setData('total_cost', e.target.value)} - className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" + className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red" /> -

- Auto-calculated from shares × price +

+ [AUTO-CALC] shares × price

- +
+ + {onCancel && ( + + )} +
); -} \ No newline at end of file +} diff --git a/resources/js/components/ui/ComponentTitle.tsx b/resources/js/components/ui/ComponentTitle.tsx new file mode 100644 index 0000000..7c39a62 --- /dev/null +++ b/resources/js/components/ui/ComponentTitle.tsx @@ -0,0 +1,15 @@ +import { FC, ReactNode } from 'react'; + +interface ComponentTitleProps { + children: ReactNode; +} + +const ComponentTitle: FC = ({ children }) => { + return ( +

+ { children } +

+ ) +} + +export default ComponentTitle diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 6753014..23c7449 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -27,11 +27,11 @@ export default function Dashboard() { total_investment: 0, average_cost_per_share: 0, }); - + const [priceData, setPriceData] = useState({ current_price: null, }); - + const [milestones, setMilestones] = useState([]); const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0); const [showProgressBar, setShowProgressBar] = useState(false); @@ -121,14 +121,14 @@ export default function Dashboard() { // Calculate portfolio stats - const currentValue = priceData.current_price - ? purchaseData.total_shares * priceData.current_price + const currentValue = priceData.current_price + ? purchaseData.total_shares * priceData.current_price : undefined; - - const profitLoss = currentValue - ? currentValue - purchaseData.total_investment + + const profitLoss = currentValue + ? currentValue - purchaseData.total_investment : undefined; - + const profitLossPercentage = profitLoss && purchaseData.total_investment > 0 ? (profitLoss / purchaseData.total_investment) * 100 : undefined; @@ -168,36 +168,37 @@ export default function Dashboard() { const handleProgressClick = () => { setShowStatsBox(!showStatsBox); + setActiveForm(null) }; return ( <> - + {/* Stacked Layout */}
{/* Box 1: LED Number Display - Fixed position from top */}
-
- + {/* Box 2: Progress Bar (toggleable) */} -
- +
- + {/* Box 3: Stats Box (toggleable) */} -
- + setActiveForm('price')} />
- + {/* Box 4: Forms (only when active form is set) */} -
+
setActiveForm(null)}