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:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { validateEmail, validateRequired } from '$lib/utils/validation';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
@@ -14,14 +15,14 @@
|
||||
function validateForm(): boolean {
|
||||
errors = {};
|
||||
|
||||
if (!email) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
const emailValidation = validateEmail(email);
|
||||
if (!emailValidation.valid) {
|
||||
errors.email = emailValidation.message;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
const passwordValidation = validateRequired(password, 'Password');
|
||||
if (!passwordValidation.valid) {
|
||||
errors.password = passwordValidation.message;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
@@ -40,40 +41,42 @@
|
||||
|
||||
<form on:submit={handleSubmit} class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
disabled={isLoading}
|
||||
placeholder="you@example.com"
|
||||
class="form-input"
|
||||
class:error={errors.email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<span class="error-text">{errors.email}</span>
|
||||
<span class="form-error-text">{errors.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.password}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
{#if errors.password}
|
||||
<span class="error-text">{errors.password}</span>
|
||||
<span class="form-error-text">{errors.password}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} class="submit-button">
|
||||
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||
{#if isLoading}
|
||||
<span class="spinner"></span>
|
||||
<span class="spinner-small"></span>
|
||||
Logging in...
|
||||
{:else}
|
||||
Login
|
||||
@@ -88,87 +91,16 @@
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-group :global(label) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group :global(input) {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { validateEmail, validatePassword, validatePasswordsMatch } from '$lib/utils/validation';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
@@ -12,44 +13,22 @@
|
||||
submit: { email: string; password: string };
|
||||
}>();
|
||||
|
||||
function validatePassword(pwd: string): { valid: boolean; message: string } {
|
||||
if (pwd.length < 8) {
|
||||
return { valid: false, message: 'Password must be at least 8 characters' };
|
||||
}
|
||||
if (!/[A-Z]/.test(pwd)) {
|
||||
return { valid: false, message: 'Password must contain an uppercase letter' };
|
||||
}
|
||||
if (!/[a-z]/.test(pwd)) {
|
||||
return { valid: false, message: 'Password must contain a lowercase letter' };
|
||||
}
|
||||
if (!/\d/.test(pwd)) {
|
||||
return { valid: false, message: 'Password must contain a number' };
|
||||
}
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
errors = {};
|
||||
|
||||
if (!email) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
const emailValidation = validateEmail(email);
|
||||
if (!emailValidation.valid) {
|
||||
errors.email = emailValidation.message;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
} else {
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
errors.password = passwordValidation.message;
|
||||
}
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
errors.password = passwordValidation.message;
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
errors.confirmPassword = 'Please confirm your password';
|
||||
} else if (password !== confirmPassword) {
|
||||
errors.confirmPassword = 'Passwords do not match';
|
||||
const confirmValidation = validatePasswordsMatch(password, confirmPassword);
|
||||
if (!confirmValidation.valid) {
|
||||
errors.confirmPassword = confirmValidation.message;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
@@ -68,58 +47,63 @@
|
||||
|
||||
<form on:submit={handleSubmit} class="register-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
disabled={isLoading}
|
||||
placeholder="you@example.com"
|
||||
class="form-input"
|
||||
class:error={errors.email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<span class="error-text">{errors.email}</span>
|
||||
<span class="form-error-text">{errors.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.password}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
{#if errors.password}
|
||||
<span class="error-text">{errors.password}</span>
|
||||
<span class="form-error-text">{errors.password}</span>
|
||||
{:else}
|
||||
<span class="help-text"> Must be 8+ characters with uppercase, lowercase, and number </span>
|
||||
<span class="form-help-text">
|
||||
Must be 8+ characters with uppercase, lowercase, and number
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password</label>
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.confirmPassword}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
{#if errors.confirmPassword}
|
||||
<span class="error-text">{errors.confirmPassword}</span>
|
||||
<span class="form-error-text">{errors.confirmPassword}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} class="submit-button">
|
||||
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||
{#if isLoading}
|
||||
<span class="spinner"></span>
|
||||
<span class="spinner-small"></span>
|
||||
Creating account...
|
||||
{:else}
|
||||
Create Account
|
||||
@@ -134,92 +118,16 @@
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-group :global(label) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group :global(input) {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { BoardSummary } from '$lib/types/boards';
|
||||
import { formatDate } from '$lib/utils/format';
|
||||
|
||||
export let board: BoardSummary;
|
||||
|
||||
@@ -15,11 +16,6 @@
|
||||
event.stopPropagation();
|
||||
dispatch('delete');
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
|
||||
34
frontend/src/lib/components/common/Message.svelte
Normal file
34
frontend/src/lib/components/common/Message.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let type: 'error' | 'success' | 'warning' | 'info' = 'info';
|
||||
export let message: string;
|
||||
export let dismissible: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{ dismiss: void }>();
|
||||
|
||||
const icons = {
|
||||
error: '⚠',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
info: 'ℹ',
|
||||
};
|
||||
|
||||
function handleDismiss() {
|
||||
dispatch('dismiss');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="message-banner message-{type}" role="alert">
|
||||
<span class="message-icon">{icons[type]}</span>
|
||||
<span class="message-text">{message}</span>
|
||||
{#if dismissible}
|
||||
<button class="close-btn" on:click={handleDismiss} aria-label="Dismiss">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.message-text {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
76
frontend/src/lib/styles/buttons.css
Normal file
76
frontend/src/lib/styles/buttons.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Shared button styles
|
||||
* Consistent button styling across the application
|
||||
*/
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
75
frontend/src/lib/styles/form.css
Normal file
75
frontend/src/lib/styles/form.css
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared form styles
|
||||
* Used across all forms to maintain consistency and reduce duplication
|
||||
*/
|
||||
|
||||
/* Form containers */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-label .required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Inputs and textareas */
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error,
|
||||
.form-textarea.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input:disabled,
|
||||
.form-textarea:disabled {
|
||||
background-color: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Help and error text */
|
||||
.form-help-text {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
display: block;
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
278
frontend/src/lib/styles/global.css
Normal file
278
frontend/src/lib/styles/global.css
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Global styles - Import this once in app layout
|
||||
* Contains all shared form, button, loading, and message styles
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
FORM STYLES
|
||||
============================================ */
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-label .required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error,
|
||||
.form-textarea.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input:disabled,
|
||||
.form-textarea:disabled {
|
||||
background-color: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-help-text {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
display: block;
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BUTTON STYLES
|
||||
============================================ */
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOADING STYLES
|
||||
============================================ */
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-medium {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.loading-container .spinner {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MESSAGE / ALERT STYLES
|
||||
============================================ */
|
||||
|
||||
.message-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.message-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-banner .close-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.message-banner .close-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.message-error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.message-error .message-icon,
|
||||
.message-error .close-btn {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.message-success {
|
||||
background: #d1fae5;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.message-success .message-icon,
|
||||
.message-success .close-btn {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.message-warning {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.message-warning .message-icon,
|
||||
.message-warning .close-btn {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.message-info {
|
||||
background: #dbeafe;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.message-info .message-icon,
|
||||
.message-info .close-btn {
|
||||
color: #1e40af;
|
||||
}
|
||||
49
frontend/src/lib/styles/loading.css
Normal file
49
frontend/src/lib/styles/loading.css
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Shared loading/spinner styles
|
||||
*/
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-medium {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.loading-container .spinner {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
80
frontend/src/lib/styles/messages.css
Normal file
80
frontend/src/lib/styles/messages.css
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Shared message/alert/banner styles
|
||||
*/
|
||||
|
||||
.message-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.message-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-banner .close-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.message-banner .close-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.message-error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.message-error .message-icon,
|
||||
.message-error .close-btn {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Success message */
|
||||
.message-success {
|
||||
background: #d1fae5;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.message-success .message-icon,
|
||||
.message-success .close-btn {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
/* Warning message */
|
||||
.message-warning {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.message-warning .message-icon,
|
||||
.message-warning .close-btn {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* Info message */
|
||||
.message-info {
|
||||
background: #dbeafe;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.message-info .message-icon,
|
||||
.message-info .close-btn {
|
||||
color: #1e40af;
|
||||
}
|
||||
78
frontend/src/lib/utils/format.ts
Normal file
78
frontend/src/lib/utils/format.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Formatting utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format date to readable string
|
||||
*/
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date with time
|
||||
*/
|
||||
export function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
export function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
|
||||
|
||||
return formatDate(dateString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size to human-readable string
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluralize word based on count
|
||||
*/
|
||||
export function pluralize(count: number, singular: string, plural?: string): string {
|
||||
if (count === 1) return singular;
|
||||
return plural || `${singular}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
125
frontend/src/lib/utils/validation.ts
Normal file
125
frontend/src/lib/utils/validation.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Shared validation utilities
|
||||
*/
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function validateEmail(email: string): ValidationResult {
|
||||
if (!email) {
|
||||
return { valid: false, message: 'Email is required' };
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return { valid: false, message: 'Please enter a valid email address' };
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
* Requirements: 8+ chars, uppercase, lowercase, number
|
||||
*/
|
||||
export function validatePassword(password: string): ValidationResult {
|
||||
if (!password) {
|
||||
return { valid: false, message: 'Password is required' };
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return { valid: false, message: 'Password must be at least 8 characters' };
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
return { valid: false, message: 'Password must contain an uppercase letter' };
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
return { valid: false, message: 'Password must contain a lowercase letter' };
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
return { valid: false, message: 'Password must contain a number' };
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate passwords match
|
||||
*/
|
||||
export function validatePasswordsMatch(
|
||||
password: string,
|
||||
confirmPassword: string
|
||||
): ValidationResult {
|
||||
if (!confirmPassword) {
|
||||
return { valid: false, message: 'Please confirm your password' };
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return { valid: false, message: 'Passwords do not match' };
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate board title
|
||||
*/
|
||||
export function validateBoardTitle(title: string): ValidationResult {
|
||||
if (!title || !title.trim()) {
|
||||
return { valid: false, message: 'Title is required' };
|
||||
}
|
||||
|
||||
if (title.length > 255) {
|
||||
return { valid: false, message: 'Title must be 255 characters or less' };
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate board description
|
||||
*/
|
||||
export function validateBoardDescription(description: string): ValidationResult {
|
||||
if (description && description.length > 1000) {
|
||||
return { valid: false, message: 'Description must be 1000 characters or less' };
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required field
|
||||
*/
|
||||
export function validateRequired(value: string, fieldName: string = 'Field'): ValidationResult {
|
||||
if (!value || !value.trim()) {
|
||||
return { valid: false, message: `${fieldName} is required` };
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate max length
|
||||
*/
|
||||
export function validateMaxLength(
|
||||
value: string,
|
||||
maxLength: number,
|
||||
fieldName: string = 'Field'
|
||||
): ValidationResult {
|
||||
if (value && value.length > maxLength) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `${fieldName} must be ${maxLength} characters or less`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
<script>
|
||||
import '$lib/styles/global.css';
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
|
||||
Reference in New Issue
Block a user