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:
2026-04-05 22:34:03 +03:00
parent b84807bbdb
commit af8b9fe00f
188 changed files with 35795 additions and 0 deletions
+33
View File
@@ -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>
);
}
+81
View File
@@ -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"
>
&#10005;
</button>
)}
</div>
);
}
+45
View File
@@ -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>
);
}
+136
View File
@@ -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"
>
&#10005;
</button>
</div>
<div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
</div>
);
}
+70
View File
@@ -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"
>
&#10005;
</button>
</div>
);
}
+86
View File
@@ -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;
}
}