Add share
This commit is contained in:
30
src/App.tsx
30
src/App.tsx
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
54
src/utils/share.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user