From f85ae4d4177b6b870f1a0d9fd422749bbc890c21 Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 2 Nov 2025 13:44:10 -0600 Subject: [PATCH] feat: add core application constants, ownership verification, and repository utilities - Introduced application-wide constants for file uploads, image processing, pagination, and authentication in `constants.py`. - Implemented synchronous and asynchronous board ownership verification functions in `ownership.py`. - Created a base repository class with common CRUD operations in `repository.py`. - Added standard response utilities for error and success messages in `responses.py`. - Refactored image validation to utilize constants for file size and MIME types. - Enhanced frontend components with consistent styling and validation utilities for forms. - Established global styles for buttons, forms, loading indicators, and messages to ensure a cohesive UI experience. --- backend/app/core/constants.py | 38 +++ backend/app/core/ownership.py | 69 +++++ backend/app/core/repository.py | 119 ++++++++ backend/app/core/responses.py | 75 +++++ backend/app/images/validation.py | 24 +- .../src/lib/components/auth/LoginForm.svelte | 114 ++----- .../lib/components/auth/RegisterForm.svelte | 156 ++-------- .../lib/components/boards/BoardCard.svelte | 6 +- .../src/lib/components/common/Message.svelte | 34 +++ frontend/src/lib/styles/buttons.css | 76 +++++ frontend/src/lib/styles/form.css | 75 +++++ frontend/src/lib/styles/global.css | 278 ++++++++++++++++++ frontend/src/lib/styles/loading.css | 49 +++ frontend/src/lib/styles/messages.css | 80 +++++ frontend/src/lib/utils/format.ts | 78 +++++ frontend/src/lib/utils/validation.ts | 125 ++++++++ frontend/src/routes/+layout.svelte | 4 + 17 files changed, 1163 insertions(+), 237 deletions(-) create mode 100644 backend/app/core/constants.py create mode 100644 backend/app/core/ownership.py create mode 100644 backend/app/core/repository.py create mode 100644 backend/app/core/responses.py create mode 100644 frontend/src/lib/components/common/Message.svelte create mode 100644 frontend/src/lib/styles/buttons.css create mode 100644 frontend/src/lib/styles/form.css create mode 100644 frontend/src/lib/styles/global.css create mode 100644 frontend/src/lib/styles/loading.css create mode 100644 frontend/src/lib/styles/messages.css create mode 100644 frontend/src/lib/utils/format.ts create mode 100644 frontend/src/lib/utils/validation.ts diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py new file mode 100644 index 0000000..1c69465 --- /dev/null +++ b/backend/app/core/constants.py @@ -0,0 +1,38 @@ +"""Application-wide constants.""" + +# File upload limits +MAX_IMAGE_SIZE = 52_428_800 # 50MB in bytes +MAX_ZIP_SIZE = 209_715_200 # 200MB in bytes + +# Image processing +MAX_IMAGE_DIMENSION = 10_000 # Max width or height in pixels +THUMBNAIL_SIZES = { + "low": 800, # For slow connections (<1 Mbps) + "medium": 1600, # For medium connections (1-5 Mbps) + "high": 3200, # For fast connections (>5 Mbps) +} + +# Pagination defaults +DEFAULT_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 100 + +# Board limits +MAX_BOARD_TITLE_LENGTH = 255 +MAX_BOARD_DESCRIPTION_LENGTH = 1000 +MAX_IMAGES_PER_BOARD = 1000 + +# Authentication +TOKEN_EXPIRE_HOURS = 168 # 7 days +PASSWORD_MIN_LENGTH = 8 + +# Supported image formats +ALLOWED_MIME_TYPES = { + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", +} + +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"} diff --git a/backend/app/core/ownership.py b/backend/app/core/ownership.py new file mode 100644 index 0000000..49e63c4 --- /dev/null +++ b/backend/app/core/ownership.py @@ -0,0 +1,69 @@ +"""Ownership verification utilities.""" + +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +from app.database.models.board import Board + + +def verify_board_ownership_sync(db: Session, board_id: UUID, user_id: UUID) -> Board: + """ + Verify board ownership (synchronous). + + Args: + db: Database session + board_id: Board UUID + user_id: User UUID + + Returns: + Board instance if owned by user + + Raises: + HTTPException: 404 if board not found or not owned by user + """ + stmt = select(Board).where( + Board.id == board_id, + Board.user_id == user_id, + Board.is_deleted == False, # noqa: E712 + ) + + board = db.execute(stmt).scalar_one_or_none() + + if not board: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found") + + return board + + +async def verify_board_ownership_async(db: AsyncSession, board_id: UUID, user_id: UUID) -> Board: + """ + Verify board ownership (asynchronous). + + Args: + db: Async database session + board_id: Board UUID + user_id: User UUID + + Returns: + Board instance if owned by user + + Raises: + HTTPException: 404 if board not found or not owned by user + """ + stmt = select(Board).where( + Board.id == board_id, + Board.user_id == user_id, + Board.is_deleted == False, # noqa: E712 + ) + + result = await db.execute(stmt) + board = result.scalar_one_or_none() + + if not board: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found") + + return board diff --git a/backend/app/core/repository.py b/backend/app/core/repository.py new file mode 100644 index 0000000..208b9ae --- /dev/null +++ b/backend/app/core/repository.py @@ -0,0 +1,119 @@ +"""Base repository with common database operations.""" + +from typing import TypeVar +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +# Type variable for model classes +ModelType = TypeVar("ModelType") + + +class BaseRepository[ModelType]: + """Base repository with common CRUD operations.""" + + def __init__(self, model: type[ModelType], db: Session | AsyncSession): + """ + Initialize repository. + + Args: + model: SQLAlchemy model class + db: Database session (sync or async) + """ + self.model = model + self.db = db + + def get_by_id_sync(self, id: UUID) -> ModelType | None: + """ + Get entity by ID (synchronous). + + Args: + id: Entity UUID + + Returns: + Entity if found, None otherwise + """ + return self.db.query(self.model).filter(self.model.id == id).first() + + async def get_by_id_async(self, id: UUID) -> ModelType | None: + """ + Get entity by ID (asynchronous). + + Args: + id: Entity UUID + + Returns: + Entity if found, None otherwise + """ + stmt = select(self.model).where(self.model.id == id) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() + + def count_sync(self, **filters) -> int: + """ + Count entities with optional filters (synchronous). + + Args: + **filters: Column filters (column_name=value) + + Returns: + Count of matching entities + """ + query = self.db.query(func.count(self.model.id)) + for key, value in filters.items(): + query = query.filter(getattr(self.model, key) == value) + return query.scalar() + + async def count_async(self, **filters) -> int: + """ + Count entities with optional filters (asynchronous). + + Args: + **filters: Column filters (column_name=value) + + Returns: + Count of matching entities + """ + stmt = select(func.count(self.model.id)) + for key, value in filters.items(): + stmt = stmt.where(getattr(self.model, key) == value) + result = await self.db.execute(stmt) + return result.scalar_one() + + def delete_sync(self, id: UUID) -> bool: + """ + Delete entity by ID (synchronous). + + Args: + id: Entity UUID + + Returns: + True if deleted, False if not found + """ + entity = self.get_by_id_sync(id) + if not entity: + return False + + self.db.delete(entity) + self.db.commit() + return True + + async def delete_async(self, id: UUID) -> bool: + """ + Delete entity by ID (asynchronous). + + Args: + id: Entity UUID + + Returns: + True if deleted, False if not found + """ + entity = await self.get_by_id_async(id) + if not entity: + return False + + await self.db.delete(entity) + await self.db.commit() + return True diff --git a/backend/app/core/responses.py b/backend/app/core/responses.py new file mode 100644 index 0000000..9c53741 --- /dev/null +++ b/backend/app/core/responses.py @@ -0,0 +1,75 @@ +"""Standard response utilities.""" + +from typing import Any + +from fastapi import status + + +class ErrorResponse: + """Standard error response formats.""" + + @staticmethod + def not_found(resource: str = "Resource") -> dict[str, Any]: + """404 Not Found response.""" + return { + "status_code": status.HTTP_404_NOT_FOUND, + "detail": f"{resource} not found", + } + + @staticmethod + def forbidden(message: str = "Access denied") -> dict[str, Any]: + """403 Forbidden response.""" + return { + "status_code": status.HTTP_403_FORBIDDEN, + "detail": message, + } + + @staticmethod + def unauthorized(message: str = "Authentication required") -> dict[str, Any]: + """401 Unauthorized response.""" + return { + "status_code": status.HTTP_401_UNAUTHORIZED, + "detail": message, + "headers": {"WWW-Authenticate": "Bearer"}, + } + + @staticmethod + def bad_request(message: str) -> dict[str, Any]: + """400 Bad Request response.""" + return { + "status_code": status.HTTP_400_BAD_REQUEST, + "detail": message, + } + + @staticmethod + def conflict(message: str) -> dict[str, Any]: + """409 Conflict response.""" + return { + "status_code": status.HTTP_409_CONFLICT, + "detail": message, + } + + +class SuccessResponse: + """Standard success response formats.""" + + @staticmethod + def created(data: dict[str, Any], message: str = "Created successfully") -> dict[str, Any]: + """201 Created response.""" + return { + "message": message, + "data": data, + } + + @staticmethod + def ok(data: dict[str, Any] | None = None, message: str = "Success") -> dict[str, Any]: + """200 OK response.""" + response = {"message": message} + if data: + response["data"] = data + return response + + @staticmethod + def no_content() -> None: + """204 No Content response.""" + return None diff --git a/backend/app/images/validation.py b/backend/app/images/validation.py index cfff5c2..b2e8503 100644 --- a/backend/app/images/validation.py +++ b/backend/app/images/validation.py @@ -3,21 +3,11 @@ import magic from fastapi import HTTPException, UploadFile, status -# Maximum file size: 50MB -MAX_FILE_SIZE = 52_428_800 - -# Allowed MIME types -ALLOWED_MIME_TYPES = { - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", - "image/svg+xml", -} - -# Allowed file extensions -ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"} +from app.core.constants import ( + ALLOWED_EXTENSIONS, + ALLOWED_MIME_TYPES, + MAX_IMAGE_SIZE, +) async def validate_image_file(file: UploadFile) -> bytes: @@ -50,10 +40,10 @@ async def validate_image_file(file: UploadFile) -> bytes: if file_size == 0: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded") - if file_size > MAX_FILE_SIZE: + if file_size > MAX_IMAGE_SIZE: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail=f"File too large. Maximum size is {MAX_FILE_SIZE / 1_048_576:.1f}MB", + detail=f"File too large. Maximum size is {MAX_IMAGE_SIZE / 1_048_576:.1f}MB", ) # Validate file extension diff --git a/frontend/src/lib/components/auth/LoginForm.svelte b/frontend/src/lib/components/auth/LoginForm.svelte index bdd7e85..439a9a3 100644 --- a/frontend/src/lib/components/auth/LoginForm.svelte +++ b/frontend/src/lib/components/auth/LoginForm.svelte @@ -1,5 +1,6 @@ diff --git a/frontend/src/lib/components/common/Message.svelte b/frontend/src/lib/components/common/Message.svelte new file mode 100644 index 0000000..21fdad3 --- /dev/null +++ b/frontend/src/lib/components/common/Message.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/frontend/src/lib/styles/buttons.css b/frontend/src/lib/styles/buttons.css new file mode 100644 index 0000000..f67312e --- /dev/null +++ b/frontend/src/lib/styles/buttons.css @@ -0,0 +1,76 @@ +/** + * Shared button styles + * Consistent button styling across the application + */ + +.btn { + padding: 0.75rem 1.5rem; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + background: white; + color: #374151; + border: 1px solid #d1d5db; +} + +.btn-secondary:hover:not(:disabled) { + background: #f9fafb; +} + +.btn-secondary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-danger { + background: #ef4444; + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); +} + +.btn-danger:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-small { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-large { + padding: 1rem 2rem; + font-size: 1.125rem; +} diff --git a/frontend/src/lib/styles/form.css b/frontend/src/lib/styles/form.css new file mode 100644 index 0000000..d11b5b4 --- /dev/null +++ b/frontend/src/lib/styles/form.css @@ -0,0 +1,75 @@ +/** + * Shared form styles + * Used across all forms to maintain consistency and reduce duplication + */ + +/* Form containers */ +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +/* Labels */ +.form-label { + display: block; + font-weight: 500; + color: #374151; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.form-label .required { + color: #ef4444; +} + +/* Inputs and textareas */ +.form-input, +.form-textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 1rem; + font-family: inherit; + transition: all 0.2s; +} + +.form-input:focus, +.form-textarea:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-input.error, +.form-textarea.error { + border-color: #ef4444; +} + +.form-input:disabled, +.form-textarea:disabled { + background-color: #f9fafb; + cursor: not-allowed; +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +/* Help and error text */ +.form-help-text { + display: block; + color: #6b7280; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.form-error-text { + display: block; + color: #ef4444; + font-size: 0.875rem; + margin-top: 0.25rem; +} diff --git a/frontend/src/lib/styles/global.css b/frontend/src/lib/styles/global.css new file mode 100644 index 0000000..5d90be4 --- /dev/null +++ b/frontend/src/lib/styles/global.css @@ -0,0 +1,278 @@ +/** + * Global styles - Import this once in app layout + * Contains all shared form, button, loading, and message styles + */ + +/* ============================================ + FORM STYLES + ============================================ */ + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.form-label { + display: block; + font-weight: 500; + color: #374151; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.form-label .required { + color: #ef4444; +} + +.form-input, +.form-textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 1rem; + font-family: inherit; + transition: all 0.2s; +} + +.form-input:focus, +.form-textarea:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-input.error, +.form-textarea.error { + border-color: #ef4444; +} + +.form-input:disabled, +.form-textarea:disabled { + background-color: #f9fafb; + cursor: not-allowed; +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-help-text { + display: block; + color: #6b7280; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.form-error-text { + display: block; + color: #ef4444; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +/* ============================================ + BUTTON STYLES + ============================================ */ + +.btn { + padding: 0.75rem 1.5rem; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + background: white; + color: #374151; + border: 1px solid #d1d5db; +} + +.btn-secondary:hover:not(:disabled) { + background: #f9fafb; +} + +.btn-secondary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-danger { + background: #ef4444; + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); +} + +.btn-danger:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-small { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-large { + padding: 1rem 2rem; + font-size: 1.125rem; +} + +/* ============================================ + LOADING STYLES + ============================================ */ + +.spinner { + width: 48px; + height: 48px; + border: 4px solid #e5e7eb; + border-top-color: #667eea; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.spinner-small { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.spinner-medium { + width: 32px; + height: 32px; + border: 3px solid #e5e7eb; + border-top-color: #667eea; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem; + color: #6b7280; +} + +.loading-container .spinner { + margin-bottom: 1rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ============================================ + MESSAGE / ALERT STYLES + ============================================ */ + +.message-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + margin-bottom: 1.5rem; +} + +.message-icon { + font-size: 1.25rem; + flex-shrink: 0; +} + +.message-banner .close-btn { + margin-left: auto; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + line-height: 1; + transition: opacity 0.2s; +} + +.message-banner .close-btn:hover { + opacity: 0.7; +} + +.message-error { + background: #fee2e2; + border: 1px solid #fca5a5; + color: #991b1b; +} + +.message-error .message-icon, +.message-error .close-btn { + color: #991b1b; +} + +.message-success { + background: #d1fae5; + border: 1px solid #a7f3d0; + color: #065f46; +} + +.message-success .message-icon, +.message-success .close-btn { + color: #065f46; +} + +.message-warning { + background: #fef3c7; + border: 1px solid #fde68a; + color: #92400e; +} + +.message-warning .message-icon, +.message-warning .close-btn { + color: #92400e; +} + +.message-info { + background: #dbeafe; + border: 1px solid #bfdbfe; + color: #1e40af; +} + +.message-info .message-icon, +.message-info .close-btn { + color: #1e40af; +} diff --git a/frontend/src/lib/styles/loading.css b/frontend/src/lib/styles/loading.css new file mode 100644 index 0000000..d0bc44d --- /dev/null +++ b/frontend/src/lib/styles/loading.css @@ -0,0 +1,49 @@ +/** + * Shared loading/spinner styles + */ + +.spinner { + width: 48px; + height: 48px; + border: 4px solid #e5e7eb; + border-top-color: #667eea; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.spinner-small { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.spinner-medium { + width: 32px; + height: 32px; + border: 3px solid #e5e7eb; + border-top-color: #667eea; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem; + color: #6b7280; +} + +.loading-container .spinner { + margin-bottom: 1rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/frontend/src/lib/styles/messages.css b/frontend/src/lib/styles/messages.css new file mode 100644 index 0000000..db3a68e --- /dev/null +++ b/frontend/src/lib/styles/messages.css @@ -0,0 +1,80 @@ +/** + * Shared message/alert/banner styles + */ + +.message-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + margin-bottom: 1.5rem; +} + +.message-icon { + font-size: 1.25rem; + flex-shrink: 0; +} + +.message-banner .close-btn { + margin-left: auto; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + line-height: 1; + transition: opacity 0.2s; +} + +.message-banner .close-btn:hover { + opacity: 0.7; +} + +/* Error message */ +.message-error { + background: #fee2e2; + border: 1px solid #fca5a5; + color: #991b1b; +} + +.message-error .message-icon, +.message-error .close-btn { + color: #991b1b; +} + +/* Success message */ +.message-success { + background: #d1fae5; + border: 1px solid #a7f3d0; + color: #065f46; +} + +.message-success .message-icon, +.message-success .close-btn { + color: #065f46; +} + +/* Warning message */ +.message-warning { + background: #fef3c7; + border: 1px solid #fde68a; + color: #92400e; +} + +.message-warning .message-icon, +.message-warning .close-btn { + color: #92400e; +} + +/* Info message */ +.message-info { + background: #dbeafe; + border: 1px solid #bfdbfe; + color: #1e40af; +} + +.message-info .message-icon, +.message-info .close-btn { + color: #1e40af; +} diff --git a/frontend/src/lib/utils/format.ts b/frontend/src/lib/utils/format.ts new file mode 100644 index 0000000..324dce6 --- /dev/null +++ b/frontend/src/lib/utils/format.ts @@ -0,0 +1,78 @@ +/** + * Formatting utilities + */ + +/** + * Format date to readable string + */ +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +/** + * Format date with time + */ +export function formatDateTime(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Format relative time (e.g., "2 hours ago") + */ +export function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) return 'just now'; + if (diffMins < 60) return `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`; + if (diffHours < 24) return `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`; + if (diffDays < 7) return `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`; + + return formatDate(dateString); +} + +/** + * Format file size to human-readable string + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} + +/** + * Pluralize word based on count + */ +export function pluralize(count: number, singular: string, plural?: string): string { + if (count === 1) return singular; + return plural || `${singular}s`; +} + +/** + * Truncate text with ellipsis + */ +export function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.slice(0, maxLength - 3) + '...'; +} diff --git a/frontend/src/lib/utils/validation.ts b/frontend/src/lib/utils/validation.ts new file mode 100644 index 0000000..af37add --- /dev/null +++ b/frontend/src/lib/utils/validation.ts @@ -0,0 +1,125 @@ +/** + * Shared validation utilities + */ + +export interface ValidationResult { + valid: boolean; + message: string; +} + +/** + * Validate email format + */ +export function validateEmail(email: string): ValidationResult { + if (!email) { + return { valid: false, message: 'Email is required' }; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return { valid: false, message: 'Please enter a valid email address' }; + } + + return { valid: true, message: '' }; +} + +/** + * Validate password strength + * Requirements: 8+ chars, uppercase, lowercase, number + */ +export function validatePassword(password: string): ValidationResult { + if (!password) { + return { valid: false, message: 'Password is required' }; + } + + if (password.length < 8) { + return { valid: false, message: 'Password must be at least 8 characters' }; + } + + if (!/[A-Z]/.test(password)) { + return { valid: false, message: 'Password must contain an uppercase letter' }; + } + + if (!/[a-z]/.test(password)) { + return { valid: false, message: 'Password must contain a lowercase letter' }; + } + + if (!/\d/.test(password)) { + return { valid: false, message: 'Password must contain a number' }; + } + + return { valid: true, message: '' }; +} + +/** + * Validate passwords match + */ +export function validatePasswordsMatch( + password: string, + confirmPassword: string +): ValidationResult { + if (!confirmPassword) { + return { valid: false, message: 'Please confirm your password' }; + } + + if (password !== confirmPassword) { + return { valid: false, message: 'Passwords do not match' }; + } + + return { valid: true, message: '' }; +} + +/** + * Validate board title + */ +export function validateBoardTitle(title: string): ValidationResult { + if (!title || !title.trim()) { + return { valid: false, message: 'Title is required' }; + } + + if (title.length > 255) { + return { valid: false, message: 'Title must be 255 characters or less' }; + } + + return { valid: true, message: '' }; +} + +/** + * Validate board description + */ +export function validateBoardDescription(description: string): ValidationResult { + if (description && description.length > 1000) { + return { valid: false, message: 'Description must be 1000 characters or less' }; + } + + return { valid: true, message: '' }; +} + +/** + * Validate required field + */ +export function validateRequired(value: string, fieldName: string = 'Field'): ValidationResult { + if (!value || !value.trim()) { + return { valid: false, message: `${fieldName} is required` }; + } + + return { valid: true, message: '' }; +} + +/** + * Validate max length + */ +export function validateMaxLength( + value: string, + maxLength: number, + fieldName: string = 'Field' +): ValidationResult { + if (value && value.length > maxLength) { + return { + valid: false, + message: `${fieldName} must be ${maxLength} characters or less`, + }; + } + + return { valid: true, message: '' }; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4fa864c..bb493c9 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1 +1,5 @@ + +