...
 
Commits (2)
{
"presets": [
["env", {
"modules": false,
"targets": {
"browsers": "> 1%",
"uglify": true
},
"useBuiltIns": true
}]
[
"env",
{
"modules": false,
"targets": {
"browsers": "> 1%",
"uglify": true
},
"useBuiltIns": true
}
],
"react"
],
"plugins": [
"syntax-dynamic-import",
"transform-object-rest-spread",
["transform-class-properties", { "spec": true }]
[
"transform-class-properties",
{
"spec": true
}
]
]
}
......@@ -14,3 +14,4 @@ RUN export SECRET_KEY_BASE=$(rails secret)
RUN rails assets:precompile
RUN rails db:migrate
RUN rails db:seed
RUN rails runner db/load_course_ratings.rb
......@@ -21,6 +21,9 @@ gem 'uglifier'
gem 'webpacker', '~> 3.5'
# Access Ruby data from JavaScript
gem 'gon'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use ActiveModel has_secure_password
......@@ -74,7 +77,3 @@ gem 'apipie-rails'
# Markdown for API docs
gem 'maruku'
# gem 'jquery-rails'
# gem 'font-awesome-sass', '~> 5.3.1'
......@@ -64,6 +64,10 @@ GEM
ffi (1.9.25)
globalid (0.4.1)
activesupport (>= 4.2.0)
gon (6.2.1)
actionpack (>= 3.0)
multi_json
request_store (>= 1.0)
httparty (0.16.3)
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
......@@ -144,6 +148,8 @@ GEM
rb-fsevent (0.10.3)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
request_store (1.4.1)
rack (>= 1.4)
rubocop (0.58.2)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
......@@ -216,6 +222,7 @@ DEPENDENCIES
apipie-rails
byebug
capybara (~> 2.13)
gon
httparty
icalendar
jbuilder (~> 2.5)
......
// Place all the behaviors and hooks related to the matching controller here.
// All this logic will automatically be available in application.js.
// Place all the styles related to the about controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
......@@ -18,6 +18,7 @@
#semester-select {
min-width: 100px;
margin-right: 8px;
}
#cart-button {
......
class AboutController < ApplicationController
def index; end
end
......@@ -28,7 +28,6 @@ class API::CourseSectionsController < ApplicationController
@sections = @sections.where('UPPER(instructors.name) LIKE UPPER(?)', "%#{params[:instructor]}%")
end
# @sections = CourseSection.fetch(params).all
res = @sections.map do |s|
{
id: s.id,
......@@ -38,6 +37,9 @@ class API::CourseSectionsController < ApplicationController
crn: s.crn,
title: s.title,
instructor_name: s.instructor_name,
instructor_url: instructor_url(s.instructor),
teaching_rating: s.instructor.rating,
course_rating: s.course_rating,
section_type: s.section_type,
start_date: s.start_date,
end_date: s.end_date,
......
# Configures the application.
class ApplicationController < ActionController::Base
include BySemester
# On each request, set the semester and cart.
before_action :set_cart
# The user's cart is stored as a JSON-encoded list of CRNs.
# set_cart sets the @cart variable, which is a list of the sections represented by the CRNs.
def set_cart
# set the cart cookie to be empty if it doesn't already exist
cookies.permanent[:cart] = "[]" if cookies.permanent[:cart].nil?
# decode the JSON list into an array
@cart = JSON.parse(cookies.permanent[:cart])
# get rid of any invalid CRNs
@cart = @cart.reject { |crn| CourseSection.find_by_crn(crn).nil? }
# set the cookie to the JSON-encoded list of valid sections
cookies.permanent[:cart] = @cart.to_json
end
end
# BySemester contains logic for setting the current request's
# Semester. This is not needed by every page as it used to be, so it
# now lives in this concern instead of ApplicationController.
module BySemester
extend ActiveSupport::Concern
included do
before_action :set_semester
end
# This page needs to know what semester it should load data from.
# set_semester checks both the semester_id query parameter and the user's cookies
# to look for a semester id and loads whatever it finds into @semester.
#
# By default, load the most recent semester.
def set_semester
if params.key?(:semester_id)
@semester = Semester.find_by_id params[:semester_id]
cookies[:semester_id] = @semester.id
elsif cookies[:semester_id].nil?
@semester = Semester.first
cookies[:semester_id] = @semester.id
else
@semester = Semester.find_by_id cookies[:semester_id]
end
end
end
......@@ -2,29 +2,32 @@
class SchedulesController < ApplicationController
include SchedulesHelper
def show
valid_crns = @cart.reject { |crn|
s = CourseSection.find_by_crn(crn)
s.nil?
}
def show; end
@all = valid_crns.map { |crn|
def view
@all = params[:crns].split(',').map { |crn|
CourseSection.latest_by_crn(crn)
}
@all.reject!(&:nil?)
@without_online = @all.reject { |s|
s.start_time == "TBA" || s.end_time == "TBA"
}
@events = generate_fullcalender_events(@without_online)
end
def view
@all = params[:crns].split(',').map { |crn|
CourseSection.latest_by_crn(crn)
}
@all.reject!(&:nil?)
@without_online = @all.reject { |s|
def events
@cart = params[:crns].split(',')
.map { |crn| CourseSection.latest_by_crn(crn) }
.reject(&:nil?)
@without_online = @cart.reject { |s|
s.start_time == "TBA" || s.end_time == "TBA"
}
@events = generate_fullcalender_events(@without_online)
sections = @cart.map do |s|
s.serializable_hash.merge(instructor_name: s.instructor.name, instructor_url: instructor_url(s.instructor))
end
render json: { events: @events, sections: sections }
end
end
class SearchController < ApplicationController
include BySemester
def index
redirect_to(home_url) unless params[:query].length > 1
......@@ -9,13 +7,51 @@ class SearchController < ApplicationController
redirect_to(instructor_url(bell))
end
results = SearchHelper::GenericItem.fetchall(String.new(params[:query]), semester: @semester).group_by(&:type)
@instructors = results[:instructor]&.map(&:data)
@courses = results[:course]&.map(&:data)
@instructors = nil
@courses = nil
if @courses&.count == 1
redirect_to course_url(@courses.first)
elsif @instructors&.count == 1
/[[:alpha:]]{2,4} \d{3}/.match(params[:query]) do |m|
subj, num = m[0].split(' ')
course = Course.find_by(subject: subj.upcase, course_number: num)
redirect_to(course_url(course)) unless course.nil?
end
/[[:alpha:]]{2,4}/i.match(params[:query]) do |m|
@courses = Course.where(subject: m[0].upcase)
.joins(:course_sections)
.merge(CourseSection.in_semester(@semester))
.uniq
if @courses.empty?
@courses = Course.where("(courses.title LIKE ?)", "%#{params[:query]}%")
.joins(:course_sections)
.merge(CourseSection.in_semester(@semester))
.uniq
other = Course.where("(courses.description LIKE ?)", "%#{params[:query]}%")
.joins(:course_sections)
.merge(CourseSection.in_semester(@semester))
.uniq
@courses = [*@courses, *other].uniq
@instructors = Instructor.named(params[:query])
end
@courses.map! do |c|
c.serializable_hash.merge(url: course_url(c))
end
gon.courses = @courses
gon.courses.map!
end
/[0-9]{5}/.match(params[:query]) do |m|
redirect_to(course_url(CourseSection.latest_by_crn(m[0]).course))
end
if @courses&.count == 1 && @instructors&.count&.zero?
redirect_to course_url(@courses.first["id"])
elsif @courses&.count&.zero? && @instructors&.count == 1
redirect_to instructor_url(@instructors.first)
end
end
......
......@@ -19,7 +19,9 @@ module SchedulesHelper
{
title: s.name,
start: "#{formatted_date}T#{time}",
end: "#{formatted_date}T#{endtime}"
end: "#{formatted_date}T#{endtime}",
crn: s.crn,
active: true
}
end
end.flatten
......
module SearchHelper
def in_cart?(crn)
@cart.include? crn.to_s
end
class GenericQueryData
attr_reader :semester
attr_reader :sort_mode
......
......@@ -10,6 +10,9 @@
import '@babel/polyfill';
import 'url-polyfill';
import React from 'react';
import Cart from 'src/Cart';
const elementFromString = string => {
const html = new DOMParser().parseFromString(string, 'text/html');
return html.body.firstChild;
......
import React from 'react';
import ReactDOM from 'react-dom';
import Cart from 'src/Cart';
import QuickAdd from 'src/QuickAdd';
document.addEventListener('DOMContentLoaded', () => {
const calendarUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port == 3000 ? ':3000' : ''}/schedule`;
ReactDOM.render(
<QuickAdd
loadCalendar={() => {
window.location.href = calendarUrl;
}}
/>,
document.getElementById('quick-add')
);
});
// /**
// * Either adds or removes a section from the cart depending on
// * if it is currently in the cart.
// */
import $ from 'jquery';
import Cart from 'src/Cart';
const addOrRemoveFromCart = async (event, sectionNode) => {
event && event.stopPropagation();
if (event.target.tagName === 'A') return;
const section = { ...sectionNode.dataset };
Cart.toggleCrn(section.crn);
const icon = $(sectionNode.querySelector('.add-remove-btn #icon'));
const text = sectionNode.querySelector('.add-remove-btn .text');
if (Cart.includesCrn(section.crn)) {
icon.addClass('fa-minus').removeClass('fa-plus');
text.innerText = 'Remove';
} else {
icon.addClass('fa-plus').removeClass('fa-minus');
text.innerText = 'Add';
}
};
const initSearchListeners = () => {
const sectionItems = Array.from(document.querySelectorAll('.section-item'));
sectionItems.forEach(item => {
item.onclick = event => addOrRemoveFromCart(event, item);
});
setTimeout(() => {
sectionItems.forEach(item => {
const icon = $(item.querySelector('.add-remove-btn #icon'));
const text = item.querySelector('.add-remove-btn .text');
console.log(item.dataset.crn);
if (Cart.includesCrn(item.dataset.crn)) {
icon.addClass('fa-minus').removeClass('fa-plus');
text.innerText = 'Remove';
} else {
icon.addClass('fa-plus').removeClass('fa-minus');
text.innerText = 'Add';
}
});
}, 100);
};
document.addEventListener('DOMContentLoaded', initSearchListeners);
import Cart from 'src/cart';
import React from 'react';
import ReactDOM from 'react-dom';
import Cart from 'src/Cart';
import { saveAs } from 'file-saver';
import html2canvas from 'html2canvas';
import $ from 'jquery';
import 'fullcalendar';
import 'moment';
import CalendarPage from 'src/CalendarPage';
document.addEventListener('DOMContentLoaded', () => {
const eventsTemplate = document.querySelector('#events');
if (eventsTemplate) {
const eventsJSON = eventsTemplate.dataset.events;
const events = JSON.parse(eventsJSON);
window.events = events;
$('#calendar').fullCalendar({
defaultDate: new Date(2019, 0, 14),
defaultView: 'agendaWeek',
header: false,
events: renderEvents,
columnHeaderFormat: 'dddd',
allDaySlot: false,
});
}
initListeners();
ReactDOM.render(<CalendarPage />, document.getElementById('root'));
});
const renderEvents = (start, end, timezone, callback) => {
callback(window.events);
};
const remove = async item => {
await Cart.toggleSection({ ...item.dataset });
location.reload(true);
};
/**
* Generates a URL for the current sections in the schedule
* and sets the link in the modal to it.
*/
const setUrlInModal = () => {
document.getElementById('calendar-link').innerText = `${window.location.protocol}//${window.location.hostname}/api/schedules?crns=${Cart._courses.join(',')}`;
};
const downloadIcs = async () => {
const response = await fetch(`${window.location.protocol}//${window.location.hostname}/api/schedules?crns=${Cart._courses.join(',')}`);
const text = await response.text();
......@@ -69,7 +39,7 @@ const initListeners = () => {
document.getElementById('save-image').onclick = saveImage;
document.getElementById('share-url').innerText = `${window.location.protocol}//${window.location.hostname}/schedule/view?crns=${Cart._courses.join(',')}`;
document.getElementById('share-url').href= `${window.location.protocol}//${window.location.hostname}/schedule/view?crns=${Cart._courses.join(',')}`;
document.getElementById('share-url').href = `${window.location.protocol}//${window.location.hostname}/schedule/view?crns=${Cart._courses.join(',')}`;
};
if (!HTMLCanvasElement.prototype.toBlob) {
......
import Cart from 'src/cart';
import Cart from 'src/Cart';
import { saveAs } from 'file-saver';
import html2canvas from 'html2canvas';
import $ from 'jquery';
......
// Place all the behaviors and hooks related to the matching controller here.
// All this logic will automatically be available in application.js.
import Cart from 'src/cart';
// import Cart from 'src/cart';
/**
* Either adds or removes a section from the cart depending on
* if it is currently in the cart.
*/
const addOrRemoveFromCart = async (event, sectionNode) => {
event && event.stopPropagation();
const section = { ...sectionNode.dataset };
// /**
// * Toggles the display of the schedule
// */
// const toggleSections = course => {
// const sections = course.querySelector('.sections');
// const chev = $(course.querySelector('#course-chevron'));
// const label = course.querySelector('#chevron-label');
await Cart.toggleSection(section);
const icon = $(sectionNode.querySelector('.add-remove-btn #icon'));
const text = sectionNode.querySelector('.add-remove-btn .text');
if (Cart.includesSection(section)) {
icon.addClass('fa-minus').removeClass('fa-plus');
text.innerText = 'Remove';
} else {
icon.addClass('fa-plus').removeClass('fa-minus');
text.innerText = 'Add';
}
};
// if (sections.style.display === 'flex') {
// sections.style.display = 'none';
// chev.addClass('fa-chevron-down').removeClass('fa-chevron-up');
// label.innerText = 'Expand';
// } else {
// sections.style.display = 'flex';
// chev.addClass('fa-chevron-up').removeClass('fa-chevron-down');
// label.innerText = 'Minimize';
// }
// };
/**
* Toggles the display of the schedule
*/
const toggleSections = course => {
const sections = course.querySelector('.sections');
const chev = $(course.querySelector('#course-chevron'));
const label = course.querySelector('#chevron-label');
import React from 'react';
import ReactDOM from 'react-dom';
import CourseList from 'src/CourseList';
if (sections.style.display === 'flex') {
sections.style.display = 'none';
chev.addClass('fa-chevron-down').removeClass('fa-chevron-up');
label.innerText = 'Expand';
} else {
sections.style.display = 'flex';
chev.addClass('fa-chevron-up').removeClass('fa-chevron-down');
label.innerText = 'Minimize';
}
};
const initSearchListeners = () => {
const courseCards = Array.from(document.querySelectorAll('.course-card'));
courseCards.forEach(card => {
card.onclick = () => toggleSections(card);
});
const sectionItems = Array.from(document.querySelectorAll('.section-item'));
sectionItems.forEach(item => (item.onclick = event => addOrRemoveFromCart(event, item)));
};
document.addEventListener('DOMContentLoaded', initSearchListeners);
document.addEventListener('DOMContentLoaded', () => {
ReactDOM.render(<CourseList courses={gon.courses} />, document.getElementById('root'));
});
import React from 'react';
import BigCalendar from 'react-big-calendar';
import moment from 'moment';
import '!style-loader!css-loader!react-big-calendar/lib/css/react-big-calendar.css';
const localizer = BigCalendar.momentLocalizer(moment);
const Calendar = props => (
<div style={{ backgroundColor: 'white', padding: '24px' }}>
<BigCalendar
localizer={localizer}
events={props.events}
title=""
defaultView="week"
views={['week', 'day']}
startAccessor="start"
endAccessor="end"
defaultDate={moment('2019-01-14').toDate()}
formats={{
dayFormat: (date, culture, localizer) => localizer.format(date, 'ddd', culture),
dayRangeHeaderFormat: () => '',
}}
style={{ height: '75vh' }}
/>
</div>
);
export default Calendar;
import React from 'react';
import Calendar from 'src/Calendar';
import Cart from 'src/Cart';
import SectionList from 'src/SectionList';
import QuickAdd from 'src/QuickAdd';
import moment from 'moment';
export default class CalendarPage extends React.Component {
state = { events: [], sections: [] };
constructor(props) {
super(props);
this.loadEvents();
}
loadEvents = async () => {
const response = await fetch(`/schedule/events?crns=${Cart.crns.join(',')}`);
const json = await response.json();
this.setState({ ...json });
Cart.crns = json.sections.map(s => s.crn);
};
events = () => {
return this.state.events.filter(e => e.active).map(e => ({ ...e, start: moment(e.start).toDate(), end: moment(e.end).toDate() }));
};
toggleSection = crn => {
const events = this.state.events.map(e => ({ ...e, active: e.crn == crn ? !e.active : e.active }));
this.setState({ events });
};
removeAll = () => {
Cart.crns = [];
location.reload();
};
render() {
return (
<div>
<Calendar events={this.events()} />
{this.state.sections.length > 0 ? (
<div className="d-flex justify-content-between align-items-end">
<h2 className="mt-4">Your Schedule</h2>{' '}
<button type="button" onClick={this.removeAll} className="btn btn-danger mb-8">
Remove all sections
</button>
</div>
) : null}
<SectionList onClick={this.toggleSection} sections={this.state.sections} expanded={true} />
<QuickAdd loadCalendar={this.loadEvents} />
</div>
);
}
}
import React from 'react';
export default class Chevron extends React.Component {
constructor(props) {
super(props);
}
render() {
const base = { display: 'block', textAlign: 'center' };
return (
<div>
<div style={this.props.open ? { ...base } : { display: 'none' }}>
<p id="chevron-label" style={{ marginBottom: '4px', fontSize: '10px' }}>
Minimize
</p>
<i id="course-chevron" className="fas fa-chevron-up" />
</div>
<div style={this.props.open ? { display: 'none' } : { ...base }}>
<p id="chevron-label" style={{ marginBottom: '4px', fontSize: '10px' }}>
Expand
</p>
<i id="course-chevron" className="fas fa-chevron-down" />
</div>
</div>
);
}
}
import React from 'react';
import SectionList from 'src/SectionList';
export default class Course extends React.Component {
constructor(props) {
super(props);
this.state = { expanded: false, sections: [] };
}
async onClick() {
if (this.state.sections.length === 0) {
const resp = await fetch(`/api/course_sections?course_id=${this.props.id}`);
const json = await resp.json();
this.setState({ sections: json });
}
this.setState({ expanded: !this.state.expanded });
}
prereqs = () => {
if (this.props.prereqs) {
const [first, rest] = this.props.prereqs.split(':');
const [reqs, note] = rest.split('.');
return (
<p>
<strong>{first}</strong>
{reqs}
<sub>{note}</sub>
</p>
);
}
return <div />;
};
// <% first, rest = course.prereqs.split(':') %>
// <% prereqs, note = rest.split('.') %>
// <p><strong><%= first %>:</strong> <%= prereqs %> <sub><%= note %></sub></p>
render() {
const { id, subject, course_number, title, credits, description, url } = this.props;
return (
<div className="card course-card" onClick={() => this.onClick()}>
<div className="card-header">
<div className="row">
<div className="col">
<a href={url}>
<h4 className="title">{`${subject} ${course_number}`}</h4>
</a>
</div>
</div>
<div className="d-md-flex justify-content-between">
<h5>
<em>{title}</em>
</h5>
<div className="attr-list justify-content-start">
<div className="attr">
<div className="icon">
<i className="fa fa-book" />
</div>
{credits} credits
</div>
</div>
</div>
</div>
<div className="card-body">
<p>{description}</p>
{this.prereqs()}
<div className="list-group list-group-flush sections" style={{ display: 'none' }} />
<SectionList {...this.state} expandable={true} />
</div>
</div>
);
}
}
import React from 'react';
import Course from 'src/Course';
export default class CourseList extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
{this.props.courses.map(course => (
<Course key={course.id} {...course} />
))}
</div>
);
}
}
import React from 'react';
import Cart from 'src/Cart';
export default class QuickAdd extends React.Component {
constructor(props) {
super(props);
this.state = { crns: '' };
}
add = e => {
e.preventDefault();
const crns = this.state.crns.split(',');
crns.forEach(c => c.length === 5 && Cart.addCrn(c));
this.props.loadCalendar();
};
render() {
return (
<div>
<h3 className="quick-add-header">Quick add</h3>
<p>Want to quickly generate a calendar populated with your semester's classes? Enter the CRNs in a comma separated list below.</p>
<form onSubmit={this.add} className="form">
<div className="input-group">
<input
id="crns"
name="crns"
type="text"
value={this.state.crns}
onChange={e => this.setState({ crns: e.target.value })}
className="form-control"
placeholder="12345,54321,..."
aria-describedby="basic-addon2"
autoComplete="off"
/>
<div className="input-group-append">
<button type="submit" className="btn btn-primary" type="button">
Populate Calendar
</button>
</div>
</div>
</form>
</div>
);
}
}
import React from 'react';
import Cart from 'src/Cart';
import Stars from 'src/Stars';
export default class Section extends React.Component {
constructor(props) {
super(props);
this.state = { inCart: Cart.includesCrn(this.props.crn) };
}
onClick = e => {
e.stopPropagation();
console.log(e.target.tagName);
if (e.target.tagName === 'A') return;
Cart.toggleCrn(this.props.crn);
this.setState({ inCart: Cart.includesCrn(this.props.crn) });
this.props.onClick && this.props.onClick(this.props.crn);
};
render() {
const { name, title, crn, instructor_name, instructor_url, teaching_rating, course_rating, location, days, start_time, end_time } = this.props;
const { inCart } = this.state;
const percent = teaching_rating ? <Stars percent={(teaching_rating[0] / 5) * 100} /> : null;
const remove = (
<span className="float-right text-center add-remove-btn" style={inCart ? {} : { display: 'none' }}>
<i id="icon" className="fas fa-minus" />
<br />
<span className="text">Remove</span>
</span>
);
const add = (
<span className="float-right text-center add-remove-btn" style={inCart ? { display: 'none' } : {}}>
<i id="icon" className="fas fa-plus" />
<br />
<span className="text">Add</span>
</span>
);
return (
<li className="list-group-item section-item" onClick={this.onClick}>
<p>
<b>{name}</b>: {title}{' '}
<em>
(#
{crn})
</em>
</p>
{remove}
{add}
<i className="fas fa-chalkboard-teacher" /> <a href={instructor_url}>{instructor_name}</a> {percent}
<br />
<i className="fas fa-map-marker-alt" /> {location} <br />
<i className="fas fa-clock" /> {days}, {start_time} - {end_time} <br />
</li>
);
}
}
import React from 'react';
import Chevron from 'src/Chevron';
import Section from 'src/Section';
export default class SectionList extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
{this.props.expandable ? <Chevron open={this.props.expanded} /> : null}
{this.props.expanded ? (
<div className="d-flex list-group list-group-flush sections">
{this.props.sections.map(section => (
<Section key={section.id} onClick={this.props.onClick} {...section} />
))}
</div>
) : (
<div />
)}
</div>
);
}
}
import React from 'react';
export default class Stars extends React.Component {
render() {
return (
<div className="star-rating">
<div className="back-stars">
<i className="fas fa-star" aria-hidden="true" />
<i className="fas fa-star" aria-hidden="true" />
<i className="fas fa-star" aria-hidden="true" />
<i className="fas fa-star" aria-hidden="true" />
<i className="fas fa-star" aria-hidden="true" />
<div className="front-stars" style={{ width: `${this.props.percent}%` }}>
<i className="fa fa-star" aria-hidden="true" />
<i className="fa fa-star" aria-hidden="true" />
<i className="fa fa-star" aria-hidden="true" />
<i className="fa fa-star" aria-hidden="true" />
<i className="fa fa-star" aria-hidden="true" />
</div>
</div>
</div>
);
}
}
//import '@babel/polyfill';
class Cart {
constructor() {
this.isOpen = false;
this._courses = [];
const cartData = document.getElementById('cart-data');
if (cartData) {
this._courses = JSON.parse(cartData.dataset.cart);
}
document.addEventListener('DOMContentLoaded', () => (document.getElementById('cart-counter').innerText = this.crns.length));
}
_parseData() {
const cartData = document.getElementById('cart-data');
if (cartData) {
this._courses = JSON.parse(cartData.dataset.cart);
}
get crns() {
const crnString = localStorage.getItem('crns');
if (!crnString) return [];
return JSON.parse(crnString);
}
toggle() {
const list = document.getElementById('cart');
const icon = document.getElementById('schedule-icon');
if (this.isOpen) {
list.style.display = 'none';
icon.style.color = 'black';
} else {
list.style.display = 'block';
icon.style.color = 'green';
}
this.isOpen = !this.isOpen;
set crns(crnList) {
localStorage.setItem('crns', JSON.stringify(crnList));
document.getElementById('cart-counter').innerText = crnList.length;
}
set courses(courses) {
this._courses = courses;
for (const courseId in this._courses) {
if (this._courses[courseId].length === 0) delete this._courses[courseId];
addCrn(crn) {
if (!this.includesCrn(crn)) {
this.crns = [...this.crns, crn];
}
document.getElementById('cart-counter').innerText = Object.keys(this._courses).length;
}
async toggleSection(section) {
const resp = await fetch(`/sessions/cart?&crn=${section.crn}`, {
cache: 'no-store',
credentials: 'same-origin',
});
const json = await resp.json();
this.courses = json;
}
includesSection(obj) {
for (const key in this._courses) {
const list = this._courses[key];
if (list.includes(obj.crn)) return true;
toggleCrn(crn) {
if (!this.includesCrn(crn)) {
this.crns = [...this.crns, crn];
} else {
this.crns = this.crns.filter(c => c != crn);
}
}
return false;
includesCrn(crn) {
return this.crns.filter(c => c == crn).length > 0;
}
}
const cart = new Cart();
document.addEventListener('DOMContentLoaded', () => cart._parseData());
export default cart;
export default new Cart();
......@@ -9,44 +9,4 @@ class Course < ApplicationRecord
def full_name
"#{subject} #{course_number}"
end
def self.from_subject(base_query, subject)
base_query.where("courses.subject = ?", subject.upcase)
end
def self.from_course_number(base_query, course_number)
base_query.where("courses.course_number = ?", course_number)
end
def self.from_title(base_query, title)
puts title
# Temporary really disgusting regex that I hate with all my heart
title = (title + " ").upcase.gsub(/(I+) +/, '\1$').gsub(/ +/, "% ").tr('$', ' ')
base_query.where("UPPER(courses.title) LIKE UPPER(?) or UPPER(courses.title) LIKE UPPER(?)", "%#{title.strip}", "%#{title}%")
end
# Given a list of filters, collect a list of matching elements. This makes it
# so you can just pass the arguments straight thru
def self.fetch(filters)
# join with course_sections so that we can get a section count for each course and then sort by that
query = Course.left_outer_joins(:course_sections)
.select("courses.*, COUNT(course_sections.id) AS section_count")
.group("courses.id")
.order("section_count DESC")
filters.each do |filter, value|
case filter
when "subject"
query = from_subject(query, value)
when "course_number"
query = from_course_number(query, value)
when "title"
query = from_title(query, value)
when "instructor"
query = Instructor.from_name(query.joins("INNER JOIN instructors ON course_sections.instructor_id = instructors.id"), value)
end
end
query
end
end
......@@ -16,6 +16,8 @@ class CourseSection < ApplicationRecord
serialize :rating_questions, Array
scope :in_semester, ->(semester) { where(semester: semester) }
def teaching_rating
if rating_questions.empty?
nil
......
class Instructor < ApplicationRecord
has_many :course_sections
scope :named, ->(name) {
name.split(' ').reduce(all) do |query, comp|
query.where("upper(instructors.name) LIKE ?", "%#{comp.upcase}%")
end
}
def self.from_name(base_query, name)
base_query.where("upper(instructors.name) LIKE ?", "%#{name.upcase}%")
end
......
......@@ -29,5 +29,5 @@
</div>
<%= javascript_pack_tag 'search' %>
<%= javascript_pack_tag 'instructor' %>
<%= stylesheet_link_tag 'search' %>
<div class="jumbotron text-center">
<h1>SRCT Schedules</h1>
<h1><i class="fas fa-calendar-alt"></i>&nbsp;SRCT Schedules</h1>
<p class="lead">Build, share, and export your schedule. Search for classes and professors.</p>
<hr>
<p>
......@@ -36,24 +36,6 @@
</div>
</div>
<div id="quick-add" />
<h3 class="quick-add-header">Quick add</h3>
<p>Want to quickly generate a calendar populated with your semester's classes? Enter the CRNs in a comma separated list below.</p>
<form action="/sessions/add_bulk" class="form">
<div class="input-group">
<input
id="crns"
name="crns"
type="text"
class="form-control"
placeholder="12345,54321,..."
aria-describedby="basic-addon2"
autocomplete="off"
>
<div class="input-group-append">
<button type="submit" class="btn btn-primary" type="button">
Populate Calendar
</button>
</div>
</div>
</form>
<%= javascript_pack_tag 'home' %>
......@@ -17,5 +17,5 @@
</div>
</div>
<%= javascript_pack_tag 'search' %>
<%= javascript_pack_tag 'instructor' %>
<%= stylesheet_link_tag 'search' %>
......@@ -5,6 +5,8 @@
<%= csrf_meta_tags %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= Gon::Base.render_data %>
<%= javascript_include_tag 'masonstrap.min' %>
<%= stylesheet_link_tag 'masonstrap.min' %>
......@@ -16,7 +18,7 @@
<meta property="og:url" content="https://schedules.gmu.edu/">
<meta property="og:type" content="website">
<meta property="og:title" content="SRCT Schedules">
<meta property="og:description" content="Easily generate a calendar with your class schedule.">
<meta property="og:description" content="Browse the GMU catalog, see course reviews, build and share your schedule.">
<meta property="og:site_name" content="SRCT Schedules">
<meta property="og:locale" content="en_US">
<meta property="article:author" content="SRCT">
......@@ -27,7 +29,7 @@
<meta name="twitter:creator" content="@MasonSRCT">
<meta name="twitter:url" content="https://schedules.gmu.edu/">
<meta name="twitter:title" content="SRCT Schedules">
<meta name="twitter:description" content="Easily generate a calendar with your class schedule.">
<meta name="twitter:description" content="Browse the GMU catalog, see course reviews, build and share your schedule.">
<!-- favicons -->
<%= favicon_link_tag %>
......
<%= javascript_pack_tag 'schedules' %>
<%= stylesheet_link_tag 'schedules' %>
<%= javascript_include_tag 'moment.min' %>
<%= stylesheet_link_tag 'fullcalendar.min' %>
<button id="open-modal-btn" type="button" class="btn btn-primary" data-toggle="modal" data-target="#exportModal">
Export Schedule
</button>
<button id="save-image" class="btn btn-secondary">Save Image</button>
<div id="calendar"></div>
<div id="root"></div>
<h3>Quick add</h3>
<p>Populate your calendar quickly by entering a comma separated list of CRNs.</p>
<form action="/sessions/add_bulk" class="form">
<div class="input-group">
<input
id="crns"
name="crns"
type="text"
class="form-control"
placeholder="12345,54321,..."
aria-describedby="basic-addon2"
autocomplete="off"
>
<div class="input-group-append">
<button type="submit" class="btn btn-primary" type="button">
Populate Calendar
</button>
</div>
</div>
</form>
<h3 id="share-header">Share</h3>
Want to share your schedule with your friends? Send them this link:<br/>
<a id="share-url"></a>
<template id="events" data-events="<%= @events.to_json %>"></template>
<hr />
<h2>Selected Courses</h2>
<%= render partial: 'shared/section', collection: @all %>
<!-- Export Modal -->
......
<% unless @instructors.nil? %>
<h2>Instructors</h2>
<div class="row">
<% @instructors.each do |instructor| %>
<div class="col"><%= render partial: 'shared/instructor', object: instructor %></div>
<% end %>
</div>
<hr />
<% end %>
<% unless @courses.nil? %>
<h2>Courses</h2>
<% @courses.each do |course| %>
<div class="col"><%= render partial: 'shared/course', object: course %></div>
<% end %>
<% end %>
<% if @courses.nil? && @instructors.nil? %>
<h1>Sorry, we couldn't find anything matching your search.</h1>
<p>Please try again!</p>
<% end %>
<div id="root"></div>
<%= javascript_pack_tag 'search' %>
<%= stylesheet_link_tag 'search' %>
......@@ -15,24 +15,21 @@
<% end %>
</div>
</div>
<h5><em><%= course.title %></em></h5>
</div>
<div class="card-body">
<div class="attr-list justify-content-start">
<div class="attr">
<div class="icon">
<i class="fa fa-book"></i>
</div>
<%= course.credits %> credits
</div>
&nbsp;&nbsp;&nbsp;
<div class="attr">
<div class="icon">
<i class="fa fa-bars"></i>
<div class="d-md-flex justify-content-between">
<h5><em><%= course.title %></em></h5>
<div class="attr-list justify-content-start">
<div class="attr">
<div class="icon">
<i class="fa fa-book"></i>
</div>
<%= course.credits %> credits
</div>
<%= sections.count %> sections
&nbsp;&nbsp;&nbsp;
</div>
</div>
</div>
<div class="card-body">
<p class="description"><%= course.description %></p>
<% unless course.prereqs.nil? || course.prereqs.empty? %>
......@@ -59,7 +56,7 @@
<!-- List of Course Sections -->
<div class="list-group list-group-flush sections" style="display: <%= expanded ? "flex" : "none" %>">
<%= render partial: 'shared/section', collection: sections, locals: { course: course } %>
</div>
</div>
</div>
<!-- <div id="navbar" class="container-fluid">
<div class="row align-left align-sm-center align-md-right" id="navbar">
<div class="col align-center">
<div class="center-vert">
<a href="/" id="logo">
<i class="fas fa-calendar-alt"></i>
Schedules
</a>
</div>
</div>
<div class="col-sm align-center order-0 order-sm-1">
<a id="cart-button" href="<%= schedule_path %>">
<h1>
<span class="fa-layers fa-fw" id="schedule-icon">
<i class="fas fa-shopping-cart"></i>
<span class="fa-layers fa-fw">
<i class="fas fa-circle" data-fa-transform="shrink-3 up-12 right-12" style="color:gray"></i>
<span id="course-counter" class="fa-layers-text fa-inverse" data-fa-transform="shrink-10 up-12 right-12" style="font-weight:600">
<%= @cart.length %>
</span>
</span>
</span>
</h1>
</a>
</div>
<div class="col order-1 order-sm-0">
<form action="/search" class="form">
<div class="input-group" style="width:100%;">
<input
id="searchbar"
value="<%= params[:query] %>"
name="query"
type="text"
class="form-control"
placeholder="Search course or professor..."
aria-describedby="basic-addon2"
autocomplete="off"
>
<div class="input-group-append">
<button type="submit" class="btn btn-secondary" type="button">
<i class="fas fa-search"></i>
</button>
</div>
</div>
</form>
</div>
</div>
</div> -->
<nav class="navbar navbar-expand-lg navbar-dark" style="background-color: #006633">
<div class="container">
<a class="navbar-brand mb-0 h1" href="/">
......@@ -64,11 +11,25 @@
<div class="collapse navbar-collapse" id="navbarTogglerDemo02">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-item">
<a href="/schedule" class="nav-link">Your Schedule (<span id="cart-counter"><%= @cart.length %></span>)</a>
<a href="/schedule" class="nav-link">Your Schedule (<span id="cart-counter"></span>)</a>
</li>
<li class="nav-item">
<a href="/about" class="nav-link">About Schedules</a>
</li>
</ul>
<form action="/search" class="form-inline">
<select id="semester-select" class="custom-select">
<% Semester.all.each do |semester| %>
<option
value="<%= semester.id %>"
<% if @semester == semester %> selected <% end %>
>
<%= "#{semester.season} #{semester.year}" %>
</option>
<% end %>
</select>
<input
id="searchbar"
value="<%= params[:query] %>"
......@@ -79,6 +40,7 @@
aria-describedby="basic-addon2"
autocomplete="off"
>
<button class="btn btn-secondary my-2 my-sm-0" type="submit">Search</button>
</form>
......
......@@ -10,8 +10,8 @@
</div>
<footer class="footer">
Schedules was built by <a href="https://srct.gmu.edu">Mason SRCT</a> and is completely open source.<br/>
Want to contribute? View the code on the <a href="https://git.gmu.edu/srct/schedules">SRCT GitLab</a>.
Schedules was built by <a href="https://srct.gmu.edu">Mason SRCT</a> and is completely open source. <br/>
Want to contribute? View the code on the <a href="https://git.gmu.edu/srct/schedules">SRCT GitLab</a>.<br/>
</footer>
</div>
......
......@@ -9,14 +9,10 @@
data-cid="<%= course.id if defined?(course) %>"
data-type="<%= section.section_type %>"
>
<p><b class="subj"><%= "#{section.name}" %></b>: <%= section.title %></p>
<p><b class="subj"><%= "#{section.name}" %></b>: <%= section.title %> <em><%= "(##{section.crn})"%></em></p>
<% if editable %>
<% if in_cart? section.crn %>
<span class="float-right text-center add-remove-btn"><i id="icon" class="fas fa-minus"></i><br/><span class="text">Remove</span></span>
<% else %>
<span class="float-right text-center add-remove-btn"><i id="icon" class="fas fa-plus"></i><br/><span class="text">Add</span></span>
<% end %>
<span class="float-right text-center add-remove-btn"><i id="icon" class="fas fa-ellipsis-h"></i><br/><span class="text">Add</span></span>
<% end %>
<i class="fas fa-chalkboard-teacher"></i>
......@@ -25,23 +21,7 @@
<% else %>
<%= link_to section.instructor.name, section.instructor %>
<% unless section.instructor.rating.nil? %>
<div class="star-rating">
<div class="back-stars">
<i class="far fa-star" aria-hidden="true"></i>
<i class="far fa-star" aria-hidden="true"></i>
<i class="far fa-star" aria-hidden="true"></i>
<i class="far fa-star" aria-hidden="true"></i>
<i class="far fa-star" aria-hidden="true"></i>
<div class="front-stars" style="width: <%= (section.instructor.rating[0] / 5 * 100).to_i %>%">
<i class="fa fa-star" aria-hidden="true"></i>
<i class="fa fa-star" aria-hidden="true"></i>
<i class="fa fa-star" aria-hidden="true"></i>
<i class="fa fa-star" aria-hidden="true"></i>
<i class="fa fa-star" aria-hidden="true"></i>
</div>
</div>
</div>
<%= render partial: 'shared/stars', locals: { percent: (section.instructor.rating[0] / 5 * 100).to_i }%>
<% end %>
<% end %>
<br/>
......
<div class="star-rating">
<div class="back-stars">
<i class="fas fa-star" aria-hidden="true"></i>
<i class="fas fa-star" aria-hidden="true"></i>
<i class="fas fa-star" aria-hidden="true"></i>
<i class="fas fa-star" aria-hidden="true"></i>
<i class="fas fa-star" aria-hidden="true"></i>
<div class="front-stars" style="width: <%= percent %>%">
<i class="fa fa-star" aria-hidden="true"></i>
<i class="fa fa-star" aria-hidden="true"></i>
<i class="fa fa-star" aria-hidden="true"></i>
<i class="fa fa-star" aria-hidden="true"></i>
<i class="fa fa-star" aria-hidden="true"></i>
</div>
</div>
</div>
# Registers all routes for the app.
Rails.application.routes.draw do
get 'about', to: 'about#index', as: 'about'
get '/', to: 'home#index', as: 'home'
get 'search', to: 'search#index', as: 'search'
get 'sessions/update', as: 'update_session'
......@@ -10,6 +12,7 @@ Rails.application.routes.draw do
resources :course_sections, only: [:show]
resources :instructors, only: [:index, :show]
get 'schedule', to: 'schedules#show', as: 'schedule'
get 'schedule/events', to: 'schedules#events'
get 'schedule/view', to: 'schedules#view', as: 'view_schedule'
scope :api, module: 'api' do # Register /api routes
......
......@@ -14,6 +14,7 @@ default: &default
cache_manifest: false
extensions:
- .jsx
- .js
- .sass
- .scss
......