Merge pull request 'share' (#1) from share into main
Reviewed-on: http://192.168.1.114:3000/rust/BoxGenerator/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
+25
-5
@@ -1,14 +1,16 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { AppConfig, LayoutSplits, GeneratedPart } from './types';
|
import { AppConfig, LayoutSplits, GeneratedPart } from './types';
|
||||||
// Убедись, что этот файл существует по этому пути
|
// Импорт должен быть правильным, проверь путь
|
||||||
import { calculateParts } from './services/geometryGenerator';
|
import { calculateParts } from './services/geometryGenerator';
|
||||||
import { ConfigStep } from './components/ConfigStep';
|
import { ConfigStep } from './components/ConfigStep';
|
||||||
import { LayoutStep } from './components/LayoutStep';
|
import { LayoutStep } from './components/LayoutStep';
|
||||||
import { PreviewStep } from './components/PreviewStep';
|
import { PreviewStep } from './components/PreviewStep';
|
||||||
|
import { parseShareUrl } from './utils/share'; // <--- Новый импорт
|
||||||
import { ChevronRight, ChevronLeft, Box } from 'lucide-react';
|
import { ChevronRight, ChevronLeft, Box } from 'lucide-react';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
|
const [isLoadedFromUrl, setIsLoadedFromUrl] = useState(false);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [config, setConfig] = useState<AppConfig>({
|
const [config, setConfig] = useState<AppConfig>({
|
||||||
@@ -22,7 +24,22 @@ const App = () => {
|
|||||||
y: []
|
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(() => {
|
const parts: GeneratedPart[] = useMemo(() => {
|
||||||
return calculateParts(config, splits);
|
return calculateParts(config, splits);
|
||||||
}, [config, splits]);
|
}, [config, splits]);
|
||||||
@@ -38,7 +55,7 @@ const App = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold tracking-tight">PrintFit</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +94,8 @@ const App = () => {
|
|||||||
|
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div className="h-[calc(100vh-200px)] min-h-[600px] animate-fade-in">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@@ -109,6 +127,8 @@ const App = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStep(1);
|
setStep(1);
|
||||||
setSplits({x: [], y: []});
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
+174
-160
@@ -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 { Canvas } from '@react-three/fiber';
|
||||||
import { OrbitControls, Center, Environment } from '@react-three/drei';
|
import { OrbitControls, Center, Environment } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { AppConfig, GeneratedPart } from '../types';
|
import { AppConfig, GeneratedPart, LayoutSplits } from '../types';
|
||||||
import { createBinGeometry, exportSTL, generateSTL } from '../services/geometryGenerator';
|
import { createBinGeometry, generateSTL, exportSTL } from '../services/geometryGenerator';
|
||||||
import { Download, Package, Info, Loader2 } from 'lucide-react';
|
import { Download, Package, Info, Loader2, Share2, Check, Ruler } from 'lucide-react';
|
||||||
|
import { generateShareUrl } from '../utils/share';
|
||||||
|
|
||||||
// --- 3D Helper Components ---
|
// --- DrawerFrame (Каркас) ---
|
||||||
|
|
||||||
// Каркас ящика (только ребра, без диагоналей)
|
|
||||||
const DrawerFrame = ({ config }: { config: AppConfig }) => {
|
const DrawerFrame = ({ config }: { config: AppConfig }) => {
|
||||||
const { width, depth, height } = config.drawer;
|
const { width, depth, height } = config.drawer;
|
||||||
const offset = 0.5; // Небольшой отступ наружу
|
const offset = 0.5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[width / 2, height / 2, depth / 2]}>
|
<group position={[width / 2, height / 2, depth / 2]}>
|
||||||
<lineSegments>
|
<lineSegments>
|
||||||
@@ -24,8 +22,7 @@ const DrawerFrame = ({ config }: { config: AppConfig }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bin Component ---
|
// --- BinMesh (Ячейка) ---
|
||||||
|
|
||||||
interface BinMeshProps {
|
interface BinMeshProps {
|
||||||
part: GeneratedPart;
|
part: GeneratedPart;
|
||||||
thickness: number;
|
thickness: number;
|
||||||
@@ -34,29 +31,15 @@ interface BinMeshProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, isSelected, onClick }) => {
|
const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, isSelected, onClick }) => {
|
||||||
// REMOVED ARTIFICIAL GAP: The part dimensions now include the printer tolerance physically.
|
const geometry = React.useMemo(() => {
|
||||||
// The gap will be visible naturally because part.width is smaller than grid size.
|
|
||||||
|
|
||||||
// Генерируем реальную геометрию, как для STL
|
|
||||||
const geometry = useMemo(() => {
|
|
||||||
return createBinGeometry(part.width, part.depth, part.height, thickness);
|
return createBinGeometry(part.width, part.depth, part.height, thickness);
|
||||||
}, [part, thickness]);
|
}, [part, thickness]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[part.x + part.width/2, 0, part.y + part.depth/2]}>
|
<group position={[part.x + part.width/2, 0, part.y + part.depth/2]}>
|
||||||
{/* Основной меш ячейки */}
|
<mesh geometry={geometry} onClick={(e) => { e.stopPropagation(); onClick(); }}>
|
||||||
<mesh
|
<meshStandardMaterial color={isSelected ? '#f59e0b' : part.color} roughness={0.5} metalness={0.1}/>
|
||||||
geometry={geometry}
|
|
||||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
|
||||||
>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={isSelected ? '#f59e0b' : part.color}
|
|
||||||
roughness={0.5}
|
|
||||||
metalness={0.1}
|
|
||||||
/>
|
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Подсветка выделения (Bounding Box) */}
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<lineSegments position={[0, part.height/2, 0]}>
|
<lineSegments position={[0, part.height/2, 0]}>
|
||||||
<edgesGeometry args={[new THREE.BoxGeometry(part.width, part.height, part.depth)]} />
|
<edgesGeometry args={[new THREE.BoxGeometry(part.width, part.height, part.depth)]} />
|
||||||
@@ -67,23 +50,22 @@ const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, isSelected, onClick
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- PreviewStep (Основной) ---
|
||||||
interface Props {
|
interface Props {
|
||||||
parts: GeneratedPart[];
|
parts: GeneratedPart[];
|
||||||
config: AppConfig;
|
config: AppConfig;
|
||||||
|
splits: LayoutSplits;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [isZipping, setIsZipping] = useState(false);
|
const [isZipping, setIsZipping] = useState(false);
|
||||||
|
const [shareUrlCopied, setShareUrlCopied] = useState(false);
|
||||||
const itemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
const itemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||||
|
|
||||||
// Автопрокрутка к выбранному элементу
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId && itemRefs.current[selectedId]) {
|
if (selectedId && itemRefs.current[selectedId]) {
|
||||||
itemRefs.current[selectedId]?.scrollIntoView({
|
itemRefs.current[selectedId]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'center'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
@@ -96,162 +78,194 @@ export const PreviewStep: React.FC<Props> = ({ parts, config }) => {
|
|||||||
const handleDownloadAll = async () => {
|
const handleDownloadAll = async () => {
|
||||||
if (isZipping) return;
|
if (isZipping) return;
|
||||||
setIsZipping(true);
|
setIsZipping(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Starting ZIP generation...");
|
|
||||||
// Ensure JSZip is available
|
|
||||||
if (typeof JSZip === 'undefined' && !JSZip) {
|
|
||||||
throw new Error("Библиотека JSZip не загружена.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
// Генерация STL для каждой части и добавление в архив
|
|
||||||
parts.forEach(part => {
|
parts.forEach(part => {
|
||||||
const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness);
|
const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness);
|
||||||
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
|
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
|
||||||
const stlData = generateSTL(mesh);
|
const stlData = generateSTL(mesh);
|
||||||
// stlData is Uint8Array or string here, which is supported
|
|
||||||
zip.file(`${part.name.replace(/\s+/g, '_')}.stl`, stlData);
|
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" });
|
const content = await zip.generateAsync({ type: "blob" });
|
||||||
|
|
||||||
console.log("ZIP blob generated. Size:", content.size);
|
|
||||||
|
|
||||||
// Скачивание
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = URL.createObjectURL(content);
|
link.href = URL.createObjectURL(content);
|
||||||
link.download = "PrintFit_Project.zip";
|
link.download = "PrintFit_Project.zip";
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Failed to create zip archive", e);
|
alert(`Ошибка архивации: ${e.message}`);
|
||||||
alert(`Ошибка при создании архива: ${e.message || 'Неизвестная ошибка'}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsZipping(false);
|
setIsZipping(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Функция копирования ---
|
||||||
|
const handleShare = async () => {
|
||||||
|
const url = generateShareUrl(config, splits);
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
throw new Error('Clipboard API unavailable');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = url;
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.left = "-9999px";
|
||||||
|
textArea.style.top = "0";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
const result = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
if (result) success = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Copy failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setShareUrlCopied(true);
|
||||||
|
setTimeout(() => setShareUrlCopied(false), 3000);
|
||||||
|
} else {
|
||||||
|
prompt("Скопируйте ссылку вручную:", url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col lg:flex-row h-full gap-6">
|
<div className="flex flex-col h-full">
|
||||||
{/* 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="flex flex-col xl: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="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">
|
<div className="flex flex-wrap items-center gap-6 justify-center md:justify-start">
|
||||||
<Info size={14} /> Управление
|
<div className="hidden md:flex items-center gap-2 text-gray-300 mr-2">
|
||||||
</div>
|
<Ruler className="text-primary" size={20} />
|
||||||
<ul className="space-y-1">
|
<span className="font-medium text-sm uppercase tracking-wide opacity-70">Размеры ящика:</span>
|
||||||
<li>• ЛКМ: Вращение</li>
|
</div>
|
||||||
<li>• ПКМ: Перемещение</li>
|
|
||||||
<li>• Скролл: Масштаб</li>
|
{/* --- ОБНОВЛЕННЫЙ БЛОК РАЗМЕРОВ --- */}
|
||||||
<li className="text-accent mt-2 font-semibold">• Клик по детали для выбора</li>
|
<div className="flex flex-wrap gap-6 font-mono text-white items-baseline">
|
||||||
</ul>
|
<div className="flex items-baseline gap-2">
|
||||||
</div>
|
<span className="text-slate-500 text-sm font-bold uppercase tracking-wider">Ширина:</span>
|
||||||
|
<span className="text-2xl font-bold text-white drop-shadow-sm">{config.drawer.width}</span>
|
||||||
<Canvas
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-slate-500 text-sm font-bold uppercase tracking-wider">Глубина:</span>
|
||||||
|
<span className="text-2xl font-bold text-white drop-shadow-sm">{config.drawer.depth}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-slate-500 text-sm font-bold uppercase tracking-wider">Высота:</span>
|
||||||
|
<span className="text-2xl font-bold text-white drop-shadow-sm">{config.drawer.height}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-500 font-bold self-baseline">мм</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 px-6 py-3 rounded-lg text-sm font-bold transition-all border shadow-md active:scale-95 shrink-0
|
||||||
|
${shareUrlCopied
|
||||||
|
? 'bg-green-600 border-green-500 text-white shadow-green-900/20'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-500 border-blue-500 text-white shadow-blue-900/20'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{shareUrlCopied ? <Check size={18} /> : <Share2 size={18} />}
|
||||||
|
{shareUrlCopied ? 'СКОПИРОВАНО!' : 'Поделиться'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
shadows
|
||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
camera={{
|
camera={{
|
||||||
position: [config.drawer.width * 1.5, config.drawer.height * 3, config.drawer.depth * 1.5],
|
position: [config.drawer.width * 1.5, config.drawer.height * 3, config.drawer.depth * 1.5],
|
||||||
fov: 45,
|
fov: 45,
|
||||||
near: 1,
|
near: 1,
|
||||||
far: 20000
|
far: 20000
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<color attach="background" args={['#0f172a']} />
|
<color attach="background" args={['#0f172a']} />
|
||||||
|
<ambientLight intensity={0.7} />
|
||||||
<ambientLight intensity={0.7} />
|
<directionalLight position={[100, 200, 50]} intensity={1.2} />
|
||||||
<directionalLight position={[100, 200, 50]} intensity={1.2} />
|
<Environment preset="city" />
|
||||||
<directionalLight position={[-100, 100, -50]} intensity={0.5} />
|
<Center>
|
||||||
<Environment preset="city" />
|
<group>
|
||||||
|
<DrawerFrame config={config} />
|
||||||
<Center>
|
{parts.map(part => (
|
||||||
<group>
|
<BinMesh
|
||||||
<DrawerFrame config={config} />
|
key={part.id} part={part} thickness={config.wallThickness}
|
||||||
{parts.map(part => (
|
isSelected={selectedId === part.id}
|
||||||
<BinMesh
|
onClick={() => setSelectedId(part.id)}
|
||||||
key={part.id}
|
/>
|
||||||
part={part}
|
))}
|
||||||
thickness={config.wallThickness}
|
</group>
|
||||||
isSelected={selectedId === part.id}
|
</Center>
|
||||||
onClick={() => setSelectedId(part.id)}
|
<OrbitControls makeDefault minDistance={10} maxDistance={10000} />
|
||||||
/>
|
</Suspense>
|
||||||
))}
|
</Canvas>
|
||||||
</group>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<OrbitControls makeDefault minDistance={10} maxDistance={10000} />
|
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pr-2 space-y-3 custom-scrollbar">
|
{/* Sidebar List */}
|
||||||
{parts.map(part => (
|
<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
|
<div className="flex justify-between items-center mb-6 shrink-0">
|
||||||
key={part.id}
|
<h2 className="text-xl font-bold flex items-center gap-2 text-primary">
|
||||||
ref={(el) => { itemRefs.current[part.id] = el }}
|
<Package size={24} /> Детали ({parts.length})
|
||||||
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'}`}
|
</h2>
|
||||||
onClick={() => setSelectedId(part.id)}
|
<button
|
||||||
>
|
onClick={handleDownloadAll}
|
||||||
<div className="flex justify-between items-start mb-2">
|
disabled={isZipping || parts.length === 0}
|
||||||
<span className="font-semibold text-gray-200 group-hover:text-white transition-colors">{part.name}</span>
|
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"
|
||||||
<div
|
>
|
||||||
className="w-3 h-3 rounded-full border border-white/10"
|
{isZipping ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||||||
style={{ backgroundColor: part.color }}
|
Архив
|
||||||
/>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex-1 overflow-y-auto pr-2 space-y-3 custom-scrollbar">
|
||||||
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Ширина</span>
|
{parts.map(part => (
|
||||||
{part.width.toFixed(1)}
|
<div
|
||||||
</div>
|
key={part.id}
|
||||||
<div className="bg-slate-900/50 p-1.5 rounded">
|
ref={(el) => { itemRefs.current[part.id] = el }}
|
||||||
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Глубина</span>
|
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'}`}
|
||||||
{part.depth.toFixed(1)}
|
onClick={() => setSelectedId(part.id)}
|
||||||
</div>
|
>
|
||||||
<div className="bg-slate-900/50 p-1.5 rounded">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Высота</span>
|
<span className="font-semibold text-gray-200">{part.name}</span>
|
||||||
{part.height.toFixed(1)}
|
<div className="w-3 h-3 rounded-full border border-white/10" style={{ backgroundColor: part.color }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleDownload(part); }}
|
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"
|
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={14} /> Скачать STL
|
<Download size={16} /> Скачать STL
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user