diff --git a/.gitignore b/.gitignore index 3dbafaf..90d5441 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,6 @@ htmlcov/ *.temp .cache/ -celerybeat-schedule \ No newline at end of file +celerybeat-schedule + +backend/app/static \ No newline at end of file diff --git a/Makefile b/Makefile index 1840953..2cd52fa 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,15 @@ .PHONY: help install dev-services dev-stop-services dev-backend dev-frontend dev build test lint clean up down restart logs celery-worker celery-beat celery-flower celery-shell +IMAGE_NAME = my-flask-react-app + +# Git Remotes +ORIGIN_REMOTE = origin +DEPLOY_REMOTE = deploy + +# Get current git commit hash +GIT_HASH = $(shell git rev-parse --short HEAD) +GIT_BRANCH = $(shell git rev-parse --abbrev-ref HEAD) + help: ## Show this help message @echo "Available commands:" @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' @@ -52,12 +62,6 @@ restart: ## Restart all services logs: ## Show logs from all services docker compose logs -f -logs-backend: ## Show backend logs - docker compose logs -f backend - -logs-frontend: ## Show frontend logs - docker compose logs -f frontend - test: ## Run all tests @echo "Running backend tests..." cd backend && . venv/bin/activate && pytest @@ -170,3 +174,19 @@ logs-celery-beat: ## Show Celery Beat logs logs-flower: ## Show Flower logs docker compose logs -f flower + +docker-build: ## Show Flower logs + docker build -f docker/Dockerfile -t $(IMAGE_NAME):$(GIT_HASH) -t $(IMAGE_NAME):latest . + +docker-run: ## Show Flower logs + docker run -p 8001:8000 $(IMAGE_NAME):latest + +# Push to deploy remote (triggers deployment) +push-deploy: + @echo "Pushing to deploy remote ($(GIT_BRANCH))..." + git push $(DEPLOY_REMOTE) $(GIT_BRANCH) + +# Push to deploy remote (triggers deployment) +push-deploy-force: + @echo "Pushing to deploy remote ($(GIT_BRANCH))..." + git push $(DEPLOY_REMOTE) $(GIT_BRANCH) --force-with-lease \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index fc87c4f..7b4fc6c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -20,11 +20,11 @@ RUN useradd -m appuser && chown -R appuser:appuser /app USER appuser # Expose port -EXPOSE 5000 +EXPOSE 8000 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5000/health/ || exit 1 + CMD curl -f http://localhost:8000/health/ || exit 1 # Run with gunicorn -CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "wsgi:app"] \ No newline at end of file +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "wsgi:app"] \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 4c4f203..3c726d0 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,4 @@ -import json +# import json import os from dotenv import load_dotenv @@ -28,10 +28,10 @@ def create_app(config_name=None): app.config.from_object(config_by_name[config_name]) - print("----------------------------------------------------------") - print(f"------------------ENVIRONMENT: {config_name}-----------------------") - print(json.dumps(dict(app.config), indent=2, default=str)) - print("----------------------------------------------------------") + # print("----------------------------------------------------------") + # print(f"------------------ENVIRONMENT: {config_name}-----------------------") + # print(json.dumps(dict(app.config), indent=2, default=str)) + # print("----------------------------------------------------------") # Initialize extensions with app db.init_app(app) migrate.init_app(app, db) @@ -48,11 +48,12 @@ def create_app(config_name=None): # Import models (required for migrations) # Register blueprints - from app.routes import api_bp, health_bp + from app.routes import api_bp, health_bp, home_bp from app.routes.kanban import kanban_bp app.register_blueprint(api_bp, url_prefix="/api") - app.register_blueprint(health_bp) + app.register_blueprint(health_bp, url_prefix="/health") + app.register_blueprint(home_bp) app.register_blueprint(kanban_bp, url_prefix="/api") # Global error handlers diff --git a/backend/app/config.py b/backend/app/config.py index 8984c09..7eb3744 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -51,7 +51,7 @@ class TestingConfig(Config): """Testing configuration""" TESTING = True - SQLALCHEMY_DATABASE_URI = os.environ["TEST_DATABASE_URL"] + SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") WTF_CSRF_ENABLED = False # Conservative connection pool settings for testing diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py index 439d3ac..b305b47 100644 --- a/backend/app/routes/__init__.py +++ b/backend/app/routes/__init__.py @@ -1,4 +1,5 @@ from .api import api_bp from .health import health_bp +from .home import home_bp -__all__ = ["api_bp", "health_bp"] +__all__ = ["api_bp", "health_bp", "home_bp"] diff --git a/backend/app/routes/home.py b/backend/app/routes/home.py new file mode 100644 index 0000000..04789f8 --- /dev/null +++ b/backend/app/routes/home.py @@ -0,0 +1,19 @@ +import os + +from flask import Blueprint +from flask import current_app as app +from flask import send_from_directory + +home_bp = Blueprint("home", __name__) + + +@home_bp.route("/") +def serve_root(): + return send_from_directory(app.static_folder, "index.html") + + +@home_bp.route("/") +def serve_spa(path): + if os.path.exists(os.path.join(app.static_folder, path)): + return send_from_directory(app.static_folder, path) + return send_from_directory(app.static_folder, "index.html") diff --git a/backend/app/services/card_position_service.py b/backend/app/services/card_position_service.py index 13eebf2..c9d60b1 100644 --- a/backend/app/services/card_position_service.py +++ b/backend/app/services/card_position_service.py @@ -78,7 +78,7 @@ class CardPositionService: for index, card in enumerate(dest_cards): if card is None: # This is where our moved card should go - moved_card = Card.query.get(moved_card_id) + moved_card = db.session.get(Card, moved_card_id) if moved_card: moved_card.pos = float(index) else: diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index af83bc9..598079c 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -11,3 +11,4 @@ faker==20.1.0 # Celery monitoring flower==2.0.1 +gunicorn==21.2.0 \ No newline at end of file diff --git a/backend/wsgi.py b/backend/wsgi.py index f4150a8..489fcd0 100644 --- a/backend/wsgi.py +++ b/backend/wsgi.py @@ -5,4 +5,4 @@ env = os.environ.get('FLASK_ENV', 'dev') app = create_app(env) if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000) \ No newline at end of file + app.run(host='0.0.0.0', port=8000) \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..fa2d5fb --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,50 @@ +# --------------------------------------------------------- +# Stage 1: Build the React Frontend +# --------------------------------------------------------- +FROM node:18-alpine AS frontend-build + +WORKDIR /app/frontend + +# Copy package files first for better caching +COPY frontend/package*.json ./ +RUN npm ci + +# Copy source code and build +COPY frontend/ ./ +RUN npm run build + +# --------------------------------------------------------- +# Stage 2: Build the Python Backend & Assemble +# --------------------------------------------------------- +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies (if needed for python packages) +# RUN apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY backend/requirements ./requirements/ +RUN pip install --no-cache-dir -r requirements/dev.txt + +# Copy Flask application code +COPY backend/ ./ + +# Copy the built React files from Stage 1 into Flask's static folder +# Adjust 'build' to 'dist' if you are using Vite +COPY --from=frontend-build /app/frontend/dist ./app/static + +# Create a non-root user for security +RUN useradd -m appuser +USER appuser + +# Expose port +EXPOSE 8000 + +# Run with Gunicorn (Production WSGI Server) +# --bind 0.0.0.0 makes it accessible outside the container +# --access-logfile - sends access logs to stdout +# --error-logfile - sends error logs to stderr +# --capture-output ensures all output is captured +# --log-level info sets the logging level +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--capture-output", "--log-level", "info", "wsgi:app"] diff --git a/frontend/favicon.svg b/frontend/favicon.svg new file mode 100644 index 0000000..7d70c3c --- /dev/null +++ b/frontend/favicon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 2e13e82..b39aa8e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,10 +2,10 @@ - + - - Crafting Shop + + Taskboard
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e3dfb9..faf9ae7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,16 @@ -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { useApp } from './context/AppContext'; import { ModalProvider } from './context/modals/useModal'; import { ModalRoot } from './context/modals/ModalRoot'; import { ToastProvider } from './context/toasts/useToast'; import { ToastRoot } from './context/toasts/ToastRoot'; import { LoaderProvider } from './context/loaders/useLoader'; import { LoaderRoot } from './context/loaders/LoaderRoot'; -import Cart from './pages/Cart'; import { Navbar } from './components/Navbar'; import { Home } from './pages/Home'; -import { Products } from './pages/Products'; import Login from './pages/Login'; import { Register } from './pages/Register'; -import { Orders } from './pages/Orders'; import { ProtectedRoute } from './components/ProtectedRoute'; import { Boards } from './pages/Boards'; import { BoardCreate } from './pages/BoardCreate'; @@ -20,15 +19,35 @@ import { BoardDetail } from './pages/BoardDetail'; import { CardDetail } from './pages/CardDetail'; const App = () => { + const { token } = useApp(); + const [isAuthenticated, setIsAuthenticated] = useState(null); + + useEffect(() => { + setIsAuthenticated(!!token); + }, [token]); + + if (isAuthenticated === null) { + return null; + } return (
-
+
- } /> + + ) : ( + + ) + } + /> + } /> } /> } /> @@ -73,11 +92,6 @@ const App = () => { } /> - - {/* Legacy Routes */} - } /> - } /> - } />
{/* Order matters for Z-Index: Loader (70) > Toast (60) > Modal (50) */} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index e4991f3..942b937 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,60 +1,59 @@ import { Link } from 'react-router-dom'; +import { useState } from 'react'; import { useApp } from '../context/AppContext'; +import { useAuth } from '../hooks/useAuth'; +import { TaskboardLogo } from './TaskboardLogo'; +import MenuIcon from './icons/MenuIcon'; +import CloseIcon from './icons/CloseIcon'; export function Navbar() { const { user } = useApp(); + const { logout } = useAuth(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); return ( ); } diff --git a/frontend/src/components/TaskboardLogo.tsx b/frontend/src/components/TaskboardLogo.tsx new file mode 100644 index 0000000..64095eb --- /dev/null +++ b/frontend/src/components/TaskboardLogo.tsx @@ -0,0 +1,55 @@ +export const TaskboardLogo = ({ className = '' }: { className?: string }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/frontend/src/components/icons/CloseIcon.tsx b/frontend/src/components/icons/CloseIcon.tsx new file mode 100644 index 0000000..9e69f38 --- /dev/null +++ b/frontend/src/components/icons/CloseIcon.tsx @@ -0,0 +1,18 @@ +const CloseIcon = () => ( + + + + +); + +export default CloseIcon; diff --git a/frontend/src/components/icons/MenuIcon.tsx b/frontend/src/components/icons/MenuIcon.tsx new file mode 100644 index 0000000..630747b --- /dev/null +++ b/frontend/src/components/icons/MenuIcon.tsx @@ -0,0 +1,19 @@ +const MenuIcon = () => ( + + + + + +); + +export default MenuIcon; diff --git a/frontend/src/components/kanban/DeleteListModal.tsx b/frontend/src/components/kanban/DeleteListModal.tsx index a10889e..a077ffe 100644 --- a/frontend/src/components/kanban/DeleteListModal.tsx +++ b/frontend/src/components/kanban/DeleteListModal.tsx @@ -1,4 +1,5 @@ import { ModalContentProps } from '../../types'; +import { useToast } from '../../context/toasts/useToast'; import Trash2Icon from '../icons/Trash2Icon'; interface DeleteListModalProps extends ModalContentProps { @@ -7,12 +8,22 @@ interface DeleteListModalProps extends ModalContentProps { } export function DeleteListModal({ onClose, onDelete, listName }: DeleteListModalProps) { + const { addNotification } = useToast(); + const handleDelete = async () => { try { await onDelete(); + addNotification({ + type: 'success', + title: 'List deleted successfully', + }); onClose(); } catch (err) { console.error('Failed to delete list:', err); + addNotification({ + type: 'error', + title: 'Failed to delete list', + }); } }; diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 58f87d5..b61923a 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -40,7 +40,10 @@ api.interceptors.response.use( // Token expired or invalid localStorage.removeItem('token'); localStorage.removeItem('user'); - window.location.href = '/login'; + + if (!['/login', '/register'].includes(window.location.pathname)) { + window.location.href = '/login'; + } } return Promise.reject(error); } diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 149f5cc..acdcc07 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -23,7 +23,6 @@ export function useAuth() { email: response.user.email, }; - // debugger // Store in localStorage first localStorage.setItem('token', response.access_token); localStorage.setItem('user', JSON.stringify(user)); @@ -43,9 +42,9 @@ export function useAuth() { navigate('/boards'); return user; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Login failed. Please try again.'; - + } catch (err: any) { + const errorMessage = + err.response?.data.error || err.message || 'Login failed. Please try again.'; // Show error toast addNotification({ type: 'error', @@ -76,7 +75,6 @@ export function useAuth() { }; // Store in localStorage first - // debugger localStorage.setItem('token', response.access_token); localStorage.setItem('user', JSON.stringify(user)); @@ -95,10 +93,8 @@ export function useAuth() { navigate('/boards'); return user; - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Registration failed. Please try again.'; - + } catch (err: any) { + const errorMessage = err.response?.data.error || err.message || 'Registration failed.'; // Show error toast addNotification({ type: 'error', diff --git a/frontend/src/hooks/useChecklistMutations.ts b/frontend/src/hooks/useChecklistMutations.ts index 8793e8d..9230438 100644 --- a/frontend/src/hooks/useChecklistMutations.ts +++ b/frontend/src/hooks/useChecklistMutations.ts @@ -84,7 +84,6 @@ export function useChecklistMutations(cardId: number, onUpdate: () => void) { }; const toggleCheckItem = async (item: CheckItem, currentState: 'incomplete' | 'complete') => { - console.log('item', item); try { const newState = currentState === 'incomplete' ? 'complete' : 'incomplete'; await withLoader( diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index f44ba7c..f769012 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -2,35 +2,39 @@ import { Link } from 'react-router-dom'; import { ModalExample } from '../context/modals/ModalExample'; import { ToastExample } from '../context/toasts/ToastExample'; import { LoaderExample } from '../context/loaders/LoaderExample'; +import { TaskboardLogo } from '../components/TaskboardLogo'; export function Home() { return ( -
+
-

Welcome to Crafting Shop

+
+ +
+

Welcome to Taskboard

- Your one-stop shop for premium crafting supplies + Organize your projects and boost productivity with powerful task management

- Browse Products + View Boards
-

Quality Products

-

Premium crafting supplies for all your projects

+

Visual Workflow

+

Drag and drop cards to manage your tasks efficiently

-

Fast Delivery

-

Quick and reliable shipping to your doorstep

+

Personal Organization

+

Your personal space to organize and track your tasks

-

Secure Payments

-

Safe and secure payment processing

+

Customizable Boards

+

Create and organize boards to fit your workflow

diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 7d87cd8..4f5508f 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,28 +1,37 @@ -import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Link } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; -import { useToast } from '../context/toasts/useToast'; + +const loginSchema = z.object({ + email: z.string().min(1, 'Email is required').email('Invalid email address'), + password: z + .string() + .min(1, 'Password is required') + .min(6, 'Password must be at least 6 characters'), +}); + +type LoginFormData = z.infer; export default function Login() { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); const { login: handleLogin } = useAuth(); - const { addNotification } = useToast(); + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + mode: 'onSubmit', + }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = async (data: LoginFormData) => { try { - await handleLogin(email, password); + await handleLogin(data.email, data.password); } catch (err) { // Error is handled by the hook (toast shown) - const errorMessage = err instanceof Error ? err.message : 'Failed to create card'; - addNotification({ - type: 'error', - title: 'Error Login', - message: errorMessage, - duration: 5000, - }); + console.error(err); } }; @@ -30,45 +39,48 @@ export default function Login() {

Login

-
+
setEmail(e.target.value)} - required + {...register('email')} className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="you@example.com" /> + {errors.email &&

{errors.email.message}

}
setPassword(e.target.value)} - required + {...register('password')} className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="••••••••" /> + {errors.password && ( +

{errors.password.message}

+ )}

- Don't have an account? + Don‘t have an account? Register diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index db26265..ef8c3cb 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; -import { useToast } from '../context/toasts/useToast'; interface FormData { email: string; @@ -23,7 +22,6 @@ export function Register() { }); const { register: handleRegister } = useAuth(); - const { addNotification } = useToast(); const handleChange = (e: React.ChangeEvent) => { setFormData({ @@ -52,13 +50,7 @@ export function Register() { last_name: formData.last_name, }); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to register'; - addNotification({ - type: 'error', - title: 'Registration Error', - message: errorMessage, - duration: 5000, - }); + console.error(err); } };