feat: complete house plan maker application
Full-featured house/apartment floor plan editor with: - Turborepo monorepo (React/Vite client, Fastify/Prisma server, shared Zod schemas) - 2D room editor with walls, doors, windows, furniture, electrical elements - 3D room preview with Three.js (auto-hide nearest walls, bird's eye default) - Wall projection views with interactive drag (elevation, position) - Apartment floor plan view with room positioning - Copy/paste, alignment tools, measurement tool, annotations - Item-attached annotations with leader lines (visible on projections) - Door open direction (LEFT/RIGHT/INWARD/OUTWARD) with swing arc - Floor type textures (wood, tile, concrete, laminate, herringbone) - Wall color picker for 3D view - Furniture: bed, desk, wardrobe, sofa, table, chair, shelf, nightstand, dresser, bookcase, TV (with stand toggle), AC unit - Furniture elevation support (wall-mounted items) - Auto-save with dirty state tracking, batch save API - Rotation-aware collision detection (SAT/OBB) with 3D elevation check - Rotation-aware hit testing - i18n (English/Russian) with locale-aware number formatting - Dark mode with system preference detection - Undo/redo, keyboard shortcuts, scale bar - PDF/PNG/JSON export and JSON import - Focus trap modal, toast notifications, tooltips - Responsive layout with overlay palettes
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { type ButtonHTMLAttributes } from 'react';
|
||||
import styles from './button.module.css';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const classNames = [
|
||||
styles.button,
|
||||
styles[variant],
|
||||
styles[size],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<button className={classNames} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { type HTMLAttributes, type ReactNode } from 'react';
|
||||
import styles from './card.module.css';
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
readonly interactive?: boolean;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
interactive = false,
|
||||
className,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}: CardProps) {
|
||||
const classNames = [
|
||||
styles.card,
|
||||
interactive ? styles.interactive : undefined,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const handleKeyDown = interactive && onClick
|
||||
? (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onClick(event as unknown as React.MouseEvent<HTMLDivElement>);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={interactive && onClick ? 0 : undefined}
|
||||
role={interactive && onClick ? 'button' : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={[styles.header, className].filter(Boolean).join(' ')} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={[styles.body, className].filter(Boolean).join(' ')} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardFooter({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={[styles.footer, className].filter(Boolean).join(' ')} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import styles from './empty-state.module.css';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
{icon && <div className={styles.icon}>{icon}</div>}
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
{description && <p className={styles.description}>{description}</p>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './error-banner.module.css';
|
||||
|
||||
interface ErrorBannerProps {
|
||||
readonly message: string;
|
||||
readonly onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ message, onDismiss }: ErrorBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles.banner} role="alert">
|
||||
<span className={styles.icon} aria-hidden="true">!</span>
|
||||
<span className={styles.message}>{message}</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className={styles.dismiss}
|
||||
onClick={onDismiss}
|
||||
aria-label={t('common.dismissError')}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type InputHTMLAttributes, useId } from 'react';
|
||||
import styles from './input.module.css';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
id: externalId,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const generatedId = useId();
|
||||
const inputId = externalId ?? generatedId;
|
||||
|
||||
const fieldClassNames = [
|
||||
styles.field,
|
||||
error ? styles.hasError : undefined,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div className={fieldClassNames}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={styles.label}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input id={inputId} className={styles.input} aria-invalid={!!error} {...rest} />
|
||||
{error && (
|
||||
<span className={styles.error} role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{hint && !error && <span className={styles.hint}>{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './loading-spinner.module.css';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 'md' }: LoadingSpinnerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.container, size !== 'md' ? styles[size] : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="status"
|
||||
aria-label={t('common.loading')}
|
||||
>
|
||||
<div className={styles.spinner} />
|
||||
<span className="sr-only">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { type ReactNode, useEffect, useCallback, useRef } from 'react';
|
||||
import styles from './modal.module.css';
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
interface ModalProps {
|
||||
readonly open: boolean;
|
||||
readonly onClose: () => void;
|
||||
readonly title: string;
|
||||
readonly children: ReactNode;
|
||||
readonly footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children, footer }: ModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const mouseDownTargetRef = useRef<EventTarget | null>(null);
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Capture the element that had focus when the modal opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement as HTMLElement | null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Auto-focus first focusable element and restore focus on close
|
||||
useEffect(() => {
|
||||
if (open && modalRef.current) {
|
||||
const focusable = modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
if (focusable.length > 0) {
|
||||
focusable[0].focus();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!open && triggerRef.current && typeof triggerRef.current.focus === 'function') {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus trap: cycle Tab/Shift+Tab within the modal
|
||||
if (event.key === 'Tab' && modalRef.current) {
|
||||
const focusable = modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, handleKeyDown]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropMouseDown = (event: React.MouseEvent) => {
|
||||
mouseDownTargetRef.current = event.target;
|
||||
};
|
||||
|
||||
const handleBackdropMouseUp = (event: React.MouseEvent) => {
|
||||
if (
|
||||
event.target === backdropRef.current &&
|
||||
mouseDownTargetRef.current === backdropRef.current
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
mouseDownTargetRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className={styles.backdrop}
|
||||
onMouseDown={handleBackdropMouseDown}
|
||||
onMouseUp={handleBackdropMouseUp}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div ref={modalRef} className={styles.modal}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<button
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './toast.module.css';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export interface ToastItem {
|
||||
readonly id: string;
|
||||
readonly message: string;
|
||||
readonly type: ToastType;
|
||||
readonly duration: number;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
readonly toasts: readonly ToastItem[];
|
||||
readonly onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onDismiss }: ToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={styles.container} aria-live="polite" aria-label={t('toast.notifications')}>
|
||||
{toasts.map((toast) => (
|
||||
<ToastEntry key={toast.id} toast={toast} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastEntryProps {
|
||||
readonly toast: ToastItem;
|
||||
readonly onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
function ToastEntry({ toast, onDismiss }: ToastEntryProps) {
|
||||
const { t } = useTranslation();
|
||||
const [exiting, setExiting] = useState(false);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setExiting(true);
|
||||
setTimeout(() => onDismiss(toast.id), 200);
|
||||
}, [toast.id, onDismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
handleDismiss();
|
||||
}, toast.duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.duration, handleDismiss]);
|
||||
|
||||
const typeClass = styles[toast.type] ?? '';
|
||||
const className = [styles.toast, typeClass, exiting ? styles.exiting : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div className={className} role="status">
|
||||
<span className={styles.message}>{toast.message}</span>
|
||||
<button
|
||||
className={styles.dismiss}
|
||||
onClick={handleDismiss}
|
||||
aria-label={t('toast.dismiss')}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from 'react';
|
||||
import styles from './tooltip.module.css';
|
||||
|
||||
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
interface TooltipProps {
|
||||
readonly content: string;
|
||||
readonly position?: TooltipPosition;
|
||||
readonly delay?: number;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
position = 'top',
|
||||
delay = 300,
|
||||
children,
|
||||
}: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState<TooltipPosition>(position);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipId = useId();
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const show = useCallback(() => {
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Adjust position if element is near viewport edge
|
||||
if (wrapperRef.current) {
|
||||
const rect = wrapperRef.current.getBoundingClientRect();
|
||||
let resolved = position;
|
||||
if (position === 'top' && rect.top < 40) {
|
||||
resolved = 'bottom';
|
||||
} else if (position === 'bottom' && rect.bottom > window.innerHeight - 40) {
|
||||
resolved = 'top';
|
||||
} else if (position === 'left' && rect.left < 100) {
|
||||
resolved = 'right';
|
||||
} else if (position === 'right' && rect.right > window.innerWidth - 100) {
|
||||
resolved = 'left';
|
||||
}
|
||||
setAdjustedPosition(resolved);
|
||||
}
|
||||
setVisible(true);
|
||||
}, delay);
|
||||
}, [clearTimer, delay, position]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
clearTimer();
|
||||
setVisible(false);
|
||||
}, [clearTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
return clearTimer;
|
||||
}, [clearTimer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.wrapper}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
>
|
||||
<span aria-describedby={visible ? tooltipId : undefined}>
|
||||
{children}
|
||||
</span>
|
||||
{visible && (
|
||||
<div
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
className={`${styles.tooltip} ${styles[adjustedPosition]}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sizes ── */
|
||||
|
||||
.sm {
|
||||
height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.md {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.lg {
|
||||
height: 42px;
|
||||
padding: 0 var(--space-6);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
/* ── Variants ── */
|
||||
|
||||
.primary {
|
||||
background-color: var(--color-accent-600);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.primary:active:not(:disabled) {
|
||||
background-color: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-hover);
|
||||
border-color: var(--color-neutral-400);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ghost:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background-color: var(--color-danger-600);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.danger:hover:not(:disabled) {
|
||||
background-color: var(--color-danger-700);
|
||||
}
|
||||
|
||||
.danger:active:not(:disabled) {
|
||||
background-color: var(--color-danger-700);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.card {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: box-shadow var(--transition-base),
|
||||
border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.interactive:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 360px;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background-color: var(--color-danger-50);
|
||||
border: 1px solid var(--color-danger-100);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-danger-700);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-danger-600);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.dismiss:hover {
|
||||
background-color: var(--color-danger-100);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.input:hover:not(:disabled) {
|
||||
border-color: var(--color-neutral-400);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent-500);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-100);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.hasError .input {
|
||||
border-color: var(--color-danger-500);
|
||||
}
|
||||
|
||||
.hasError .input:focus {
|
||||
box-shadow: 0 0 0 3px var(--color-danger-100);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-danger-600);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid var(--color-neutral-200);
|
||||
border-top-color: var(--color-accent-600);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.sm .spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.lg .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: var(--z-modal-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
animation: fadeIn var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-modal);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp var(--transition-base) ease;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-lg);
|
||||
transition: background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
right: var(--space-6);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: var(--space-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-normal);
|
||||
min-width: 240px;
|
||||
max-width: 400px;
|
||||
pointer-events: auto;
|
||||
animation: toastIn var(--transition-base) ease forwards;
|
||||
}
|
||||
|
||||
.toast.exiting {
|
||||
animation: toastOut var(--transition-base) ease forwards;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: var(--color-success-50);
|
||||
border: 1px solid var(--color-success-500);
|
||||
color: var(--color-success-700);
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: var(--color-danger-50);
|
||||
border: 1px solid var(--color-danger-500);
|
||||
color: var(--color-danger-700);
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: var(--color-accent-50);
|
||||
border: 1px solid var(--color-accent-400);
|
||||
color: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-neutral-800);
|
||||
color: var(--color-neutral-0);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-normal);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
animation: tooltipIn var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.top {
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.left {
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.right {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
@keyframes tooltipIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user