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.
This commit is contained in:
38
backend/app/core/constants.py
Normal file
38
backend/app/core/constants.py
Normal file
@@ -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"}
|
||||
69
backend/app/core/ownership.py
Normal file
69
backend/app/core/ownership.py
Normal file
@@ -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
|
||||
119
backend/app/core/repository.py
Normal file
119
backend/app/core/repository.py
Normal file
@@ -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
|
||||
75
backend/app/core/responses.py
Normal file
75
backend/app/core/responses.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { validateEmail, validateRequired } from '$lib/utils/validation';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
@@ -14,14 +15,14 @@
|
||||
function validateForm(): boolean {
|
||||
errors = {};
|
||||
|
||||
if (!email) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
const emailValidation = validateEmail(email);
|
||||
if (!emailValidation.valid) {
|
||||
errors.email = emailValidation.message;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
const passwordValidation = validateRequired(password, 'Password');
|
||||
if (!passwordValidation.valid) {
|
||||
errors.password = passwordValidation.message;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
@@ -40,40 +41,42 @@
|
||||
|
||||
<form on:submit={handleSubmit} class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
disabled={isLoading}
|
||||
placeholder="you@example.com"
|
||||
class="form-input"
|
||||
class:error={errors.email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<span class="error-text">{errors.email}</span>
|
||||
<span class="form-error-text">{errors.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.password}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
{#if errors.password}
|
||||
<span class="error-text">{errors.password}</span>
|
||||
<span class="form-error-text">{errors.password}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} class="submit-button">
|
||||
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||
{#if isLoading}
|
||||
<span class="spinner"></span>
|
||||
<span class="spinner-small"></span>
|
||||
Logging in...
|
||||
{:else}
|
||||
Login
|
||||
@@ -88,87 +91,16 @@
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-group :global(label) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group :global(input) {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { validateEmail, validatePassword, validatePasswordsMatch } from '$lib/utils/validation';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
@@ -12,44 +13,22 @@
|
||||
submit: { email: string; password: string };
|
||||
}>();
|
||||
|
||||
function validatePassword(pwd: string): { valid: boolean; message: string } {
|
||||
if (pwd.length < 8) {
|
||||
return { valid: false, message: 'Password must be at least 8 characters' };
|
||||
}
|
||||
if (!/[A-Z]/.test(pwd)) {
|
||||
return { valid: false, message: 'Password must contain an uppercase letter' };
|
||||
}
|
||||
if (!/[a-z]/.test(pwd)) {
|
||||
return { valid: false, message: 'Password must contain a lowercase letter' };
|
||||
}
|
||||
if (!/\d/.test(pwd)) {
|
||||
return { valid: false, message: 'Password must contain a number' };
|
||||
}
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
errors = {};
|
||||
|
||||
if (!email) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
const emailValidation = validateEmail(email);
|
||||
if (!emailValidation.valid) {
|
||||
errors.email = emailValidation.message;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
} else {
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
errors.password = passwordValidation.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
errors.confirmPassword = 'Please confirm your password';
|
||||
} else if (password !== confirmPassword) {
|
||||
errors.confirmPassword = 'Passwords do not match';
|
||||
const confirmValidation = validatePasswordsMatch(password, confirmPassword);
|
||||
if (!confirmValidation.valid) {
|
||||
errors.confirmPassword = confirmValidation.message;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
@@ -68,58 +47,63 @@
|
||||
|
||||
<form on:submit={handleSubmit} class="register-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
disabled={isLoading}
|
||||
placeholder="you@example.com"
|
||||
class="form-input"
|
||||
class:error={errors.email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<span class="error-text">{errors.email}</span>
|
||||
<span class="form-error-text">{errors.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.password}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
{#if errors.password}
|
||||
<span class="error-text">{errors.password}</span>
|
||||
<span class="form-error-text">{errors.password}</span>
|
||||
{:else}
|
||||
<span class="help-text"> Must be 8+ characters with uppercase, lowercase, and number </span>
|
||||
<span class="form-help-text">
|
||||
Must be 8+ characters with uppercase, lowercase, and number
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password</label>
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.confirmPassword}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
{#if errors.confirmPassword}
|
||||
<span class="error-text">{errors.confirmPassword}</span>
|
||||
<span class="form-error-text">{errors.confirmPassword}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} class="submit-button">
|
||||
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||
{#if isLoading}
|
||||
<span class="spinner"></span>
|
||||
<span class="spinner-small"></span>
|
||||
Creating account...
|
||||
{:else}
|
||||
Create Account
|
||||
@@ -134,92 +118,16 @@
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-group :global(label) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group :global(input) {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { BoardSummary } from '$lib/types/boards';
|
||||
import { formatDate } from '$lib/utils/format';
|
||||
|
||||
export let board: BoardSummary;
|
||||
|
||||
@@ -15,11 +16,6 @@
|
||||
event.stopPropagation();
|
||||
dispatch('delete');
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
|
||||
34
frontend/src/lib/components/common/Message.svelte
Normal file
34
frontend/src/lib/components/common/Message.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let type: 'error' | 'success' | 'warning' | 'info' = 'info';
|
||||
export let message: string;
|
||||
export let dismissible: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{ dismiss: void }>();
|
||||
|
||||
const icons = {
|
||||
error: '⚠',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
info: 'ℹ',
|
||||
};
|
||||
|
||||
function handleDismiss() {
|
||||
dispatch('dismiss');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="message-banner message-{type}" role="alert">
|
||||
<span class="message-icon">{icons[type]}</span>
|
||||
<span class="message-text">{message}</span>
|
||||
{#if dismissible}
|
||||
<button class="close-btn" on:click={handleDismiss} aria-label="Dismiss">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.message-text {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
76
frontend/src/lib/styles/buttons.css
Normal file
76
frontend/src/lib/styles/buttons.css
Normal file
@@ -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;
|
||||
}
|
||||
75
frontend/src/lib/styles/form.css
Normal file
75
frontend/src/lib/styles/form.css
Normal file
@@ -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;
|
||||
}
|
||||
278
frontend/src/lib/styles/global.css
Normal file
278
frontend/src/lib/styles/global.css
Normal file
@@ -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;
|
||||
}
|
||||
49
frontend/src/lib/styles/loading.css
Normal file
49
frontend/src/lib/styles/loading.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
80
frontend/src/lib/styles/messages.css
Normal file
80
frontend/src/lib/styles/messages.css
Normal file
@@ -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;
|
||||
}
|
||||
78
frontend/src/lib/utils/format.ts
Normal file
78
frontend/src/lib/utils/format.ts
Normal file
@@ -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) + '...';
|
||||
}
|
||||
125
frontend/src/lib/utils/validation.ts
Normal file
125
frontend/src/lib/utils/validation.ts
Normal file
@@ -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: '' };
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
<script>
|
||||
import '$lib/styles/global.css';
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
|
||||
Reference in New Issue
Block a user