feat: added app routing

This commit is contained in:
Тимур Городецкий
2026-08-03 17:58:24 +05:00
parent e6203e7093
commit dc60d2dbee
22 changed files with 460 additions and 61 deletions
@@ -0,0 +1,75 @@
@import '../../../../../styles/design-tokens';
.zone {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
border: 1.5px dashed $gray-300;
border-radius: $r-md;
color: $gray-400;
font-size: 12.5px;
cursor: pointer;
transition: .18s;
&:hover,
&.over {
border-color: $gold-500;
color: $gold-600;
background: $gold-50;
}
}
.hidden {
display: none;
}
.list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
font-size: 13px;
color: $gray-700;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
color: $gray-400;
font-size: 11.5px;
flex-shrink: 0;
}
.remove {
display: inline-flex;
padding: 2px;
border: 0;
background: transparent;
color: $gray-400;
cursor: pointer;
flex-shrink: 0;
&:hover {
color: $red-500;
}
}
@@ -0,0 +1,87 @@
import * as React from 'react';
import { useState, useRef } from 'react';
import styles from './FileDropzone.module.scss';
import { Icon } from '../Icon/Icon';
interface IFileDropzoneProps {
files: File[];
onChange: (files: File[]) => void;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
/** Зона выбора файлов (перетащить/кликнуть) со списком выбранных и удалением. */
export const FileDropzone: React.FC<IFileDropzoneProps> = ({ files, onChange }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState<boolean>(false);
const addFiles = (incoming: FileList | null): void => {
if (!incoming || incoming.length === 0) {
return;
}
onChange([...files, ...Array.from(incoming)]);
};
const removeAt = (index: number): void => {
onChange(files.filter((_, i) => i !== index));
};
const zoneClasses = [styles.zone, dragOver ? styles.over : ''].filter(Boolean).join(' ');
return (
<div>
<div
className={zoneClasses}
onClick={() => inputRef.current && inputRef.current.click()}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
addFiles(e.dataTransfer.files);
}}
>
<Icon name="upload" size="sm" />
<span>Нажмите или перетащите файлы сюда</span>
</div>
<input
ref={inputRef}
type="file"
multiple
className={styles.hidden}
onChange={(e) => {
addFiles(e.target.files);
e.target.value = ''; // чтобы можно было выбрать тот же файл повторно
}}
/>
{files.length > 0 && (
<ul className={styles.list}>
{files.map((file, index) => (
<li key={`${file.name}-${index}`} className={styles.item}>
<Icon name="document" size="sm" />
<span className={styles.name}>{file.name}</span>
<span className={styles.size}>{formatSize(file.size)}</span>
<button
type="button"
className={styles.remove}
aria-label="Удалить файл"
onClick={() => removeAt(index)}
>
<Icon name="close" size="sm" />
</button>
</li>
))}
</ul>
)}
</div>
);
};