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:
Danilo Reyes
2025-11-02 13:44:10 -06:00
parent ca81729c50
commit f85ae4d417
17 changed files with 1163 additions and 237 deletions

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