001-reference-board-viewer #1

Merged
jawz merged 43 commits from 001-reference-board-viewer into main 2025-11-02 15:58:57 -06:00
17 changed files with 1163 additions and 237 deletions
Showing only changes of commit f85ae4d417 - Show all commits

View 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"}

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
}
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>

View File

@@ -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 -->

View 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>

View 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;
}

View 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;
}

View 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;
}

View 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);
}
}

View 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;
}

View 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) + '...';
}

View 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: '' };
}

View File

@@ -1 +1,5 @@
<script>
import '$lib/styles/global.css';
</script>
<slot />