+
+
Schedule
+
+
+ Day:
+
+ {
+ daysOfTheWeek.map((day, index) =>
+
+ {day}
+
+ )
+ }
+
+
+
+
Route:
+
+ {routeNames.map((route, index) => (
+ setSelectedRoute(route)}
+ >
+ {route}
+
+ ))}
+
+
+
+ Stop:
+ setSelectedStop(e.target.value)}>
+ All Stops
+ {
+ stopNames.map((stop, index) =>
+
+ {routeData[safeSelectedRoute][stop]?.NAME}
+
+ )
+ }
+
+
+ {collapsibleSecondaryTimeline && safeSelectedStop === "all" && (
+
+ 0 ? "Collapse all" : "Expand all"}
+ >
+ {expandedGroups.size > 0 ? "Collapse All" : "Expand All"}
+
+
+ )}
+
-
-
Loop:
-
setSelectedRoute(e.target.value)}>
+
+
+
+
{
- routeNames.map((route, index) =>
-
- {route}
-
- )
+ safeSelectedStop === "all" ?
+ schedule[safeSelectedRoute]?.map((time, index) => {
+ const firstStop = routeData[safeSelectedRoute].STOPS[0];
+ const firstStopTime = offsetTime(time, routeData[safeSelectedRoute][firstStop].OFFSET);
+ const nextUpcomingStopIndex = getNextUpcomingStop(index);
+ const isFirstStopCurrentTime = selectedDay === now.getDay() &&
+ firstStopTime.getHours() === now.getHours() &&
+ Math.abs(firstStopTime.getMinutes() - now.getMinutes()) <= 5 &&
+ nextUpcomingStopIndex === 0; // Only highlight if first stop is actually the next upcoming
+ const isFirstStopPastTime = selectedDay === now.getDay() && firstStopTime < now;
+
+ const isExpanded = expandedGroups.has(index);
+ const hasSecondaryStops = routeData[safeSelectedRoute].STOPS.length > 1;
+
+ return (
+
+ {/* Main timeline - first stop only */}
+
toggleGroup(index) : undefined}>
+
+
+
+ {firstStopTime.toLocaleTimeString(undefined, { timeStyle: 'short' })}
+
+
+ {routeData[safeSelectedRoute][firstStop].NAME}
+
+ {collapsibleSecondaryTimeline && hasSecondaryStops && (
+
+ ▼
+
+ )}
+
+
+
+ {/* Secondary timeline - subsequent stops */}
+ {hasSecondaryStops && (
+
+ {routeData[safeSelectedRoute].STOPS.slice(1).map((stop, sidx) => {
+ const stopTime = offsetTime(time, routeData[safeSelectedRoute][stop].OFFSET);
+ const actualStopIndex = sidx + 1; // +1 because we sliced off the first stop
+ const isUpcomingStop = nextUpcomingStopIndex === actualStopIndex;
+ const isPastTime = selectedDay === now.getDay() && stopTime < now;
+
+ return (
+
+
+
+
+ {stopTime.toLocaleTimeString(undefined, { timeStyle: 'short' })}
+
+
+ {routeData[safeSelectedRoute][stop].NAME}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+ }) :
+ schedule[safeSelectedRoute]?.map((time, index) => {
+ const stopTime = offsetTime(time, routeData[safeSelectedRoute][selectedStop]?.OFFSET);
+ const isCurrentTime = selectedDay === now.getDay() &&
+ stopTime.getHours() === now.getHours() &&
+ Math.abs(stopTime.getMinutes() - now.getMinutes()) <= 5;
+ const isPastTime = selectedDay === now.getDay() && stopTime < now;
+
+ return (
+
+
+
+
+ {stopTime.toLocaleTimeString(undefined, { timeStyle: 'short' })}
+
+
+ {routeData[safeSelectedRoute][selectedStop]?.NAME}
+
+
+
+ );
+ })
}
-
-
-
- Stop:
- setSelectedStop(e.target.value)}>
- All Stops
- {
- stopNames.map((stop, index) =>
-
- {routeData[safeSelectedRoute][stop]?.NAME}
-
- )
- }
-
-
-
-
-
-
- Time (estimated)
-
-
-
- {
- safeSelectedStop === "all" ?
- schedule[safeSelectedRoute]?.map((time, index) => (
- routeData[safeSelectedRoute].STOPS.map((stop, sidx) => (
-
- {offsetTime(time, routeData[safeSelectedRoute][stop].OFFSET).toLocaleTimeString(undefined, { timeStyle: 'short' })} {routeData[safeSelectedRoute][stop].NAME}
-
- ))
- )) :
- schedule[safeSelectedRoute]?.map((time, index) => (
-
- {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
- .
-
-
+
+
+
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