From 97a53fe59a6edd82659f19e082b1c0791e1db58e Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 22 Jul 2026 21:36:16 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20b=E1=BB=95=20sung=20ph=E1=BA=A7n=20sect?= =?UTF-8?q?ion=20v=C3=A0=20nh=E1=BA=A5p=20=C4=91=C3=B4i=20=C4=91=E1=BB=83?= =?UTF-8?q?=20s=E1=BB=ADa=20section,=20qui=20=C4=91=E1=BB=8Bnh=20tab=20v?= =?UTF-8?q?=E1=BB=ABa=20m=E1=BB=9F=20l=C3=A0=20con=20c=E1=BB=A7a=20item=20?= =?UTF-8?q?section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/js/app.jsx | 146 +++++++++++++++++++++++++------ app/static/js/app.precompiled.js | 111 ++++++++++++++++++++--- app/storage/sonicforge.db | Bin 45056 -> 45056 bytes 3 files changed, 220 insertions(+), 37 deletions(-) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 358fd41..acd9d32 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -186,6 +186,7 @@ const WaveformLane = ({ onClipStretchStart, onSectionItemDragStart, onSectionItemResizeStart, + onEditSectionInTab, onSelectionEdgeDragStart, setSelectedClipId, selectedClipId, @@ -814,6 +815,18 @@ const WaveformLane = ({ name: track.name, speed: track.speed || 1.0 }] : []; + // Check double-click on section first + const dblSecItems = track.sections || []; + let dblSecHit = null; + for (const sec of dblSecItems) { + if (time >= sec.start && time < sec.start + sec.duration) { dblSecHit = sec; break; } + } + if (dblSecHit) { + e.preventDefault(); + e.stopPropagation(); + if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id); + return; + } const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); if (clickedClip) { e.preventDefault(); @@ -830,7 +843,13 @@ const WaveformLane = ({ const rect = canvasRef.current.getBoundingClientRect(); const x = e.clientX - rect.left + (scrollLeft || 0); const time = Math.max(0, x / zoom); - if (onContextMenu) onContextMenu(e, track.id, time); + // Detect section under cursor + const secList = track.sections || []; + let hitSectionId = null; + for (const sec of secList) { + if (time >= sec.start && time < sec.start + sec.duration) { hitSectionId = sec.id; break; } + } + if (onContextMenu) onContextMenu(e, track.id, time, hitSectionId); } })); }; @@ -3727,6 +3746,11 @@ const App = () => { const [subTabGainVal, setSubTabGainVal] = useState(100); const [subTabPitchVal, setSubTabPitchVal] = useState(0); const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] + const [sessionTabs, setSessionTabs] = useState([]); // [{id, name, tracks}, ...] + const activeTracks = useMemo(() => { + const st = sessionTabs.find(s => s.id === activeTab); + return st ? st.tracks : tracks; + }, [activeTab, sessionTabs, tracks]); const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 @@ -4693,6 +4717,27 @@ const App = () => { setActiveTab(tabId); }; + // ── Double-click/Edit Section: open Main Session in new tab ── + const handleEditSectionInTab = (trackId, sectionId) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const section = (track.sections || []).find(s => s.id === sectionId); + if (!section) return; + const existing = sessionTabs.find(s => s.sectionId === sectionId); + if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; } + const tabId = 'session_' + Date.now(); + const tabName = section.name || 'Section'; + const clonedTracks = tracks.map(t => ({ + ...t, + clips: [], + sections: [], + midiItems: [], + markers: [] + })); + setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]); + setActiveTab(tabId); + }; + // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── const applySubTab = tabId => { const subTab = subTabs.find(s => s.id === tabId); @@ -5028,6 +5073,10 @@ const App = () => { setSubTabs(prev => prev.filter(s => s.id !== tabId)); if (activeTab === tabId) setActiveTab('main'); }; + const closeSessionTab = tabId => { + setSessionTabs(prev => prev.filter(s => s.id !== tabId)); + if (activeTab === tabId) setActiveTab('main'); + }; const updateSubTabEffects = (tabId, effects) => { setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, @@ -5073,18 +5122,26 @@ const App = () => { }; // ── Context Menu Handlers ── - const handleContextMenu = (e, trackId, clickTime) => { + const handleContextMenu = (e, trackId, clickTime, sectionId) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, trackId, - time: clickTime || currentTime + time: clickTime || currentTime, + sectionId: sectionId || null }); }; const closeContextMenu = () => setContextMenu(null); + const contextMenuEditSection = () => { + if (contextMenu.sectionId) { + handleEditSectionInTab(contextMenu.trackId, contextMenu.sectionId); + } + closeContextMenu(); + }; + // Close context menu on any click outside useEffect(() => { const handler = () => { @@ -9208,7 +9265,29 @@ const App = () => { }, /*#__PURE__*/React.createElement("i", { "data-lucide": "layout-dashboard", className: "w-3 h-3" - })), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", { + })), " Main Session"), sessionTabs.map(st => /*#__PURE__*/React.createElement("div", { + key: st.id, + className: "flex items-stretch" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab(st.id), + className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "layout-dashboard", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "max-w-[120px] truncate" + }, st.name)), /*#__PURE__*/React.createElement("button", { + onClick: () => closeSessionTab(st.id), + className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs", + title: "Close tab" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + }))))), subTabs.map(st => /*#__PURE__*/React.createElement("div", { key: st.id, className: "flex items-stretch" }, /*#__PURE__*/React.createElement("button", { @@ -10197,24 +10276,24 @@ const App = () => { className: "flex-1 flex flex-col overflow-hidden min-w-0" }, /*#__PURE__*/React.createElement("div", { className: "flex-1 flex overflow-hidden" - }, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { - ref: tcpContainerRef, - onScroll: handleTCPScroll, - className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900", - style: { - scrollbarWidth: 'none', - msOverflowStyle: 'none' - } - }, /*#__PURE__*/React.createElement("div", { - className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "sliders", - className: "w-3.5 h-3.5 text-cyan-400" - })), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", { + }, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + ref: tcpContainerRef, + onScroll: handleTCPScroll, + className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900", + style: { + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement("div", { + className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0" + }, /*#__PURE__*/React.createElement("span", { + className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sliders", + className: "w-3.5 h-3.5 text-cyan-400" + })), "TRACKS (", activeTracks.length, ")"), /*#__PURE__*/React.createElement("button", { onClick: addNewTrack, className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition" }, /*#__PURE__*/React.createElement("span", { @@ -10246,7 +10325,7 @@ const App = () => { className: "text-xs text-zinc-500" }, "BPM")))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]" - }, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", { + }, activeTracks.length === 0 ? /*#__PURE__*/React.createElement("div", { className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" @@ -10261,9 +10340,9 @@ const App = () => { }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "plus", + "data-lucide": "plus-circle", className: "w-3.5 h-3.5" - })), " Thêm Track Mới")) : tracks.map((track, idx) => { + })), " Thêm Track Mới")) : activeTracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; return /*#__PURE__*/React.createElement("div", { key: track.id, @@ -10531,7 +10610,7 @@ const App = () => { scrollLeft: scrollLeft }))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" - }, tracks.map((track, idx) => { + }, activeTracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; return /*#__PURE__*/React.createElement("div", { key: track.id, @@ -10567,6 +10646,7 @@ const App = () => { activeTool: activeTool, onSplitTrackAtTime: handleSplitTrackAtTime, onEditClipInSubTab: handleEditClipInSubTab, + onEditSectionInTab: handleEditSectionInTab, snapValue: snapValue, bpm: bpm, selectionMode: selectionMode, @@ -11295,7 +11375,19 @@ const App = () => { top: contextMenu.y }, onClick: e => e.stopPropagation() - }, /*#__PURE__*/React.createElement("button", { + }, contextMenu.sectionId ? /*#__PURE__*/React.createElement("button", { + onClick: contextMenuEditSection, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "file-edit", + className: "w-3.5 h-3.5 text-amber-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Edit Section"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" + }, "Ctrl+E")) : /*#__PURE__*/React.createElement("button", { onClick: contextMenuEdit, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 8377727..66317ad 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -186,6 +186,7 @@ const WaveformLane = ({ onClipStretchStart, onSectionItemDragStart, onSectionItemResizeStart, + onEditSectionInTab, onSelectionEdgeDragStart, setSelectedClipId, selectedClipId, @@ -814,6 +815,18 @@ const WaveformLane = ({ name: track.name, speed: track.speed || 1.0 }] : []; + // Check double-click on section first + const dblSecItems = track.sections || []; + let dblSecHit = null; + for (const sec of dblSecItems) { + if (time >= sec.start && time < sec.start + sec.duration) { dblSecHit = sec; break; } + } + if (dblSecHit) { + e.preventDefault(); + e.stopPropagation(); + if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id); + return; + } const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); if (clickedClip) { e.preventDefault(); @@ -830,7 +843,12 @@ const WaveformLane = ({ const rect = canvasRef.current.getBoundingClientRect(); const x = e.clientX - rect.left + (scrollLeft || 0); const time = Math.max(0, x / zoom); - if (onContextMenu) onContextMenu(e, track.id, time); + const secList = track.sections || []; + let hitSectionId = null; + for (const sec of secList) { + if (time >= sec.start && time < sec.start + sec.duration) { hitSectionId = sec.id; break; } + } + if (onContextMenu) onContextMenu(e, track.id, time, hitSectionId); } })); }; @@ -3778,6 +3796,11 @@ const App = () => { const [subTabGainVal, setSubTabGainVal] = useState(100); const [subTabPitchVal, setSubTabPitchVal] = useState(0); const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] + const [sessionTabs, setSessionTabs] = useState([]); // [{id, name, tracks}, ...] + const activeTracks = useMemo(() => { + const st = sessionTabs.find(s => s.id === activeTab); + return st ? st.tracks : tracks; + }, [activeTab, sessionTabs, tracks]); const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 // ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── @@ -4753,6 +4776,27 @@ const App = () => { setActiveTab(tabId); }; + // ── Double-click/Edit Section: open Main Session in new tab ── + const handleEditSectionInTab = (trackId, sectionId) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const section = (track.sections || []).find(s => s.id === sectionId); + if (!section) return; + const existing = sessionTabs.find(s => s.sectionId === sectionId); + if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; } + const tabId = 'session_' + Date.now(); + const tabName = section.name || 'Section'; + const clonedTracks = tracks.map(t => ({ + ...t, + clips: [], + sections: [], + midiItems: [], + markers: [] + })); + setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]); + setActiveTab(tabId); + }; + // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── const applySubTab = tabId => { const subTab = subTabs.find(s => s.id === tabId); @@ -5088,6 +5132,10 @@ const App = () => { setSubTabs(prev => prev.filter(s => s.id !== tabId)); if (activeTab === tabId) setActiveTab('main'); }; + const closeSessionTab = tabId => { + setSessionTabs(prev => prev.filter(s => s.id !== tabId)); + if (activeTab === tabId) setActiveTab('main'); + }; const updateSubTabEffects = (tabId, effects) => { setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, @@ -5133,18 +5181,26 @@ const App = () => { }; // ── Context Menu Handlers ── - const handleContextMenu = (e, trackId, clickTime) => { + const handleContextMenu = (e, trackId, clickTime, sectionId) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, trackId, - time: clickTime || currentTime + time: clickTime || currentTime, + sectionId: sectionId || null }); }; const closeContextMenu = () => setContextMenu(null); + const contextMenuEditSection = () => { + if (contextMenu.sectionId) { + handleEditSectionInTab(contextMenu.trackId, contextMenu.sectionId); + } + closeContextMenu(); + }; + // Close context menu on any click outside useEffect(() => { const handler = () => { @@ -9701,7 +9757,29 @@ const App = () => { }, /*#__PURE__*/React.createElement("i", { "data-lucide": "layout-dashboard", className: "w-3 h-3" - })), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", { + })), " Main Session"), sessionTabs.map(st => /*#__PURE__*/React.createElement("div", { + key: st.id, + className: "flex items-stretch" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab(st.id), + className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "layout-dashboard", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "max-w-[120px] truncate" + }, st.name)), /*#__PURE__*/React.createElement("button", { + onClick: () => closeSessionTab(st.id), + className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs", + title: "Close tab" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + }))))), subTabs.map(st => /*#__PURE__*/React.createElement("div", { key: st.id, className: "flex items-stretch" }, /*#__PURE__*/React.createElement("button", { @@ -10748,7 +10826,7 @@ const App = () => { className: "flex-1 flex flex-col overflow-hidden min-w-0" }, /*#__PURE__*/React.createElement("div", { className: "flex-1 flex overflow-hidden" - }, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + }, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { ref: tcpContainerRef, onScroll: handleTCPScroll, className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900", @@ -10765,7 +10843,7 @@ const App = () => { }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sliders", className: "w-3.5 h-3.5 text-cyan-400" - })), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", { + })), "TRACKS (", activeTracks.length, ")"), /*#__PURE__*/React.createElement("button", { onClick: addNewTrack, className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition" }, /*#__PURE__*/React.createElement("span", { @@ -10797,7 +10875,7 @@ const App = () => { className: "text-xs text-zinc-500" }, "BPM")))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]" - }, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", { + }, activeTracks.length === 0 ? /*#__PURE__*/React.createElement("div", { className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" @@ -10814,7 +10892,7 @@ const App = () => { }, /*#__PURE__*/React.createElement("i", { "data-lucide": "plus", className: "w-3.5 h-3.5" - })), " Thêm Track Mới")) : tracks.map((track, idx) => { + })), " Thêm Track Mới")) : activeTracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; return /*#__PURE__*/React.createElement("div", { key: track.id, @@ -11092,7 +11170,7 @@ const App = () => { scrollLeft: scrollLeft }))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" - }, tracks.map((track, idx) => { + }, activeTracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; return /*#__PURE__*/React.createElement("div", { key: track.id, @@ -11128,6 +11206,7 @@ const App = () => { activeTool: activeTool, onSplitTrackAtTime: handleSplitTrackAtTime, onEditClipInSubTab: handleEditClipInSubTab, + onEditSectionInTab: handleEditSectionInTab, snapValue: snapValue, bpm: bpm, selectionMode: selectionMode, @@ -11856,7 +11935,19 @@ const App = () => { top: contextMenu.y }, onClick: e => e.stopPropagation() - }, /*#__PURE__*/React.createElement("button", { + }, contextMenu.sectionId ? /*#__PURE__*/React.createElement("button", { + onClick: contextMenuEditSection, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "file-edit", + className: "w-3.5 h-3.5 text-amber-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Edit Section"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" + }, "Ctrl+E")) : /*#__PURE__*/React.createElement("button", { onClick: contextMenuEdit, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 0fe61eefbcf1bd4ee3b38c03005456b939bbfc9e..83ef1078e1adbf63147754d1923f725f832c122b 100644 GIT binary patch delta 100 zcmV-q0Gt1S-~xc)0+1U4@sS)u0r9b5rEduflK>B|4Y~`Hvkq_&3bBxN0SSa83<+OV zKZawICwj7zhkLG*{eBSvGLvC^SCeOdJs2z^b9G`=VPbP4I$K?R0jojUm^C#T!$h;X GeMT~GEG6au delta 186 zcmZp8z|`=7X@WH4<%u%RjF&eiEY0WSl4Ias&%29DZZltj02gPwBoAkNsC|#`Ed8(A2dn_3!JDCsEWCFZ6A#Y2h`ld~02WhW=rgtH|Y z873#DP2OK;CuEGS+!(CF+`ur=+!Uz7OfptSskk&LBr&O2$tpUwmRZ5^)(oQ+!XI94 J-c{>n1OOBHJC6VW