001-reference-board-viewer #1
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
|
import magic
|
||||||
from fastapi import HTTPException, UploadFile, status
|
from fastapi import HTTPException, UploadFile, status
|
||||||
|
|
||||||
# Maximum file size: 50MB
|
from app.core.constants import (
|
||||||
MAX_FILE_SIZE = 52_428_800
|
ALLOWED_EXTENSIONS,
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
# Allowed MIME types
|
MAX_IMAGE_SIZE,
|
||||||
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"}
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_image_file(file: UploadFile) -> bytes:
|
async def validate_image_file(file: UploadFile) -> bytes:
|
||||||
@@ -50,10 +40,10 @@ async def validate_image_file(file: UploadFile) -> bytes:
|
|||||||
if file_size == 0:
|
if file_size == 0:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded")
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
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
|
# Validate file extension
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { validateEmail, validateRequired } from '$lib/utils/validation';
|
||||||
|
|
||||||
export let isLoading = false;
|
export let isLoading = false;
|
||||||
|
|
||||||
@@ -14,14 +15,14 @@
|
|||||||
function validateForm(): boolean {
|
function validateForm(): boolean {
|
||||||
errors = {};
|
errors = {};
|
||||||
|
|
||||||
if (!email) {
|
const emailValidation = validateEmail(email);
|
||||||
errors.email = 'Email is required';
|
if (!emailValidation.valid) {
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
errors.email = emailValidation.message;
|
||||||
errors.email = 'Please enter a valid email address';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password) {
|
const passwordValidation = validateRequired(password, 'Password');
|
||||||
errors.password = 'Password is required';
|
if (!passwordValidation.valid) {
|
||||||
|
errors.password = passwordValidation.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
@@ -40,40 +41,42 @@
|
|||||||
|
|
||||||
<form on:submit={handleSubmit} class="login-form">
|
<form on:submit={handleSubmit} class="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email" class="form-label">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.email}
|
class:error={errors.email}
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
/>
|
/>
|
||||||
{#if errors.email}
|
{#if errors.email}
|
||||||
<span class="error-text">{errors.email}</span>
|
<span class="form-error-text">{errors.email}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password" class="form-label">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.password}
|
class:error={errors.password}
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
{#if errors.password}
|
{#if errors.password}
|
||||||
<span class="error-text">{errors.password}</span>
|
<span class="form-error-text">{errors.password}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading} class="submit-button">
|
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<span class="spinner"></span>
|
<span class="spinner-small"></span>
|
||||||
Logging in...
|
Logging in...
|
||||||
{:else}
|
{:else}
|
||||||
Login
|
Login
|
||||||
@@ -88,87 +91,16 @@
|
|||||||
gap: 1.25rem;
|
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 {
|
.submit-button {
|
||||||
padding: 0.875rem 1.5rem;
|
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;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { validateEmail, validatePassword, validatePasswordsMatch } from '$lib/utils/validation';
|
||||||
|
|
||||||
export let isLoading = false;
|
export let isLoading = false;
|
||||||
|
|
||||||
@@ -12,44 +13,22 @@
|
|||||||
submit: { email: string; password: string };
|
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 {
|
function validateForm(): boolean {
|
||||||
errors = {};
|
errors = {};
|
||||||
|
|
||||||
if (!email) {
|
const emailValidation = validateEmail(email);
|
||||||
errors.email = 'Email is required';
|
if (!emailValidation.valid) {
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
errors.email = emailValidation.message;
|
||||||
errors.email = 'Please enter a valid email address';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password) {
|
const passwordValidation = validatePassword(password);
|
||||||
errors.password = 'Password is required';
|
if (!passwordValidation.valid) {
|
||||||
} else {
|
errors.password = passwordValidation.message;
|
||||||
const passwordValidation = validatePassword(password);
|
|
||||||
if (!passwordValidation.valid) {
|
|
||||||
errors.password = passwordValidation.message;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirmPassword) {
|
const confirmValidation = validatePasswordsMatch(password, confirmPassword);
|
||||||
errors.confirmPassword = 'Please confirm your password';
|
if (!confirmValidation.valid) {
|
||||||
} else if (password !== confirmPassword) {
|
errors.confirmPassword = confirmValidation.message;
|
||||||
errors.confirmPassword = 'Passwords do not match';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
@@ -68,58 +47,63 @@
|
|||||||
|
|
||||||
<form on:submit={handleSubmit} class="register-form">
|
<form on:submit={handleSubmit} class="register-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email" class="form-label">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.email}
|
class:error={errors.email}
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
/>
|
/>
|
||||||
{#if errors.email}
|
{#if errors.email}
|
||||||
<span class="error-text">{errors.email}</span>
|
<span class="form-error-text">{errors.email}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password" class="form-label">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.password}
|
class:error={errors.password}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
{#if errors.password}
|
{#if errors.password}
|
||||||
<span class="error-text">{errors.password}</span>
|
<span class="form-error-text">{errors.password}</span>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="confirmPassword">Confirm Password</label>
|
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||||
<input
|
<input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.confirmPassword}
|
class:error={errors.confirmPassword}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
{#if errors.confirmPassword}
|
{#if errors.confirmPassword}
|
||||||
<span class="error-text">{errors.confirmPassword}</span>
|
<span class="form-error-text">{errors.confirmPassword}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading} class="submit-button">
|
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<span class="spinner"></span>
|
<span class="spinner-small"></span>
|
||||||
Creating account...
|
Creating account...
|
||||||
{:else}
|
{:else}
|
||||||
Create Account
|
Create Account
|
||||||
@@ -134,92 +118,16 @@
|
|||||||
gap: 1.25rem;
|
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 {
|
.submit-button {
|
||||||
padding: 0.875rem 1.5rem;
|
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;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { BoardSummary } from '$lib/types/boards';
|
import type { BoardSummary } from '$lib/types/boards';
|
||||||
|
import { formatDate } from '$lib/utils/format';
|
||||||
|
|
||||||
export let board: BoardSummary;
|
export let board: BoardSummary;
|
||||||
|
|
||||||
@@ -15,11 +16,6 @@
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
dispatch('delete');
|
dispatch('delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
<!-- 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 />
|
<slot />
|
||||||
|
|||||||
Reference in New Issue
Block a user