diff --git a/src/RailRound.jsx b/src/RailRound.jsx index def87f1..b26ae22 100644 --- a/src/RailRound.jsx +++ b/src/RailRound.jsx @@ -14,6 +14,7 @@ import { LogOut, User, Github } from 'lucide-react'; import { LoginModal } from './components/LoginModal'; +import Tutorial from './components/Tutorial'; import { api } from './services/api'; import { db } from './utils/db'; @@ -794,9 +795,9 @@ const TripEditor = ({ {isEditing ? : } {isEditing ? '编辑行程' : '新行程'} - + -
+
@@ -919,7 +920,7 @@ const PinEditor = ({ editingPin, setEditingPin, pinMode, setPinMode, deletePin, ); }; const FabButton = ({ activeTab, pinMode, togglePinMode }) => ( -
{activeTab === 'map' && ()}
+
{activeTab === 'map' && ()}
); // --- Shared Helper: Calculate Visualization Data --- @@ -1267,7 +1268,7 @@ const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, se
); }))} - +
); const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeometries, onOpenCard, companyDB }) => { @@ -1300,7 +1301,7 @@ const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeom }); } return ( -
+
{user && (
@@ -2457,7 +2458,7 @@ export default function RailLOOPApp() { {user ? (
欢迎, {user.username} -
@@ -2468,7 +2469,7 @@ export default function RailLOOPApp() { )} {activeTab !== 'map' ? ( -
+
@@ -2481,7 +2482,7 @@ export default function RailLOOPApp() {
) : ( -
+
@@ -2492,7 +2493,7 @@ export default function RailLOOPApp() {
{activeTab === 'records' && { setTripForm({ date: new Date().toISOString().split('T')[0], memo: '', segments: [{ id: Date.now().toString(), lineKey: '', fromId: '', toId: '' }] }); setIsTripEditing(true); }} />} {activeTab === 'stats' && } -
+
@@ -2500,6 +2501,17 @@ export default function RailLOOPApp() {
setIsTripEditing(false)} isEditing={!!editingTripId} form={tripForm} setForm={setTripForm} onSave={handleSaveTrip} railwayData={railwayData} editorMode={editorMode} setEditorMode={setEditorMode} autoForm={autoForm} setAutoForm={setAutoForm} onAutoSearch={handleAutoRouteSearch} isRouteSearching={isRouteSearching} /> + + setIsLoginOpen(false)} onLoginSuccess={handleLoginSuccess} /> setIsGithubRegisterOpen(false)} regToken={githubRegToken} onLoginSuccess={handleLoginSuccess} /> setCardModalUser(null)} /> @@ -2507,7 +2519,7 @@ export default function RailLOOPApp() { {/* Line Selector */} {}} onSelect={() => {}} railwayData={railwayData} />
); diff --git a/src/components/LoginModal.jsx b/src/components/LoginModal.jsx index 495e46e..3b0ee8d 100644 --- a/src/components/LoginModal.jsx +++ b/src/components/LoginModal.jsx @@ -312,7 +312,7 @@ export const LoginModal = ({ isOpen, onClose, onLoginSuccess }) => {
{/* Right: User Guide / Agreement */} -
+
diff --git a/src/components/Tutorial.jsx b/src/components/Tutorial.jsx new file mode 100644 index 0000000..be417f7 --- /dev/null +++ b/src/components/Tutorial.jsx @@ -0,0 +1,416 @@ +import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronRight, ArrowRight } from 'lucide-react'; + +const Tutorial = ({ + activeTab, + setActiveTab, + isTripEditing, + setIsTripEditing, + isLoginOpen, + setIsLoginOpen, + user, + pinMode +}) => { + const [step, setStep] = useState(-1); // -1: Loading/Check, 0+: Steps + const [rect, setRect] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const [tooltipStyle, setTooltipStyle] = useState({}); + + // Ref for the tooltip card to measure strict dimensions + const tooltipRef = useRef(null); + + // Config + const STEPS = [ + { + id: 'welcome', + target: null, // Center modal + title: "Welcome to RailLOOP", + content: "Welcome! Let's take a quick tour to help you get started with tracking your railway journeys.", + position: 'center', + action: 'next' + }, + { + id: 'tab-records', + target: '#tab-btn-records', + title: "Your Journey Log", + content: "This is the Records tab. Here you can view and manage all your railway trips.", + position: 'top', + action: 'switch-tab', + tab: 'records' + }, + { + id: 'add-trip', + target: '#btn-add-trip', + title: "Record a Trip", + content: "Click here to add a new trip. Let's try opening it now.", + position: 'top', + action: 'wait-interaction', // Wait for user to click + check: () => isTripEditing + }, + { + id: 'editor-modes', + target: '#trip-editor-toggle-mode', + title: "Two Input Modes", + content: "You can manually enter details or use the 'Auto Plan' feature to search for routes.", + position: 'bottom', + action: 'next' + }, + { + id: 'close-editor', + target: '#btn-close-editor', + title: "Close Editor", + content: "Let's close this for now to continue the tour.", + position: 'bottom', + action: 'wait-interaction', + check: () => !isTripEditing + }, + { + id: 'import-export', + target: '#header-actions', + title: "Data Management", + content: "Here you can export your data (KML/JSON) or import backups. Keep your data safe!", + position: 'bottom', + action: 'next' + }, + { + id: 'tab-map', + target: '#tab-btn-map', + title: "Map View", + content: "Switch to the Map tab to see your travels on the map.", + position: 'top', + action: 'switch-tab', + tab: 'map' + }, + { + id: 'map-pins', + target: '#btn-pins-fab', + title: "Map Pins", + content: "Use this button to add pins (photos or comments) to the map.", + position: 'right', // FAB is bottom-left + action: 'wait-interaction', + check: () => pinMode && pinMode !== 'idle' + }, + { + id: 'map-layers', + target: '.leaflet-control-layers', + title: "Switch Map Style", + content: "Toggle between Dark and Light map themes here.", + position: 'left', // Top-right control + action: 'next' + }, + { + id: 'tab-stats', + target: '#tab-btn-stats', + title: "Statistics", + content: "Finally, check your travel statistics here.", + position: 'top', + action: 'wait-click-tab', // Specifically asked to guide click + tab: 'stats', + check: () => activeTab === 'stats' + }, + { + id: 'stats-content', + target: '#stats-view-content', + title: "Your Achievements", + content: "View your total distance, cost, and most frequented lines here.", + position: 'center', // It's a large area, maybe just highlight without blocking? Or center modal over it? Let's try target. + action: 'next' + }, + { + id: 'finish-login', + target: null, + title: "Login & User Guide", + content: "That's it! We'll now open the Login screen where you can read the full User Guide.", + position: 'center', + action: 'finish' + }, + { + id: 'login-guide', + target: '#login-readme-container', + title: "Read the Guide", + content: "Please take a moment to read the User Guide / Agreement here.", + position: 'left', + action: 'end' + } + ]; + + // Initialization check + useEffect(() => { + const skipped = localStorage.getItem('rail_tutorial_skipped'); + if (skipped === 'true' || user) { + setStep(-2); // Skipped + return; + } + setStep(0); + setIsVisible(true); + }, [user]); + + // Step Transition Logic & Rect Calculation + useEffect(() => { + if (step < 0 || step >= STEPS.length) return; + + const currentStep = STEPS[step]; + + // 1. Target Resolution + const updateRect = () => { + if (!currentStep.target) { + setRect(null); + setTooltipStyle({ + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + opacity: 1, + transition: 'all 0.5s cubic-bezier(0.25, 1, 0.5, 1)' + }); + return; + } + const el = document.querySelector(currentStep.target); + if (el) { + const r = el.getBoundingClientRect(); + + // Add padding to highlight box + const highlight = { + top: r.top - 5, + left: r.left - 5, + width: r.width + 10, + height: r.height + 10 + }; + setRect(highlight); + } else { + // Element not found? Retry in a bit (maybe animation or mounting) + setRect(null); + } + }; + + // Initial update + updateRect(); + // Poll for rect changes (animations) + const interval = setInterval(updateRect, 100); + + // 2. Auto-actions (Switch Tab) + if (currentStep.action === 'switch-tab' && currentStep.tab && activeTab !== currentStep.tab) { + setActiveTab(currentStep.tab); + } + + // 3. Wait Conditions + let checkInterval; + if (currentStep.check) { + checkInterval = setInterval(() => { + if (currentStep.check()) { + handleNext(); + } + }, 200); + } + + return () => { + clearInterval(interval); + if (checkInterval) clearInterval(checkInterval); + }; + }, [step, activeTab, isTripEditing, isLoginOpen, pinMode]); + + // Strict Positioning Logic + const calculatePosition = () => { + if (step < 0 || step >= STEPS.length || !rect || !tooltipRef.current) return; + + const currentStep = STEPS[step]; + const PADDING = 20; + + // Measure ACTUAL tooltip dimensions + const CARD_W = tooltipRef.current.offsetWidth || 384; + const CARD_H = tooltipRef.current.offsetHeight || 250; + + // Viewport Dimensions + const winH = window.visualViewport ? window.visualViewport.height : window.innerHeight; + const winW = window.visualViewport ? window.visualViewport.width : window.innerWidth; + + // Center of the target element + const targetCenterX = rect.left + rect.width / 2; + const targetCenterY = rect.top + rect.height / 2; + + let top = 0; + let left = 0; + let pos = currentStep.position; + + // Helper to check collision + const checkBounds = (t, l) => { + return (t >= PADDING && l >= PADDING && (t + CARD_H) <= (winH - PADDING) && (l + CARD_W) <= (winW - PADDING)); + }; + + // 1. Determine Initial Position & Flip if needed + const getCoords = (p) => { + let t, l; + if (p === 'bottom') { + t = rect.top + rect.height + 20; + l = targetCenterX - (CARD_W / 2); + } else if (p === 'top') { + t = rect.top - CARD_H - 20; + l = targetCenterX - (CARD_W / 2); + } else if (p === 'right') { + t = targetCenterY - (CARD_H / 2); + l = rect.left + rect.width + 20; + } else if (p === 'left') { + t = targetCenterY - (CARD_H / 2); + l = rect.left - CARD_W - 20; + } else { // center + t = winH / 2 - CARD_H / 2; + l = winW / 2 - CARD_W / 2; + } + return { t, l }; + }; + + let coords = getCoords(pos); + + // Smart Flip + if (!checkBounds(coords.t, coords.l) && pos !== 'center') { + const opposites = { 'top': 'bottom', 'bottom': 'top', 'left': 'right', 'right': 'left' }; + const altPos = opposites[pos]; + if (altPos) { + const altCoords = getCoords(altPos); + // If alternative is better (or valid), take it + // Simple heuristic: check if alt fits vertically for top/bottom swap + if (pos === 'top' || pos === 'bottom') { + if (altCoords.t >= PADDING && (altCoords.t + CARD_H) <= (winH - PADDING)) { + coords = altCoords; + pos = altPos; + } + } + // Check if alt fits horizontally for left/right swap + if (pos === 'left' || pos === 'right') { + if (altCoords.l >= PADDING && (altCoords.l + CARD_W) <= (winW - PADDING)) { + coords = altCoords; + pos = altPos; + } + } + } + } + + // 2. Strict Clamping (The "Aggressive" Part) + // Ensure card never goes off screen, even if it de-centers from target + top = Math.max(PADDING, Math.min(coords.t, winH - CARD_H - PADDING)); + left = Math.max(PADDING, Math.min(coords.l, winW - CARD_W - PADDING)); + + setTooltipStyle({ + position: 'fixed', + top: `${top}px`, + left: `${left}px`, + transform: 'none', + opacity: 1, + transition: 'all 0.4s cubic-bezier(0.25, 1, 0.5, 1)' // Smooth transitions + }); + }; + + useLayoutEffect(() => { + calculatePosition(); + }, [rect, step]); + + // ResizeObserver to handle content size changes + useEffect(() => { + if (!tooltipRef.current) return; + const observer = new ResizeObserver(() => { + calculatePosition(); + }); + observer.observe(tooltipRef.current); + return () => observer.disconnect(); + }, [rect, step]); + + const handleNext = () => { + if (step === STEPS.length - 2) { // Finish step + // Open Login Modal + setIsLoginOpen(true); + // Wait a bit for modal to open then next + setTimeout(() => setStep(s => s + 1), 500); + } else if (step >= STEPS.length - 1) { + // End + setStep(-2); + setIsVisible(false); + } else { + setStep(s => s + 1); + } + }; + + const handleSkip = (dontShowAgain) => { + if (dontShowAgain) { + localStorage.setItem('rail_tutorial_skipped', 'true'); + } + setStep(-2); + setIsVisible(false); + }; + + if (!isVisible || step < 0) return null; + + const currentStep = STEPS[step]; + const isInteractionStep = currentStep.action === 'wait-interaction' || currentStep.action === 'wait-click-tab'; + + // Render + return createPortal( +
+ {/* Mask / Spotlight */} + {rect ? ( +
+ ) : ( + // Full overlay if no target (Welcome step) +
+ )} + + {/* Tooltip / Card */} +
+
+
+

{currentStep.title}

+ {step + 1} / {STEPS.length} +
+

+ {currentStep.content} +

+ +
+
+ {step === 0 && ( + + )} + {step > 0 && ( + + )} +
+ + {!isInteractionStep && ( + + )} + {isInteractionStep && ( +
+ Please interact to continue... +
+ )} +
+
+
+
+ , document.body); +}; + +export default Tutorial; diff --git a/verify_tutorial_logic.js b/verify_tutorial_logic.js new file mode 100644 index 0000000..861833c --- /dev/null +++ b/verify_tutorial_logic.js @@ -0,0 +1,40 @@ + +const fs = require('fs'); +const path = require('path'); + +const tutorialPath = path.join('src', 'components', 'Tutorial.jsx'); + +console.log("Verifying Tutorial Logic..."); + +if (!fs.existsSync(tutorialPath)) { + console.error("Tutorial.jsx not found"); + process.exit(1); +} + +const content = fs.readFileSync(tutorialPath, 'utf-8'); + +// 1. Check for visualViewport usage +if (content.includes('window.visualViewport.height')) { + console.log("[OK] visualViewport usage found"); +} else { + console.error("[FAIL] visualViewport usage missing"); + process.exit(1); +} + +// 2. Check for ResizeObserver usage +if (content.includes('new ResizeObserver')) { + console.log("[OK] ResizeObserver usage found"); +} else { + console.error("[FAIL] ResizeObserver usage missing"); + process.exit(1); +} + +// 3. Check for map-pins interactive mode +if (content.includes('id: \'map-pins\'') && content.includes('action: \'wait-interaction\'') && content.includes('pinMode')) { + console.log("[OK] Map pins interactive mode configured"); +} else { + console.error("[FAIL] Map pins configuration incorrect"); + process.exit(1); +} + +console.log("Verification Successful."); diff --git a/verify_tutorial_refinement.js b/verify_tutorial_refinement.js new file mode 100644 index 0000000..c62b23c --- /dev/null +++ b/verify_tutorial_refinement.js @@ -0,0 +1,40 @@ + +const fs = require('fs'); +const path = require('path'); + +const tutorialPath = path.join('src', 'components', 'Tutorial.jsx'); + +console.log("Verifying Tutorial Refinements..."); + +if (!fs.existsSync(tutorialPath)) { + console.error("Tutorial.jsx not found"); + process.exit(1); +} + +const content = fs.readFileSync(tutorialPath, 'utf-8'); + +// 1. Check for tooltipStyle usage +if (content.includes('style={tooltipStyle}')) { + console.log("[OK] Dynamic style usage found"); +} else { + console.error("[FAIL] Dynamic style usage missing"); + process.exit(1); +} + +// 2. Check for Math.max clamping logic +if (content.includes('Math.max(PADDING, Math.min')) { + console.log("[OK] Strict Clamping logic found"); +} else { + console.error("[FAIL] Strict Clamping logic missing"); + process.exit(1); +} + +// 3. Check for Smart Flip logic +if (content.includes('const opposites = { \'top\': \'bottom\'')) { + console.log("[OK] Smart Flip logic found"); +} else { + console.error("[FAIL] Smart Flip logic missing"); + process.exit(1); +} + +console.log("Verification Successful."); diff --git a/verify_tutorial_structure.js b/verify_tutorial_structure.js new file mode 100644 index 0000000..ebf3adb --- /dev/null +++ b/verify_tutorial_structure.js @@ -0,0 +1,53 @@ + +const fs = require('fs'); +const path = require('path'); + +const railRoundPath = path.join('src', 'RailRound.jsx'); +const loginModalPath = path.join('src', 'components', 'LoginModal.jsx'); +const tutorialPath = path.join('src', 'components', 'Tutorial.jsx'); + +function checkFileExists(p) { + if (fs.existsSync(p)) { + console.log(`[OK] ${p} exists.`); + } else { + console.error(`[FAIL] ${p} does not exist.`); + process.exit(1); + } +} + +function checkContent(p, searchString) { + const content = fs.readFileSync(p, 'utf-8'); + if (content.includes(searchString)) { + console.log(`[OK] Found "${searchString}" in ${p}`); + } else { + console.error(`[FAIL] Could not find "${searchString}" in ${p}`); + process.exit(1); + } +} + +console.log("Verifying Tutorial Implementation..."); + +// 1. Check Files +checkFileExists(railRoundPath); +checkFileExists(loginModalPath); +checkFileExists(tutorialPath); + +// 2. Check RailRound IDs and Import +checkContent(railRoundPath, 'import Tutorial from \'./components/Tutorial\''); +checkContent(railRoundPath, '