diff --git a/client/src/App.css b/client/src/App.css index df999b0d..c092c3bf 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,6 +1,9 @@ :root { --text-color: #141301; --header-color: #a1c3ff; + --accent-color: #206ef9; + --border-color: #e1e8ed; + --shadow: 0 1px 4px rgba(0, 0, 0, 0.05); } .App { @@ -52,7 +55,7 @@ header { top: 0; width: 100%; box-sizing: border-box; - max-height: 100px; + max-height: 80px; background-color: var(--header-color); padding: 20px; margin: 0; @@ -63,6 +66,10 @@ header { flex-direction: row; justify-content: space-between; gap: 10px; + color: #184b7f; + /* adding static height for full-screen content */ + height:80px; + min-height: 80px; } @media (max-width: 1024px) { @@ -95,6 +102,16 @@ header { } } +@media (max-width: 768px) { + header { + height: 50px; + min-height: 50px; + max-height: 50px; + padding: 10px 20px; + font-size: 1.5em; + } +} + li a { display: flex; flex-direction: row; @@ -108,17 +125,33 @@ li a { } .feedback-container { - border: 1px solid #444; - border-radius: 5px; + border: 1px solid var(--border-color); + border-radius: 10px; padding: 10px; margin: 20px 20px 20px 20px; + box-shadow: var(--shadow); + position: relative; + overflow: hidden; +} + +.feedback-container::before{ + content: ""; + position: absolute; + top: 0; + left: 0; + width: 6px; + height: 100%; + background-color: var(--header-color); } footer { - padding: 20px; + padding: 10px; box-sizing: border-box; width: 100%; - background-color: #ddd; + background-color: white; + border-top: 1px solid var(--border-color); + border-radius: 0; + } .big-footer { diff --git a/client/src/App.jsx b/client/src/App.jsx index 6fdca57d..a50a661a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -44,7 +44,7 @@ function App() { - {staging && } + {/* {staging && } */}
} /> @@ -62,17 +62,17 @@ function App() {
  • - +
  • - +
  • - +
diff --git a/client/src/components/Feedback.jsx b/client/src/components/Feedback.jsx index 71cfa77a..8efdfc30 100644 --- a/client/src/components/Feedback.jsx +++ b/client/src/components/Feedback.jsx @@ -3,12 +3,14 @@ import '../styles/Feedback.css'; export default function Feedback() { return (
- - - - - - +
+ + + + + + +

Have feedback? Please fill out our feedback form!

diff --git a/client/src/components/Schedule.jsx b/client/src/components/Schedule.jsx index 30b29951..fcadaf8e 100644 --- a/client/src/components/Schedule.jsx +++ b/client/src/components/Schedule.jsx @@ -4,7 +4,7 @@ import scheduleData from '../data/schedule.json'; import routeData from '../data/routes.json'; import { aggregatedSchedule } from '../data/parseSchedule'; -export default function Schedule({ selectedRoute, setSelectedRoute, selectedStop, setSelectedStop }) { +export default function Schedule({ selectedRoute, setSelectedRoute, selectedStop, setSelectedStop, collapsibleSecondaryTimeline = true, defaultExpanded = true }) { // Validate props once at the top if (typeof setSelectedRoute !== 'function') { throw new Error('setSelectedRoute must be a function'); @@ -18,6 +18,8 @@ export default function Schedule({ selectedRoute, setSelectedRoute, selectedStop const [routeNames, setRouteNames] = useState(Object.keys(aggregatedSchedule[selectedDay])); const [stopNames, setStopNames] = useState([]); const [schedule, setSchedule] = useState([]); + const [expandedGroups, setExpandedGroups] = useState(new Set()); + const [userHasInteracted, setUserHasInteracted] = useState(false); // Define safe values to avoid repeated null checks const safeSelectedStop = selectedStop || "all"; @@ -34,6 +36,41 @@ export default function Schedule({ selectedRoute, setSelectedRoute, selectedStop } }, [selectedDay, selectedRoute, setSelectedRoute]); + // Initialize expanded groups based on defaultExpanded setting and auto-collapse past routes + // Only run this when route or day changes, not on every render + useEffect(() => { + if (collapsibleSecondaryTimeline && schedule[safeSelectedRoute] && !userHasInteracted) { + const newExpandedGroups = new Set(); + + schedule[safeSelectedRoute].forEach((time, index) => { + const firstStop = routeData[safeSelectedRoute].STOPS[0]; + const firstStopTime = offsetTime(time, routeData[safeSelectedRoute][firstStop].OFFSET); + const isPastRoute = selectedDay === now.getDay() && firstStopTime < now; + const hasUpcomingSecondary = hasUpcomingSecondaryStops(index); + + // Only expand if: + // 1. defaultExpanded is true AND (it's not a past route OR it has upcoming secondary stops), OR + // 2. It has upcoming secondary stops (keep open to show them) + if (defaultExpanded && (!isPastRoute || hasUpcomingSecondary)) { + newExpandedGroups.add(index); + } else if (hasUpcomingSecondary) { + // Always keep open if there are upcoming secondary stops + newExpandedGroups.add(index); + } + }); + + setExpandedGroups(newExpandedGroups); + } else if (collapsibleSecondaryTimeline && !defaultExpanded && !userHasInteracted) { + // Initialize all groups as collapsed if defaultExpanded is false + setExpandedGroups(new Set()); + } + }, [schedule, safeSelectedRoute, collapsibleSecondaryTimeline, defaultExpanded, selectedDay, userHasInteracted]); + + // Reset user interaction flag when route or day changes + useEffect(() => { + setUserHasInteracted(false); + }, [selectedRoute, selectedDay]); + // Update stopNames and selectedStop when selectedRoute changes useEffect(() => { if (!safeSelectedRoute || !(safeSelectedRoute in routeData)) return; @@ -55,110 +92,313 @@ export default function Schedule({ selectedRoute, setSelectedRoute, selectedStop return date; } + // Function to toggle group expansion + const toggleGroup = (groupIndex) => { + if (!collapsibleSecondaryTimeline) return; + + setUserHasInteracted(true); // Mark that user has manually interacted + + const newExpandedGroups = new Set(expandedGroups); + if (newExpandedGroups.has(groupIndex)) { + newExpandedGroups.delete(groupIndex); + } else { + newExpandedGroups.add(groupIndex); + } + setExpandedGroups(newExpandedGroups); + } + + // Function to toggle all groups + const toggleAllGroups = () => { + if (!collapsibleSecondaryTimeline || !schedule[safeSelectedRoute]) return; + + setUserHasInteracted(true); // Mark that user has manually interacted + + const allGroups = new Set(); + const hasExpandedGroups = expandedGroups.size > 0; + + if (!hasExpandedGroups) { + // Expand all groups (except past routes if we want to keep them collapsed) + schedule[safeSelectedRoute].forEach((time, index) => { + const firstStop = routeData[safeSelectedRoute].STOPS[0]; + const firstStopTime = offsetTime(time, routeData[safeSelectedRoute][firstStop].OFFSET); + const isPastRoute = selectedDay === now.getDay() && firstStopTime < now; + + // Only expand non-past routes + if (!isPastRoute) { + allGroups.add(index); + } + }); + } + + setExpandedGroups(allGroups); + } + + // Function to find the next upcoming stop for a route group + const getNextUpcomingStop = (routeGroupIndex) => { + if (selectedDay !== now.getDay() || !schedule[safeSelectedRoute]) return null; + + const routeTimes = schedule[safeSelectedRoute]; + const currentRouteTime = routeTimes[routeGroupIndex]; + if (!currentRouteTime) return null; + + const stops = routeData[safeSelectedRoute].STOPS; + const stopTimes = stops.map(stop => offsetTime(currentRouteTime, routeData[safeSelectedRoute][stop].OFFSET)); + + // Find the first stop that hasn't passed yet + for (let i = 0; i < stopTimes.length; i++) { + if (stopTimes[i] > now) { + return i; // Return the index of the next upcoming stop + } + } + + return null; // All stops have passed + } + + // Function to check if a route group has any upcoming secondary stops + const hasUpcomingSecondaryStops = (routeGroupIndex) => { + if (selectedDay !== now.getDay() || !schedule[safeSelectedRoute]) return false; + + const routeTimes = schedule[safeSelectedRoute]; + const currentRouteTime = routeTimes[routeGroupIndex]; + if (!currentRouteTime) return false; + + const stops = routeData[safeSelectedRoute].STOPS; + + // Check if any secondary stops (index > 0) haven't passed yet + for (let i = 1; i < stops.length; i++) { + const stopTime = offsetTime(currentRouteTime, routeData[safeSelectedRoute][stops[i]].OFFSET); + if (stopTime > now) { + return true; // Found an upcoming secondary stop + } + } + + return false; // No upcoming secondary stops + } + + // Update timeline line height to match content + useEffect(() => { + const updateTimelineLineHeight = () => { + const timelineContainer = document.querySelector('.timeline-container'); + const timelineLine = document.querySelector('.timeline-line'); + const timelineContent = document.querySelector('.timeline-content'); + + if (timelineContainer && timelineLine && timelineContent) { + // Get the full height of the content + const contentHeight = timelineContent.scrollHeight; + // Set the timeline line height to match the content height + timelineLine.style.height = `${contentHeight}px`; + } + }; + + // Update height immediately + updateTimelineLineHeight(); + + // Update height when content changes (e.g., when groups expand/collapse) + const timeoutId = setTimeout(updateTimelineLineHeight, 100); + + return () => clearTimeout(timeoutId); + }, [schedule, selectedRoute, selectedDay, selectedStop, expandedGroups]); + // scroll to the current time on route change useEffect(() => { - const scheduleDiv = document.querySelector('.schedule-scroll'); - if (!scheduleDiv) return; + const timelineContainer = document.querySelector('.timeline-container'); + if (!timelineContainer) return; if (selectedDay !== now.getDay()) return; // only scroll if viewing today's schedule - const currentTimeRow = Array.from(scheduleDiv.querySelectorAll('td.outdented')).find(td => { - const text = td.textContent.trim(); + + // First, try to find a highlighted item (current time) + let targetItem = Array.from(timelineContainer.querySelectorAll('.timeline-item.current-time, .secondary-timeline-item.current-time')).find(item => { + return item.offsetParent !== null; // Make sure it's visible + }); - // Expect "H:MM AM/PM ..." → split at the first space - const [timePart, meridian] = text.split(" "); - if (!timePart || !meridian) return false; + // If no highlighted item, find the first current or future time item + if (!targetItem) { + targetItem = Array.from(timelineContainer.querySelectorAll('.timeline-item, .secondary-timeline-item')).find(item => { + const timeElement = item.querySelector('.timeline-time, .secondary-timeline-time'); + if (!timeElement) return false; + + const text = timeElement.textContent.trim(); + const [timePart, meridian] = text.split(" "); + if (!timePart || !meridian) return false; - const [rawHours, rawMinutes] = timePart.split(":"); - let hours = parseInt(rawHours, 10); - const minutes = parseInt(rawMinutes, 10); + const [rawHours, rawMinutes] = timePart.split(":"); + let hours = parseInt(rawHours, 10); + const minutes = parseInt(rawMinutes, 10); - // Convert to 24h - if (meridian.toUpperCase() === "PM" && hours < 12) { - hours += 12; - } - if (meridian.toUpperCase() === "AM" && hours === 12) { - hours = 0; - } + // Convert to 24h + if (meridian.toUpperCase() === "PM" && hours < 12) { + hours += 12; + } + if (meridian.toUpperCase() === "AM" && hours === 12) { + hours = 0; + } - const timeDate = new Date(); - timeDate.setHours(hours, minutes, 0, 0); + const timeDate = new Date(); + timeDate.setHours(hours, minutes, 0, 0); - return timeDate >= now; - }); + return timeDate >= now; + }); + } - if (currentTimeRow) { - currentTimeRow.scrollIntoView({ behavior: "auto" }); + if (targetItem) { + // Add a small delay to ensure the timeline line height has been updated + setTimeout(() => { + targetItem.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 150); } - }, [selectedRoute, selectedDay, selectedStop, schedule]); + }, [selectedRoute, selectedDay, selectedStop, schedule, expandedGroups]); const daysOfTheWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; return ( -
-

Schedule

-
- - +
+
+

Schedule

+
+
+ + +
+
+ +
+ {routeNames.map((route, index) => ( + + ))} +
+
+
+ + +
+ {collapsibleSecondaryTimeline && safeSelectedStop === "all" && ( +
+ +
+ )} +
-
- - -
-
- - -
-
- - - - - - - - { - safeSelectedStop === "all" ? - schedule[safeSelectedRoute]?.map((time, index) => ( - routeData[safeSelectedRoute].STOPS.map((stop, sidx) => ( - - - - )) - )) : - schedule[safeSelectedRoute]?.map((time, index) => ( - - - - )) - } - -
Time (estimated)
{offsetTime(time, routeData[safeSelectedRoute][stop].OFFSET).toLocaleTimeString(undefined, { timeStyle: 'short' })} {routeData[safeSelectedRoute][stop].NAME}
{offsetTime(time, routeData[safeSelectedRoute][selectedStop]?.OFFSET).toLocaleTimeString(undefined, { timeStyle: 'short' })}
+
); diff --git a/client/src/components/TextAnimation.jsx b/client/src/components/TextAnimation.jsx new file mode 100644 index 00000000..18c15d36 --- /dev/null +++ b/client/src/components/TextAnimation.jsx @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; +import '../styles/TextAnimation.css'; + +export default function TextAnimation({ + words, + delayTime = 2500, + animationTime = 500, + height = 40, + fontSize = 16 +}) { + const [wordIndex, setWordIndex] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + + useEffect(() => { + const interval = setInterval(() => { + setIsAnimating(true); + + // after animation completes, update the word index + setTimeout(() => { + setWordIndex((prevIndex) => (prevIndex + 1) % words.length); + setIsAnimating(false); + }, animationTime); + }, delayTime); + + return () => clearInterval(interval); + }, [words, delayTime, animationTime]); + + return ( + +
+
+ {words[wordIndex]} +
+
+ {words[(wordIndex + 1) % words.length]} +
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/pages/About.jsx b/client/src/pages/About.jsx index 98f05ee7..1611fcf0 100644 --- a/client/src/pages/About.jsx +++ b/client/src/pages/About.jsx @@ -1,50 +1,72 @@ import '../styles/About.css'; -import { - useState, - useEffect, -} from 'react'; +import { } from 'react'; import { Link } from 'react-router'; +import TextAnimation from '../components/TextAnimation'; export default function About() { - - const [wordIndex, setWordIndex] = useState(0); const words = ['Reliable', 'Predictable', 'Accountable']; - // Rotate words every 2 seconds - useEffect(() => { - setInterval(() => { - setWordIndex((prevIndex) => { - return (prevIndex + 1) % words.length; - }); - }, 2000); - }, []); + //calculates on mount, didn't feel it was necessary to use a hook, not technically responsive, but doesn't need to be + const screenWidth = window.innerWidth; + const isMobile = screenWidth < 1025; return (
-

Making Shuttles

-

{words[wordIndex]}

-

- Shubble is the latest shuttle tracker, which is built using Mapkit JS, React, and Flask. -

-

- Shubble is an open source project under the Rensselaer Center for Open Source (RCOS). -

-

- Have an idea to improve it? Contributions are welcome. Visit our Github Repository to learn more. -

-

- Interested in Shubble's data? Take a look at our - - data page - . -

-
-

- © 2025 SHUBBLE -

-
+
+
+

Making Shuttles

+

+ +

+

+ Track RPI campus shuttles in real-time with Shubble - a reliable shuttle tracking system built for students, by students. +

+ +
+ + Explore Schedules + + + Live Location + +
+
+
+ + +
+
+

About Shubble

+
+
+

+ Shubble is the latest shuttle tracker, built using modern web technologies including MapKit JS, React, and Flask. + Our mission is to make campus transportation more reliable and predictable for students. +

+

+ As an open source project under the Rensselaer Center for Open Source (RCOS), Shubble represents the power + of collaborative development and student innovation. +

+

+ Have an idea to improve it? Contributions are welcome! Visit our + GitHub Repository to learn more. +

+
+
+ + Explore Data + +
+
+
+
+
) } diff --git a/client/src/styles/About.css b/client/src/styles/About.css index 64fcb05b..d7f4a2f0 100644 --- a/client/src/styles/About.css +++ b/client/src/styles/About.css @@ -1,42 +1,268 @@ -/* Regular view > 1025px */ -.word-rotator { +/* Landing Page Styles */ +.about { + min-height: 100dvh; /* Dynamic viewport height for mobile browsers */ + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + width: 100%; +} + +/* Container */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +.about-section .container{ + padding: 0 40px; +} + +/* Hero Section */ +.hero { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, transparent 0%, transparent 70%, var(--header-color) 100%); + color: var(--text-color); + position: relative; + overflow: hidden; + /* dvh for mobile support */ + height: calc(100dvh - 80px); + min-height: calc(100dvh - 80px); +} + + +.hero-content { + position: relative; + z-index: 1; + max-width: 800px; + padding: 0 20px; +} + +.hero-title { + font-size: 3.5rem; + font-weight: 800; + margin-bottom: 10px +} + +.hero-subtitle { + font-size: 2.5rem; + font-weight: 600; + margin-bottom: 2rem; + margin-top: 0; + min-height: 80px; + display: flex; + align-items: center; +} + +.hero-description { + font-size: 1.3rem; + line-height: 1.6; + margin-bottom: 3rem; + opacity: 0.95; + max-width: 600px; + margin-left: auto; + margin-right: auto; +} + +.hero-actions { + display: flex; + gap: 1.5rem; + justify-content: flex-start; + flex-wrap: wrap; +} + +.btn { + display: inline-block; + padding: 1rem 2rem; + border-radius: 50px; + text-decoration: none; + font-weight: 600; + font-size: 1.1rem; + transition: all 0.3s ease; + border: 2px solid transparent; + cursor: pointer; + text-align: center; + min-width: 160px; +} + +.btn-primary { + background: #206ef9; + color: white; + box-shadow: 0 4px 15px rgba(32, 110, 249, 0.4); +} + +.btn-primary:hover { + background: #1a5bd4; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(32, 110, 249, 0.6); +} + +.btn-secondary { + background: transparent; + color: var(--accent-color); + border: 2px solid var(--accent-color); +} + +.btn-secondary:hover { + background: white; + color: #667eea; + transform: translateY(-2px); +} + +.btn-outline { + background: transparent; color: #206ef9; - font-weight: bold; - text-decoration: underline; - text-decoration-color: #206ef9; + border: 2px solid #206ef9; +} + +.btn-outline:hover { + background: #206ef9; + color: white; + transform: translateY(-2px); } +/* About Section */ +.about-section { + padding: 5rem 0; + background: #f8f9fa; +} +.about-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} -.about { - padding: 10px; +.about-text p { + font-size: 1.1rem; + line-height: 1.7; + margin-bottom: 1.5rem; + color: #555; } -.link1{ - margin-left: .6ch; +.about-text a { + color: #206ef9; + text-decoration: none; + font-weight: 600; } -.about h1 { - font-size: 2.5em; - font-weight: 700; +.about-text a:hover { + text-decoration: underline; +} + +.about-actions { + width: 100%; + display: flex; + justify-content: flex-start; + align-items: flex-start; +} + +.about-actions .btn{ + font-size: 1rem; + padding: 0.5rem 1rem; +} + +/* Footer */ +.footer { + background: #333; + color: white; + padding: 2rem 0; text-align: center; - } -.about p { - font-size: 1.3em; - text-align: left; - padding: 5px; +.footer p { + margin: 0; + font-size: 0.9rem; + opacity: 0.8; +} + +/* Text Animation Styles */ +.about .current-word, +.about .next-word { + /* text gradient */ + background: linear-gradient(135deg, #206ef9, #7eaaf8, #206ef9); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; + font-weight: bold; } -@media (max-width: 1025px){ - /* Mobile View < 1025px */ - .about h1 { - font-size: 1.5em; +/* Mobile Responsive */ +@media (max-width: 1025px) { + .hero-title { + font-size: 2.5rem; + } + + .hero-subtitle { + font-size: 2rem; + } + + .hero-description { + font-size: 1.1rem; + margin-bottom: 2rem; + } + + .hero-actions { + flex-direction: column; + align-items: center; + } + + .btn { + width: 100%; + max-width: 280px; + } + + .section-title { + font-size: 2rem; } + + .features-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .feature-card { + padding: 2rem 1.5rem; + } + + .about-content { + grid-template-columns: 1fr; + gap: 2rem; + } + + .about-text p { + font-size: 1rem; + } +} - .about p { - font-size: 1em; +@media (max-width: 768px) { + .hero { + min-height: calc(100dvh - 50px); + padding: 2rem 0; + height: calc(100dvh - 50px); + } + + .hero-title { + font-size: 2.5rem; + } + + .hero-subtitle { + font-size: 1.5rem; + min-height: 50px; + } + + .features { + padding: 3rem 0; + } + + .about-section { + padding: 3rem 0; + } + + .container { + padding: 0 15px; } } diff --git a/client/src/styles/Feedback.css b/client/src/styles/Feedback.css index af6e914e..fc27c543 100644 --- a/client/src/styles/Feedback.css +++ b/client/src/styles/Feedback.css @@ -10,3 +10,14 @@ align-items: center; padding: 0 10px; } + +.flex-feedback .icon-container { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background-color: var(--header-color); + padding:3px; + border-radius: 6px; +} diff --git a/client/src/styles/Schedule.css b/client/src/styles/Schedule.css index 39162853..2cedf4fd 100644 --- a/client/src/styles/Schedule.css +++ b/client/src/styles/Schedule.css @@ -1,34 +1,659 @@ -.schedule-scroll { - padding: 10px 15px; - overflow: scroll; - border-style: solid; - border-width: thin; - margin-bottom: 0; - height: calc(50vh - 150px); - min-width: 250px; +/* Schedule Container */ +.schedule-container { + padding: 20px; + max-width: 90%; + width:100%; + box-sizing: border-box; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } -.schedule-scroll::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 10px; +/* Header Section */ +.schedule-header { + margin-bottom: 30px; } -.schedule-scroll::-webkit-scrollbar-thumb:hover { - background: #555; +.schedule-header h2 { + margin: 0 0 20px 0; + color: #2c3e50; + font-size: 28px; + font-weight: 600; } -.indented-time { +.schedule-controls { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: center; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.control-group label { + font-size: 14px; + font-weight: 500; + color: #555; + margin: 0; +} + +.schedule-dropdown { + padding: 8px 12px; + font-size: 14px; + border: 2px solid #e1e8ed; + border-radius: 8px; + background: white; + color: #2c3e50; + transition: all 0.2s ease; + min-width: 120px; +} + +.schedule-dropdown:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); +} + +.collapse-all-button { + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + border: 2px solid #3498db; + border-radius: 8px; + background: white; + color: #3498db; + cursor: pointer; + transition: all 0.2s ease; + min-width: 120px; +} + +.collapse-all-button:hover { + background: #3498db; + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(52, 152, 219, 0.2); +} + +.collapse-all-button:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(52, 152, 219, 0.2); +} + +/* Route Toggle Buttons */ +.route-toggle { + display: flex; + gap: 4px; + background: #f8f9fa; + border-radius: 8px; + padding: 4px; + border: 1px solid #e1e8ed; +} + +.route-toggle-button { + flex: 1; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + border: none; + border-radius: 6px; + background: transparent; + color: #6c757d; + cursor: pointer; + transition: all 0.2s ease; + min-height: 36px; +} + +.route-toggle-button:hover { + background: rgba(52, 152, 219, 0.1); + color: #3498db; +} + +.route-toggle-button.active { + background: #3498db; + color: white; + box-shadow: 0 2px 4px rgba(52, 152, 219, 0.2); +} + +.route-toggle-button.active:hover { + background: #2980b9; +} + +/* Timeline Container */ +.timeline-container { + position: relative; + padding: 20px 0; + max-height: calc(70vh - 200px); + overflow-y: auto; + scroll-behavior: smooth; + min-height: 200px; +} + +.timeline-container::-webkit-scrollbar { + width: 6px; +} + +.timeline-container::-webkit-scrollbar-track { + background: #f8f9fa; + border-radius: 3px; +} + +.timeline-container::-webkit-scrollbar-thumb { + background: #bdc3c7; + border-radius: 3px; +} + +.timeline-container::-webkit-scrollbar-thumb:hover { + background: #95a5a6; +} + +/* Timeline Line */ +.timeline-line { + position: absolute; + left: 20px; + top: 0; + width: 2px; + background: linear-gradient(to bottom, #3498db, #e74c3c); + border-radius: 1px; + z-index: 1; + height: 100px; /* Initial height, will be updated by JavaScript */ +} + +/* Timeline Content */ +.timeline-content { + position: relative; + padding-left: 48px; + padding-right:20px; + +} + +/* Timeline Items */ +.timeline-item { + position: relative; + margin-bottom: 20px; + display: flex; + align-items: flex-start; + gap: 15px; +} + +.timeline-dot { + position: absolute; + left: -35px; + top: 8px; + width: 12px; + height: 12px; + background: #3498db; + border: 3px solid white; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 2; +} + +.timeline-content-item { + background: white; + border-radius: 12px; + padding: 16px 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + border: 1px solid #e1e8ed; + flex: 1; + transition: all 0.2s ease; +} + +.timeline-content-item:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + transform: translateY(-1px); +} + +.timeline-time { + font-size: 18px; + font-weight: 600; + color: #2c3e50; + margin-bottom: 4px; +} + +.timeline-stop { + font-size: 14px; + color: #7f8c8d; + font-weight: 500; +} + +/* Route Group Styling */ +.timeline-route-group { + margin-bottom: 25px; +} + +.timeline-route-group .timeline-item.first-stop { + margin-bottom: 0px; +} + +/* Collapsible Header Styling */ +.timeline-item.collapsible-header { + cursor: pointer; + user-select: none; + transition: all 0.2s ease; +} + +.timeline-item.collapsible-header:hover { + transform: translateY(-1px); +} + +.timeline-item.collapsible-header:hover .timeline-content-item { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +.collapse-indicator { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: rgba(52, 152, 219, 0.1); + border-radius: 50%; + transition: all 0.3s ease; +} + +.collapse-arrow { + font-size: 12px; + color: #3498db; + transition: transform 0.3s ease; + font-weight: bold; +} + +.collapse-arrow.expanded { + transform: rotate(180deg); +} + +/* Secondary Timeline Styling */ +.secondary-timeline { + position: relative; + margin-left: 30px; padding-left: 20px; } -.schedule-dropdown-style { - padding: 5px; - font-size: 16px; - border: 1px solid; - border-radius: 5px; - margin: 5px 0 5px 5px; +.secondary-timeline::before { + content: ''; + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(to bottom, + transparent 0%, + #e1e8ed 20%, + #e1e8ed 95%, + transparent 100% + ); +} + +/* Collapsible Content */ +.secondary-timeline.collapsible-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out, opacity 0.2s ease-out; + opacity: 0; + margin-left: 0; + padding-left: 0; + border-left: none; +} + +.secondary-timeline.collapsible-content.expanded { + max-height: 1000px; + opacity: 1; + transition: max-height 0.3s ease-in, opacity 0.3s ease-in; + margin-left: 30px; + padding-left: 20px; + padding-top:10px; + padding-right:10px; + overflow: visible; + +} + +.secondary-timeline.collapsible-content.expanded::before { + content: ''; + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(to bottom, + #e1e8ed 0%, + #e1e8ed 90%, + transparent 100% + ); +} + +.secondary-timeline-item { + position: relative; + margin-bottom: 12px; + display: flex; + align-items: flex-start; + gap: 12px; +} + +.secondary-timeline-dot { + position: absolute; + left: -25px; + top: 6px; + width: 8px; + height: 8px; + background: #95a5a6; + border: 2px solid white; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + z-index: 2; +} + +.secondary-timeline-content { + background: white; + border-radius: 8px; + padding: 10px 14px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); + border: 1px solid #e9ecef; + flex: 1; + transition: all 0.2s ease; +} + +.secondary-timeline-content:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); +} + +.secondary-timeline-time { + font-size: 14px; + font-weight: 500; + color: #2c3e50; + margin-bottom: 2px; +} + +.secondary-timeline-stop { + font-size: 12px; + color: #868e96; + font-weight: 400; +} + +/* current stop */ +.timeline-item.current-time .timeline-dot { + background: var(--accent-color); + animation: pulse 2s infinite; + box-shadow: 0 0 0 4px rgba(126, 161, 237, 0.2); +} + +.timeline-item.current-time .timeline-content-item { + background: linear-gradient(135deg, #afcbfc5c, #ffffff); + border-color: var(--accent-color); + box-shadow: 0 4px 16px rgba(126, 161, 237, 0.15); +} + +.timeline-item.current-time .timeline-time { + color: var(--accent-color); + font-weight: 700; +} + +.timeline-item.current-time .timeline-stop { + color: var(--accent-color); + font-weight: 600; +} + +/* Single Stop Styling */ +.timeline-item.single-stop .timeline-dot { + background: #27ae60; +} + +.timeline-item.single-stop.current-time .timeline-dot { + background: var(--accent-color); +} + +/* Past Time Styling */ +.timeline-item.past-time .timeline-dot { + background: #bdc3c7 !important; + opacity: 0.6; +} + +.timeline-item.past-time .timeline-content-item { + background: #f8f9fa !important; + border-color: #e9ecef !important; + opacity: 0.7; + position: relative; +} + +.timeline-item.past-time .timeline-content-item::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.1)); + border-radius: 12px; + pointer-events: none; +} + +.timeline-item.past-time .timeline-time { + color: #95a5a6 !important; + text-decoration: line-through; + text-decoration-color: #bdc3c7; + text-decoration-thickness: 1px; +} + +.timeline-item.past-time .timeline-stop { + color: #bdc3c7 !important; +} + +.timeline-item.past-time:hover .timeline-content-item { + transform: none !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important; +} + +/* Override current time styling for past times */ +.timeline-item.past-time.current-time .timeline-dot { + background: var(--accent-color) !important; + opacity: 1; +} + +.timeline-item.past-time.current-time .timeline-content-item { + background: linear-gradient(135deg, #afcbfc5c, #ffffff) !important; + border-color: var(--accent-color) !important; + opacity: 1; +} + +.timeline-item.past-time.current-time .timeline-content-item::after { + display: none; +} + +.timeline-item.past-time.current-time .timeline-time { + color: var(--accent-color) !important; + text-decoration: none !important; +} + +.timeline-item.past-time.current-time .timeline-stop { + color: var(--accent-color) !important; +} + +/* Secondary Timeline Current Time Styling */ +.secondary-timeline-item.current-time .secondary-timeline-dot { + background: var(--accent-color) !important; + animation: pulse 2s infinite; + box-shadow: 0 0 0 3px rgba(126, 161, 237, 0.2); +} + +.secondary-timeline-item.current-time .secondary-timeline-content { + background: linear-gradient(135deg, #afcbfc5c, #ffffff) !important; + border-color: var(--accent-color) !important; + box-shadow: 0 2px 8px rgba(126, 161, 237, 0.15); +} + +.secondary-timeline-item.current-time .secondary-timeline-time { + color: var(--accent-color) !important; + font-weight: 600; +} + +.secondary-timeline-item.current-time .secondary-timeline-stop { + color: var(--accent-color) !important; + font-weight: 500; +} + +/* Secondary Timeline Past Time Styling */ +.secondary-timeline-item.past-time .secondary-timeline-dot { + background: #bdc3c7 !important; + opacity: 0.6; +} + +.secondary-timeline-item.past-time .secondary-timeline-content { + background: #f1f3f4 !important; + border-color: #e9ecef !important; + opacity: 0.7; + position: relative; +} + +.secondary-timeline-item.past-time .secondary-timeline-content::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.1)); + border-radius: 8px; + pointer-events: none; +} + +.secondary-timeline-item.past-time .secondary-timeline-time { + color: #95a5a6 !important; + text-decoration: line-through; + text-decoration-color: #bdc3c7; + text-decoration-thickness: 1px; +} + +.secondary-timeline-item.past-time .secondary-timeline-stop { + color: #bdc3c7 !important; +} + +.secondary-timeline-item.past-time:hover .secondary-timeline-content { + transform: none !important; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05) !important; +} + +/* Override current time styling for past times in secondary timeline */ +.secondary-timeline-item.past-time.current-time .secondary-timeline-dot { + background: var(--accent-color) !important; + opacity: 1; +} + +.secondary-timeline-item.past-time.current-time .secondary-timeline-content { + background: linear-gradient(135deg, #afcbfc5c, #ffffff) !important + border-color: var(--accent-color) !important; + opacity: 1; +} + +.secondary-timeline-item.past-time.current-time .secondary-timeline-content::after { + display: none; +} + +.secondary-timeline-item.past-time.current-time .secondary-timeline-time { + color: var(--accent-color) !important; + text-decoration: none !important; +} + +.secondary-timeline-item.past-time.current-time .secondary-timeline-stop { + color: var(--accent-color) !important; +} + +/* Animations */ +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(126, 161, 237, 0.4); + } + 70% { + box-shadow: 0 0 0 8px rgba(126, 161, 237, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); + } } -th.schedule-header { - text-align: left; +/* Responsive Design */ +@media (max-width: 768px) { + .schedule-container { + padding: 15px; + } + + .schedule-controls { + flex-direction: column; + align-items: stretch; + gap: 15px; + } + + .control-group { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .schedule-dropdown { + min-width: 150px; + } + + .collapse-all-button { + min-width: 100px; + padding: 6px 12px; + font-size: 13px; + } + + .route-toggle { + gap: 2px; + padding: 3px; + } + + .route-toggle-button { + padding: 6px 12px; + font-size: 13px; + min-height: 32px; + } + + .timeline-content { + padding-left:37px; + } + + .timeline-line { + left: 15px; + } + + .timeline-dot { + left: -30px; + } + + .timeline-route-group .timeline-item:not(:first-child) .timeline-dot { + left: -28px; + } + + .secondary-timeline { + margin-left: 20px; + padding-left: 15px; + } + + .secondary-timeline-dot { + left: -22px; + } + + .collapse-indicator { + right: 12px; + width: 20px; + height: 20px; + } + + .collapse-arrow { + font-size: 10px; + } + + .secondary-timeline.collapsible-content.expanded { + margin-left: 20px; + padding-left: 15px; + overflow: visible; + } } diff --git a/client/src/styles/TextAnimation.css b/client/src/styles/TextAnimation.css new file mode 100644 index 00000000..eba41603 --- /dev/null +++ b/client/src/styles/TextAnimation.css @@ -0,0 +1,34 @@ +.text-animation { + display: flex; + flex-direction: column; + height: var(--word-height, 40px); + overflow: hidden; +} + +.animation-container { + display: flex; + flex-direction: column; + height: var(--container-height, 80px); +} + +.animation-container.animate { + animation: animate var(--animation-duration, 1s) ease-in-out; +} + +.current-word, .next-word { + height: var(--word-height, 40px); + font-size: var(--font-size, 16px); + display: flex; + align-items: center; +} + + + +@keyframes animate { + 0% { + transform: translateY(0%); + } + 100% { + transform: translateY(-50%); + } +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index b259d9ad..9d043164 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,10 @@ import react from '@vitejs/plugin-react' export default defineConfig({ root: 'client', plugins: [react()], + server: { + host: '0.0.0.0', // Allow external connections + port: 5173 + }, build: { outDir: '../client/dist', emptyOutDir: true