Add share

This commit is contained in:
Халимов Рустам
2025-12-27 19:04:48 +03:00
parent 50cdd3a945
commit 8010c172c7
3 changed files with 214 additions and 169 deletions

View File

@@ -1,14 +1,16 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { AppConfig, LayoutSplits, GeneratedPart } from './types';
// Убедись, что этот файл существует по этому пути
// Импорт должен быть правильным, проверь путь
import { calculateParts } from './services/geometryGenerator';
import { ConfigStep } from './components/ConfigStep';
import { LayoutStep } from './components/LayoutStep';
import { PreviewStep } from './components/PreviewStep';
import { parseShareUrl } from './utils/share'; // <--- Новый импорт
import { ChevronRight, ChevronLeft, Box } from 'lucide-react';
const App = () => {
const [step, setStep] = useState(1);
const [isLoadedFromUrl, setIsLoadedFromUrl] = useState(false);
// State
const [config, setConfig] = useState<AppConfig>({
@@ -22,7 +24,22 @@ const App = () => {
y: []
});
// Derived State: Parts (Мгновенный пересчет без API)
// --- ЛОГИКА ВОССТАНОВЛЕНИЯ ИЗ ССЫЛКИ ---
useEffect(() => {
const sharedData = parseShareUrl();
if (sharedData) {
setConfig(sharedData.config);
setSplits(sharedData.splits);
setStep(3); // Сразу прыгаем на превью
setIsLoadedFromUrl(true);
// Очищаем URL, чтобы он не мозолил глаза (опционально)
window.history.replaceState({}, '', window.location.pathname);
}
}, []);
// ---------------------------------------
// Derived State: Parts
const parts: GeneratedPart[] = useMemo(() => {
return calculateParts(config, splits);
}, [config, splits]);
@@ -38,7 +55,7 @@ const App = () => {
</div>
<div>
<h1 className="text-xl font-bold tracking-tight">PrintFit</h1>
<p className="text-xs text-gray-400">Генератор без ИИ</p>
<p className="text-xs text-gray-400">Генератор органайзеров</p>
</div>
</div>
@@ -77,7 +94,8 @@ const App = () => {
{step === 3 && (
<div className="h-[calc(100vh-200px)] min-h-[600px] animate-fade-in">
<PreviewStep parts={parts} config={config} />
{/* Передаем splits, чтобы кнопка Share могла их использовать */}
<PreviewStep parts={parts} config={config} splits={splits} />
</div>
)}
</main>
@@ -109,6 +127,8 @@ const App = () => {
onClick={() => {
setStep(1);
setSplits({x: [], y: []});
// Сбрасываем URL если он был
window.history.replaceState({}, '', window.location.pathname);
}}
className="flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-gray-400 hover:text-white transition-colors border border-transparent hover:border-slate-700"
>

View File

@@ -1,19 +1,17 @@
import React, { useMemo, Suspense, useEffect, useRef, useState } from 'react';
import React, { Suspense, useEffect, useRef, useState } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Center, Environment } from '@react-three/drei';
import * as THREE from 'three';
import JSZip from 'jszip';
import { AppConfig, GeneratedPart } from '../types';
import { createBinGeometry, exportSTL, generateSTL } from '../services/geometryGenerator';
import { Download, Package, Info, Loader2 } from 'lucide-react';
import { AppConfig, GeneratedPart, LayoutSplits } from '../types';
import { createBinGeometry, generateSTL, exportSTL } from '../services/geometryGenerator';
import { Download, Package, Info, Loader2, Share2, Check, Ruler } from 'lucide-react';
import { generateShareUrl } from '../utils/share'; // <--- Импорт утилиты
// --- 3D Helper Components ---
// Каркас ящика (только ребра, без диагоналей)
// ... (DrawerFrame и BinMesh остаются без изменений) ...
const DrawerFrame = ({ config }: { config: AppConfig }) => {
const { width, depth, height } = config.drawer;
const offset = 0.5; // Небольшой отступ наружу
const offset = 0.5;
return (
<group position={[width / 2, height / 2, depth / 2]}>
<lineSegments>
@@ -24,8 +22,6 @@ const DrawerFrame = ({ config }: { config: AppConfig }) => {
)
}
// --- Bin Component ---
interface BinMeshProps {
part: GeneratedPart;
thickness: number;
@@ -34,29 +30,15 @@ interface BinMeshProps {
}
const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, isSelected, onClick }) => {
// REMOVED ARTIFICIAL GAP: The part dimensions now include the printer tolerance physically.
// The gap will be visible naturally because part.width is smaller than grid size.
// Генерируем реальную геометрию, как для STL
const geometry = useMemo(() => {
const geometry = React.useMemo(() => {
return createBinGeometry(part.width, part.depth, part.height, thickness);
}, [part, thickness]);
return (
<group position={[part.x + part.width/2, 0, part.y + part.depth/2]}>
{/* Основной меш ячейки */}
<mesh
geometry={geometry}
onClick={(e) => { e.stopPropagation(); onClick(); }}
>
<meshStandardMaterial
color={isSelected ? '#f59e0b' : part.color}
roughness={0.5}
metalness={0.1}
/>
<mesh geometry={geometry} onClick={(e) => { e.stopPropagation(); onClick(); }}>
<meshStandardMaterial color={isSelected ? '#f59e0b' : part.color} roughness={0.5} metalness={0.1}/>
</mesh>
{/* Подсветка выделения (Bounding Box) */}
{isSelected && (
<lineSegments position={[0, part.height/2, 0]}>
<edgesGeometry args={[new THREE.BoxGeometry(part.width, part.height, part.depth)]} />
@@ -67,23 +49,23 @@ const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, isSelected, onClick
);
};
// --- Основной компонент ---
interface Props {
parts: GeneratedPart[];
config: AppConfig;
splits: LayoutSplits; // <--- Добавили splits в пропсы
}
export const PreviewStep: React.FC<Props> = ({ parts, config }) => {
export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isZipping, setIsZipping] = useState(false);
const [shareUrlCopied, setShareUrlCopied] = useState(false);
const itemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
// Автопрокрутка к выбранному элементу
useEffect(() => {
if (selectedId && itemRefs.current[selectedId]) {
itemRefs.current[selectedId]?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
itemRefs.current[selectedId]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [selectedId]);
@@ -96,162 +78,151 @@ export const PreviewStep: React.FC<Props> = ({ parts, config }) => {
const handleDownloadAll = async () => {
if (isZipping) return;
setIsZipping(true);
try {
console.log("Starting ZIP generation...");
// Ensure JSZip is available
if (typeof JSZip === 'undefined' && !JSZip) {
throw new Error("Библиотека JSZip не загружена.");
}
const zip = new JSZip();
// Генерация STL для каждой части и добавление в архив
parts.forEach(part => {
const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
const stlData = generateSTL(mesh);
// stlData is Uint8Array or string here, which is supported
zip.file(`${part.name.replace(/\s+/g, '_')}.stl`, stlData);
});
console.log("Files added to ZIP. Generating blob...");
// Генерация самого ZIP файла
const content = await zip.generateAsync({ type: "blob" });
console.log("ZIP blob generated. Size:", content.size);
// Скачивание
const link = document.createElement('a');
link.href = URL.createObjectURL(content);
link.download = "PrintFit_Project.zip";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (e: any) {
console.error("Failed to create zip archive", e);
alert(`Ошибка при создании архива: ${e.message || 'Неизвестная ошибка'}`);
alert(`Ошибка архивации: ${e.message}`);
} finally {
setIsZipping(false);
}
};
return (
<div className="flex flex-col lg:flex-row h-full gap-6">
{/* 3D Viewer */}
<div className="flex-1 bg-slate-900 rounded-xl overflow-hidden shadow-2xl border border-slate-800 relative min-h-[400px]">
<div className="absolute top-4 right-4 z-10 bg-black/60 p-3 rounded-lg text-xs text-gray-300 backdrop-blur pointer-events-none border border-slate-700">
<div className="flex items-center gap-2 mb-1 text-primary font-bold">
<Info size={14} /> Управление
</div>
<ul className="space-y-1">
<li> ЛКМ: Вращение</li>
<li> ПКМ: Перемещение</li>
<li> Скролл: Масштаб</li>
<li className="text-accent mt-2 font-semibold"> Клик по детали для выбора</li>
</ul>
</div>
<Canvas
shadows
dpr={[1, 2]}
camera={{
position: [config.drawer.width * 1.5, config.drawer.height * 3, config.drawer.depth * 1.5],
fov: 45,
near: 1,
far: 20000
}}
>
<Suspense fallback={null}>
<color attach="background" args={['#0f172a']} />
<ambientLight intensity={0.7} />
<directionalLight position={[100, 200, 50]} intensity={1.2} />
<directionalLight position={[-100, 100, -50]} intensity={0.5} />
<Environment preset="city" />
// Логика кнопки "Поделиться"
const handleShare = () => {
const url = generateShareUrl(config, splits);
navigator.clipboard.writeText(url).then(() => {
setShareUrlCopied(true);
setTimeout(() => setShareUrlCopied(false), 2000);
});
};
<Center>
<group>
<DrawerFrame config={config} />
{parts.map(part => (
<BinMesh
key={part.id}
part={part}
thickness={config.wallThickness}
isSelected={selectedId === part.id}
onClick={() => setSelectedId(part.id)}
/>
))}
</group>
</Center>
<OrbitControls makeDefault minDistance={10} maxDistance={10000} />
</Suspense>
</Canvas>
return (
<div className="flex flex-col h-full">
{/* 1. ВЕРХНЯЯ ПАНЕЛЬ С РАЗМЕРАМИ И КНОПКОЙ SHARE */}
<div className="flex flex-col md:flex-row justify-between items-center bg-slate-800/80 p-4 rounded-xl border border-slate-700 mb-4 gap-4 backdrop-blur-sm shadow-lg">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-gray-300">
<Ruler className="text-primary" size={20} />
<span className="font-medium text-sm uppercase tracking-wide opacity-70">Размеры ящика:</span>
</div>
<div className="flex gap-4 font-mono text-lg font-bold text-white">
<div className="flex items-center gap-1">
<span className="text-slate-500 text-xs">W:</span> {config.drawer.width}
</div>
<div className="flex items-center gap-1">
<span className="text-slate-500 text-xs">D:</span> {config.drawer.depth}
</div>
<div className="flex items-center gap-1">
<span className="text-slate-500 text-xs">H:</span> {config.drawer.height}
</div>
<span className="text-xs text-slate-500 self-end mb-1">мм</span>
</div>
</div>
<button
onClick={handleShare}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all border
${shareUrlCopied
? 'bg-green-600/20 border-green-500/50 text-green-400'
: 'bg-slate-700 hover:bg-slate-600 border-slate-600 text-white'
}
`}
>
{shareUrlCopied ? <Check size={16} /> : <Share2 size={16} />}
{shareUrlCopied ? 'Ссылка скопирована!' : 'Поделиться проектом'}
</button>
</div>
{/* Sidebar List */}
<div className="w-full lg:w-96 bg-slate-900 p-6 rounded-xl border border-slate-800 flex flex-col h-full shadow-xl">
<div className="flex justify-between items-center mb-6 shrink-0">
<h2 className="text-xl font-bold flex items-center gap-2 text-primary">
<Package size={24} /> Детали ({parts.length})
</h2>
<button
onClick={handleDownloadAll}
disabled={isZipping || parts.length === 0}
className={`bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-1 transition-all active:scale-95 disabled:opacity-50 disabled:scale-100 disabled:cursor-not-allowed`}
>
{isZipping ? (
<>
<Loader2 size={16} className="animate-spin" /> ZIP...
</>
) : (
<>
<Download size={16} /> Скачать все
</>
)}
</button>
<div className="flex flex-col lg:flex-row h-full gap-6 relative flex-1 min-h-0">
{/* 3D Viewer */}
<div className="flex-1 bg-slate-900 rounded-xl overflow-hidden shadow-2xl border border-slate-800 relative min-h-[400px]">
<div className="absolute top-4 right-4 z-10 bg-black/60 p-3 rounded-lg text-xs text-gray-300 backdrop-blur pointer-events-none border border-slate-700">
<div className="flex items-center gap-2 mb-1 text-primary font-bold">
<Info size={14} /> Управление
</div>
<ul className="space-y-1">
<li> ЛКМ: Вращение</li>
<li> ПКМ: Перемещение</li>
<li> Скролл: Масштаб</li>
</ul>
</div>
<Canvas shadows dpr={[1, 2]} camera={{ position: [config.drawer.width * 1.5, config.drawer.height * 3, config.drawer.depth * 1.5], fov: 45 }}>
<Suspense fallback={null}>
<color attach="background" args={['#0f172a']} />
<ambientLight intensity={0.7} />
<directionalLight position={[100, 200, 50]} intensity={1.2} />
<Environment preset="city" />
<Center>
<group>
<DrawerFrame config={config} />
{parts.map(part => (
<BinMesh
key={part.id} part={part} thickness={config.wallThickness}
isSelected={selectedId === part.id}
onClick={() => setSelectedId(part.id)}
/>
))}
</group>
</Center>
<OrbitControls makeDefault minDistance={10} maxDistance={10000} />
</Suspense>
</Canvas>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3 custom-scrollbar">
{parts.map(part => (
<div
key={part.id}
ref={(el) => { itemRefs.current[part.id] = el }}
className={`p-4 rounded-lg border transition-all cursor-pointer group ${selectedId === part.id ? 'bg-slate-800 border-accent shadow-md shadow-accent/10 ring-1 ring-accent' : 'bg-slate-800/50 border-slate-700 hover:border-slate-500 hover:bg-slate-800'}`}
onClick={() => setSelectedId(part.id)}
>
<div className="flex justify-between items-start mb-2">
<span className="font-semibold text-gray-200 group-hover:text-white transition-colors">{part.name}</span>
<div
className="w-3 h-3 rounded-full border border-white/10"
style={{ backgroundColor: part.color }}
/>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-400 mb-3">
<div className="bg-slate-900/50 p-1.5 rounded">
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Ширина</span>
{part.width.toFixed(1)}
</div>
<div className="bg-slate-900/50 p-1.5 rounded">
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Глубина</span>
{part.depth.toFixed(1)}
</div>
<div className="bg-slate-900/50 p-1.5 rounded">
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Высота</span>
{part.height.toFixed(1)}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleDownload(part); }}
className="w-full py-2 bg-slate-700 hover:bg-primary hover:text-white text-gray-300 rounded text-xs flex items-center justify-center gap-2 transition-colors font-medium"
>
<Download size={14} /> Скачать STL
</button>
</div>
))}
{/* Sidebar List */}
<div className="w-full lg:w-96 bg-slate-900 p-6 rounded-xl border border-slate-800 flex flex-col h-full shadow-xl">
<div className="flex justify-between items-center mb-6 shrink-0">
<h2 className="text-xl font-bold flex items-center gap-2 text-primary">
<Package size={24} /> Детали ({parts.length})
</h2>
<button
onClick={handleDownloadAll}
disabled={isZipping || parts.length === 0}
className="bg-slate-700 hover:bg-slate-600 text-white px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-1 transition-all"
>
{isZipping ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
Архив
</button>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3 custom-scrollbar">
{parts.map(part => (
<div
key={part.id}
ref={(el) => { itemRefs.current[part.id] = el }}
className={`p-4 rounded-lg border transition-all cursor-pointer group ${selectedId === part.id ? 'bg-slate-800 border-accent shadow-md shadow-accent/10 ring-1 ring-accent' : 'bg-slate-800/50 border-slate-700 hover:border-slate-500 hover:bg-slate-800'}`}
onClick={() => setSelectedId(part.id)}
>
<div className="flex justify-between items-center mb-2">
<span className="font-semibold text-gray-200">{part.name}</span>
<div className="w-3 h-3 rounded-full border border-white/10" style={{ backgroundColor: part.color }} />
</div>
<button
onClick={(e) => { e.stopPropagation(); handleDownload(part); }}
className="w-full py-2.5 bg-slate-700 hover:bg-primary hover:text-white text-gray-300 rounded text-sm flex items-center justify-center gap-2 transition-colors font-medium mt-2"
>
<Download size={16} /> Скачать STL
</button>
</div>
))}
</div>
</div>
</div>
</div>

54
src/utils/share.ts Normal file
View File

@@ -0,0 +1,54 @@
import { AppConfig, LayoutSplits } from '../types';
interface ShareData {
c: AppConfig; // config
s: LayoutSplits; // splits
}
/**
* Генерирует ссылку на текущее состояние
*/
export const generateShareUrl = (config: AppConfig, splits: LayoutSplits): string => {
try {
// 1. Собираем объект данных
const data: ShareData = { c: config, s: splits };
// 2. Превращаем в JSON строку
const jsonString = JSON.stringify(data);
// 3. Кодируем в Base64 (чтобы URL был чище)
// btoa работает только с ASCII, поэтому для надежности кодируем через URI
const base64 = btoa(encodeURIComponent(jsonString));
// 4. Формируем полный URL
return `${window.location.origin}?share=${base64}`;
} catch (e) {
console.error('Ошибка генерации ссылки:', e);
return '';
}
};
/**
* Пытается восстановить состояние из URL
*/
export const parseShareUrl = (): { config: AppConfig, splits: LayoutSplits } | null => {
try {
const params = new URLSearchParams(window.location.search);
const shareParam = params.get('share');
if (!shareParam) return null;
// Декодируем обратно
const jsonString = decodeURIComponent(atob(shareParam));
const data: ShareData = JSON.parse(jsonString);
// Простая валидация, что данные похожи на правду
if (data.c && data.s && Array.isArray(data.s.x)) {
return { config: data.c, splits: data.s };
}
return null;
} catch (e) {
console.error('Ошибка чтения ссылки:', e);
return null;
}
};