Merge pull request #138 from bornhack/schedule-elm-rewrite

Schedule elm rewrite
This commit is contained in:
Víðir Valberg Guðmundsson 2017-08-16 20:28:22 +02:00 committed by GitHub
commit bfd115ac5a
27 changed files with 18887 additions and 729 deletions

2
.gitignore vendored
View file

@ -6,4 +6,4 @@ db.sqlite3
*.pyc *.pyc
venv/ venv/
environment_settings.py environment_settings.py
elm-stuff/

5
schedule/Makefile Normal file
View file

@ -0,0 +1,5 @@
all:
elm-make src/Main.elm --warn --output ../src/program/static/js/elm_based_schedule.js
debug:
elm-make src/Main.elm --debug --warn --output ../src/program/static/js/elm_based_schedule.js

22
schedule/elm-package.json Normal file
View file

@ -0,0 +1,22 @@
{
"version": "1.0.0",
"summary": "helpful summary of your project, less than 80 characters",
"repository": "https://github.com/user/project.git",
"license": "BSD3",
"source-directories": [
"src/"
],
"exposed-modules": [],
"dependencies": {
"NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0",
"elm-community/list-extra": "6.1.0 <= v < 7.0.0",
"elm-lang/core": "5.1.1 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/navigation": "2.1.0 <= v < 3.0.0",
"elm-lang/websocket": "1.0.2 <= v < 2.0.0",
"evancz/elm-markdown": "3.0.2 <= v < 4.0.0",
"evancz/url-parser": "2.0.1 <= v < 3.0.0",
"justinmimbs/elm-date-extra": "2.0.3 <= v < 3.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}

122
schedule/src/Decoders.elm Normal file
View file

@ -0,0 +1,122 @@
module Decoders exposing (..)
-- Local modules
import Models exposing (Day, Speaker, Event, EventInstance, Model, Flags, Filter, Route(..), FilterType(..))
-- Core modules
import Json.Decode exposing (int, string, float, list, bool, dict, Decoder, nullable)
import Json.Decode.Pipeline exposing (decode, required, optional, hardcoded)
import Date exposing (Date, Month(..))
-- External modules
import Date.Extra
import Navigation exposing (Location)
-- DECODERS
type alias WebSocketAction =
{ action : String
}
webSocketActionDecoder : Decoder WebSocketAction
webSocketActionDecoder =
decode WebSocketAction
|> required "action" string
dayDecoder : Decoder Day
dayDecoder =
decode Day
|> required "day_name" string
|> required "iso" dateDecoder
|> required "repr" string
speakerDecoder : Decoder Speaker
speakerDecoder =
decode Speaker
|> required "name" string
|> required "slug" string
|> required "biography" string
|> optional "large_picture_url" (nullable string) Nothing
|> optional "small_picture_url" (nullable string) Nothing
eventDecoder : Decoder Event
eventDecoder =
decode Event
|> required "title" string
|> required "slug" string
|> required "abstract" string
|> required "speaker_slugs" (list string)
|> required "video_state" string
|> optional "video_url" (nullable string) Nothing
|> required "event_type" string
dateDecoder : Decoder Date
dateDecoder =
let
unpacked isoString =
isoString
|> Date.Extra.fromIsoString
|> Maybe.withDefault (Date.Extra.fromParts 1970 Jan 1 0 0 0 0)
in
Json.Decode.map unpacked string
eventInstanceDecoder : Decoder EventInstance
eventInstanceDecoder =
decode EventInstance
|> required "title" string
|> required "slug" string
|> required "id" int
|> required "url" string
|> required "event_slug" string
|> required "event_type" string
|> required "bg-color" string
|> required "fg-color" string
|> required "from" dateDecoder
|> required "to" dateDecoder
|> required "timeslots" float
|> required "location" string
|> required "location_icon" string
|> required "video_state" string
|> optional "video_url" (nullable string) Nothing
|> optional "is_favorited" (nullable bool) Nothing
eventLocationDecoder : Decoder FilterType
eventLocationDecoder =
decode LocationFilter
|> required "name" string
|> required "slug" string
|> required "icon" string
eventTypeDecoder : Decoder FilterType
eventTypeDecoder =
decode TypeFilter
|> required "name" string
|> required "slug" string
|> required "color" string
|> required "light_text" bool
initDataDecoder : Decoder (Flags -> Filter -> Location -> Route -> Bool -> Model)
initDataDecoder =
decode Model
|> required "days" (list dayDecoder)
|> required "events" (list eventDecoder)
|> required "event_instances" (list eventInstanceDecoder)
|> required "event_locations" (list eventLocationDecoder)
|> required "event_types" (list eventTypeDecoder)
|> required "speakers" (list speakerDecoder)

51
schedule/src/Main.elm Normal file
View file

@ -0,0 +1,51 @@
module Main exposing (..)
-- Local modules
import Models exposing (..)
import Routing exposing (parseLocation)
import Update exposing (update)
import Messages exposing (Msg(..))
import WebSocketCalls exposing (sendInitMessage)
import Views exposing (view)
-- External modules
import WebSocket exposing (listen)
import Navigation exposing (Location)
main : Program Flags Model Msg
main =
Navigation.programWithFlags
OnLocationChange
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
init : Flags -> Location -> ( Model, Cmd Msg )
init flags location =
let
currentRoute =
parseLocation location
emptyFilter =
Filter [] [] []
model =
Model [] [] [] [] [] [] flags emptyFilter location currentRoute False
in
model ! [ sendInitMessage flags.camp_slug flags.websocket_server ]
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
WebSocket.listen model.flags.websocket_server WebSocketPayload

18
schedule/src/Messages.elm Normal file
View file

@ -0,0 +1,18 @@
module Messages exposing (Msg(..))
-- Local modules
import Models exposing (Day, EventInstance, FilterType)
-- External modules
import Navigation exposing (Location)
type Msg
= NoOp
| WebSocketPayload String
| ToggleFilter FilterType
| OnLocationChange Location
| BackInHistory

179
schedule/src/Models.elm Normal file
View file

@ -0,0 +1,179 @@
module Models exposing (..)
-- Core modules
import Date exposing (Date, now)
-- External modules
import Navigation exposing (Location)
type alias EventSlug =
String
type alias EventInstanceSlug =
String
type alias SpeakerSlug =
String
type alias DaySlug =
String
type alias FilterQuery =
String
-- Route is defined here rather than in Routing.elm due to it being used in Model. If it were in Routing.elm we would have a circular dependency.
type Route
= OverviewRoute
| OverviewFilteredRoute FilterQuery
| DayRoute DaySlug
| EventRoute EventSlug
| SpeakerRoute SpeakerSlug
| NotFoundRoute
type alias Model =
{ days : List Day
, events : List Event
, eventInstances : List EventInstance
, eventLocations : List FilterType
, eventTypes : List FilterType
, speakers : List Speaker
, flags : Flags
, filter : Filter
, location : Location
, route : Route
, dataLoaded : Bool
}
type alias Day =
{ day_name : String
, date : Date
, repr : String
}
type alias Speaker =
{ name : String
, slug : SpeakerSlug
, biography : String
, largePictureUrl : Maybe String
, smallPictureUrl : Maybe String
}
type alias EventInstance =
{ title : String
, slug : EventInstanceSlug
, id : Int
, url : String
, eventSlug : EventSlug
, eventType : String
, backgroundColor : String
, forgroundColor : String
, from : Date
, to : Date
, timeslots : Float
, location : String
, locationIcon : String
, videoState : String
, videoUrl : Maybe String
, isFavorited : Maybe Bool
}
type alias Event =
{ title : String
, slug : EventSlug
, abstract : String
, speakerSlugs : List SpeakerSlug
, videoState : String
, videoUrl : Maybe String
, eventType : String
}
type alias Flags =
{ schedule_timeslot_length_minutes : Int
, schedule_midnight_offset_hours : Int
, ics_button_href : String
, camp_slug : String
, websocket_server : String
}
-- FILTERS
type alias FilterName =
String
type alias FilterSlug =
String
type alias LocationIcon =
String
type alias TypeColor =
String
type alias TypeLightText =
Bool
type FilterType
= TypeFilter FilterName FilterSlug TypeColor TypeLightText
| LocationFilter FilterName FilterSlug LocationIcon
| VideoFilter FilterName FilterSlug
type alias Filter =
{ eventTypes : List FilterType
, eventLocations : List FilterType
, videoRecording : List FilterType
}
unpackFilterType filter =
case filter of
TypeFilter name slug _ _ ->
( name, slug )
LocationFilter name slug _ ->
( name, slug )
VideoFilter name slug ->
( name, slug )
getSlugFromFilterType filter =
let
( _, slug ) =
unpackFilterType filter
in
slug
getNameFromFilterType filter =
let
( name, slug ) =
unpackFilterType filter
in
name

72
schedule/src/Routing.elm Normal file
View file

@ -0,0 +1,72 @@
module Routing exposing (..)
-- Local modules
import Models exposing (Route(..))
-- External modules
import Navigation exposing (Location)
import UrlParser exposing (Parser, (</>), oneOf, map, top, s, string, parseHash)
{--
URLs to support:
- #/
This show the overview of the schedule
- #/?type={types},location={locations},video={not-to-be-recorded,to-be-recorded,has-recording}
This is the overview, just with filters enable
- #/day/{year}-{month}-{day}
Show a particular day
- #/event/{slug}
Show a particular event
--}
matchers : Parser (Route -> a) a
matchers =
oneOf
[ map OverviewRoute top
, map OverviewFilteredRoute (top </> string)
, map DayRoute (s "day" </> string)
, map EventRoute (s "event" </> string)
, map SpeakerRoute (s "speaker" </> string)
]
parseLocation : Location -> Route
parseLocation location =
parseHash matchers location
|> Maybe.withDefault NotFoundRoute
routeToString : Route -> String
routeToString route =
let
parts =
case route of
OverviewRoute ->
[]
OverviewFilteredRoute query ->
[ query ]
DayRoute iso ->
[ "day", iso ]
EventRoute slug ->
[ "event", slug ]
SpeakerRoute slug ->
[ "speaker", slug ]
NotFoundRoute ->
[]
in
"#/" ++ String.join "/" parts

123
schedule/src/Update.elm Normal file
View file

@ -0,0 +1,123 @@
module Update exposing (update)
-- Local modules
import Models exposing (Model, Route(..), Filter, FilterType(..))
import Messages exposing (Msg(..))
import Decoders exposing (webSocketActionDecoder, initDataDecoder, eventDecoder)
import Routing exposing (parseLocation)
import Views.FilterView exposing (parseFilterFromQuery, filterToQuery)
-- Core modules
import Json.Decode
-- External modules
import Navigation
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
NoOp ->
( model, Cmd.none )
WebSocketPayload str ->
let
newModel =
case Json.Decode.decodeString webSocketActionDecoder str of
Ok webSocketAction ->
case webSocketAction.action of
"init" ->
case Json.Decode.decodeString initDataDecoder str of
Ok m ->
m model.flags model.filter model.location model.route True
Err error ->
model
_ ->
model
Err error ->
model
( newModel_, _ ) =
update (OnLocationChange model.location) newModel
in
newModel_ ! []
ToggleFilter filter ->
let
currentFilter =
model.filter
newFilter =
case filter of
TypeFilter name slug color lightText ->
let
eventType =
TypeFilter name slug color lightText
in
{ currentFilter
| eventTypes =
if List.member eventType model.filter.eventTypes then
List.filter (\x -> x /= eventType) model.filter.eventTypes
else
eventType :: model.filter.eventTypes
}
LocationFilter name slug icon ->
let
eventLocation =
LocationFilter name slug icon
in
{ currentFilter
| eventLocations =
if List.member eventLocation model.filter.eventLocations then
List.filter (\x -> x /= eventLocation) model.filter.eventLocations
else
eventLocation :: model.filter.eventLocations
}
VideoFilter name slug ->
let
videoRecording =
VideoFilter name slug
in
{ currentFilter
| videoRecording =
if List.member videoRecording model.filter.videoRecording then
List.filter (\x -> x /= videoRecording) model.filter.videoRecording
else
videoRecording :: model.filter.videoRecording
}
query =
filterToQuery newFilter
cmd =
Navigation.newUrl query
in
{ model | filter = newFilter } ! [ cmd ]
OnLocationChange location ->
let
newRoute =
parseLocation location
newFilter =
case newRoute of
OverviewFilteredRoute query ->
parseFilterFromQuery query model
_ ->
model.filter
in
{ model | filter = newFilter, route = newRoute, location = location } ! []
BackInHistory ->
model ! [ Navigation.back 1 ]

60
schedule/src/Views.elm Normal file
View file

@ -0,0 +1,60 @@
module Views exposing (..)
-- Local modules
import Models exposing (..)
import Messages exposing (Msg(..))
import Views.DayPicker exposing (dayPicker)
import Views.DayView exposing (dayView)
import Views.EventDetail exposing (eventDetailView)
import Views.SpeakerDetail exposing (speakerDetailView)
import Views.ScheduleOverview exposing (scheduleOverviewView)
-- Core modules
import Date exposing (Month(..))
-- External modules
import Html exposing (Html, Attribute, div, input, text, li, ul, a, h4, label, i, span, hr, small, p)
import Date.Extra
view : Model -> Html Msg
view model =
case model.dataLoaded of
True ->
div []
[ dayPicker model
, hr [] []
, case model.route of
OverviewRoute ->
scheduleOverviewView model
OverviewFilteredRoute _ ->
scheduleOverviewView model
DayRoute dayIso ->
let
day =
model.days
|> List.filter (\x -> (Date.Extra.toFormattedString "y-MM-dd" x.date) == dayIso)
|> List.head
|> Maybe.withDefault (Day "" (Date.Extra.fromParts 1970 Jan 1 0 0 0 0) "")
in
dayView day model
EventRoute eventSlug ->
eventDetailView eventSlug model
SpeakerRoute speakerSlug ->
speakerDetailView speakerSlug model
NotFoundRoute ->
div [] [ text "Not found!" ]
]
False ->
h4 [] [ text "Loading schedule..." ]

View file

@ -0,0 +1,86 @@
module Views.DayPicker exposing (..)
-- Local modules
import Models exposing (..)
import Messages exposing (Msg(..))
import Routing exposing (routeToString)
import Views.FilterView exposing (maybeFilteredOverviewRoute)
-- Core modules
import Date exposing (Date)
-- External modules
import Html exposing (Html, text, a, div)
import Html.Attributes exposing (class, classList, href, id)
import Date.Extra
dayPicker : Model -> Html Msg
dayPicker model =
let
activeDate =
case model.route of
DayRoute iso ->
Date.Extra.fromIsoString iso
_ ->
Nothing
isAllDaysActive =
case activeDate of
Just _ ->
False
Nothing ->
True
in
div
[ classList
[ ( "row", True )
, ( "sticky", True )
]
, id "daypicker"
]
[ div [ id "schedule-days", class "btn-group" ]
([ a
[ classList
[ ( "btn", True )
, ( "btn-default", not isAllDaysActive )
, ( "btn-primary", isAllDaysActive )
]
, href <| routeToString <| maybeFilteredOverviewRoute model
]
[ text "All Days"
]
]
++ (List.map (\day -> dayButton day activeDate) model.days)
)
]
dayButton : Day -> Maybe Date -> Html Msg
dayButton day activeDate =
let
isActive =
case activeDate of
Just activeDate ->
day.date == activeDate
Nothing ->
False
in
a
[ classList
[ ( "btn", True )
, ( "btn-default", not isActive )
, ( "btn-primary", isActive )
]
, href <| routeToString <| DayRoute <| Date.Extra.toFormattedString "y-MM-dd" day.date
]
[ text day.day_name
]

View file

@ -0,0 +1,278 @@
module Views.DayView exposing (dayView)
-- Local modules
import Messages exposing (Msg(..))
import Models exposing (Model, Day, EventInstance, Route(EventRoute), FilterType(..), getSlugFromFilterType, getNameFromFilterType)
import Routing exposing (routeToString)
-- Core modules
import Date exposing (Date)
-- External modules
import Html exposing (Html, text, div, ul, li, span, i, h4, table, p, a)
import Html.Attributes exposing (classList, style, href)
import Date.Extra
import List.Extra
blockHeight : Int
blockHeight =
15
headerHeight : Int
headerHeight =
50
px : Int -> String
px value =
(toString value) ++ "px"
dayView : Day -> Model -> Html Msg
dayView day model =
let
start =
Date.Extra.add Date.Extra.Hour model.flags.schedule_midnight_offset_hours day.date
lastHour =
Date.Extra.add Date.Extra.Day 1 start
minutes =
Date.Extra.range Date.Extra.Minute 15 start lastHour
filteredEventInstances =
List.filter (\x -> Date.Extra.equalBy Date.Extra.Day x.from day.date) model.eventInstances
in
div
[ classList [ ( "row", True ) ] ]
[ gutter minutes
, locationColumns filteredEventInstances model.eventLocations model.flags.schedule_midnight_offset_hours minutes
]
locationColumns : List EventInstance -> List FilterType -> Int -> List Date -> Html Msg
locationColumns eventInstances eventLocations offset minutes =
let
columnWidth =
100.0 / toFloat (List.length eventLocations)
in
div
[ style
[ ( "display", "flex" )
, ( "justify-content", "space-around" )
]
, classList
[ ( "col-sm-11", True )
]
]
(List.map (\location -> locationColumn columnWidth eventInstances offset minutes location) eventLocations)
locationColumn : Float -> List EventInstance -> Int -> List Date -> FilterType -> Html Msg
locationColumn columnWidth eventInstances offset minutes location =
let
locationInstances =
List.filter (\instance -> instance.location == getSlugFromFilterType location) eventInstances
overlappingGroups =
List.Extra.groupWhile
(\instanceA instanceB -> Date.Extra.isBetween instanceA.from instanceA.to instanceB.from)
locationInstances
in
div
[ style
[ ( "width", (toString columnWidth) ++ "%" )
]
, classList
[ ( "location-column", True ) ]
]
([ div
[ style
[ ( "height", px headerHeight )
]
, classList
[ ( "location-column-header", True )
]
]
[ text <| getNameFromFilterType location ]
]
++ (List.map
(\x ->
div
[ style
[ ( "backgroundColor"
, if Date.minute x == 30 || Date.minute x == 45 then
"#f8f8f8"
else
"#fff"
)
, ( "height", px blockHeight )
]
]
[]
)
minutes
)
++ (List.map (\group -> renderGroup offset group) overlappingGroups)
)
renderGroup : Int -> List EventInstance -> Html Msg
renderGroup offset group =
let
sortedGroup =
List.sortWith
(\x y ->
case Date.Extra.compare x.from y.from of
z ->
z
)
group
findLefts instanceA =
( instanceA
, List.foldl
(+)
0
(List.map
(\instanceB ->
if instanceA == instanceB then
0
else if (Date.Extra.equal instanceB.from instanceA.from) && (Date.Extra.equal instanceB.to instanceA.to) then
-- Set to 0 and then fix it further down in the code
0
else if (Date.Extra.equal instanceB.from instanceA.from) && not (Date.Extra.equal instanceB.to instanceA.to) then
-- Set to 0 and then fix it further down in the code
0
else if Date.Extra.isBetween instanceB.from instanceB.to instanceA.from then
1
else
0
)
sortedGroup
)
)
lefts =
List.map findLefts sortedGroup
numberInGroup =
lefts
|> List.map (\( _, left ) -> left)
|> List.maximum
|> Maybe.withDefault 1
fixedLefts =
if numberInGroup == 0 then
List.map
(\( instance, x ) ->
( instance
, lefts
|> List.Extra.elemIndex ( instance, x )
|> Maybe.withDefault 0
)
)
lefts
else
lefts
fixedNumberInGroup =
fixedLefts
|> List.map (\( _, left ) -> left)
|> List.maximum
|> Maybe.withDefault 1
in
div
[ style
[ ( "display", "flex" )
]
]
(List.map (\instance -> eventInstanceBlock offset fixedNumberInGroup instance) fixedLefts)
eventInstanceBlock : Int -> Int -> ( EventInstance, Int ) -> Html Msg
eventInstanceBlock offset numberInGroup ( eventInstance, lefts ) =
let
length =
(toFloat (Date.Extra.diff Date.Extra.Minute eventInstance.from eventInstance.to)) / 15
height =
(toString (length * toFloat blockHeight)) ++ "px"
hourInMinutes =
(Date.hour eventInstance.from) * 60
minutes =
Date.minute eventInstance.from
topOffset =
((((toFloat (hourInMinutes + minutes)) / 60)
- (toFloat offset)
)
* 4.0
* (toFloat blockHeight)
)
+ (toFloat headerHeight)
width =
100 / (toFloat (numberInGroup + 1))
in
a
[ classList
[ ( "event", True )
, ( "event-in-dayview", True )
]
, style
[ ( "height", height )
, ( "width", (toString width) ++ "%" )
, ( "top", (toString topOffset) ++ "px" )
, ( "left", (toString (toFloat (lefts) * width)) ++ "%" )
, ( "background-color", eventInstance.backgroundColor )
, ( "color", eventInstance.forgroundColor )
]
, href <| routeToString <| EventRoute eventInstance.eventSlug
]
[ p [] [ text ((Date.Extra.toFormattedString "HH:mm" eventInstance.from) ++ " " ++ eventInstance.title) ]
]
gutter : List Date -> Html Msg
gutter hours =
div
[ classList
[ ( "col-sm-1", True )
, ( "day-view-gutter", True )
]
]
([ div [ style [ ( "height", px headerHeight ) ] ]
[ text ""
]
]
++ (List.map gutterHour hours)
)
gutterHour : Date -> Html Msg
gutterHour date =
let
textToShow =
case Date.minute date of
0 ->
(Date.Extra.toFormattedString "HH:mm" date)
30 ->
(Date.Extra.toFormattedString "HH:mm" date)
_ ->
""
in
div [ style [ ( "height", px blockHeight ) ] ]
[ text textToShow
]

View file

@ -0,0 +1,208 @@
module Views.EventDetail exposing (eventDetailView)
-- Local modules
import Messages exposing (Msg(..))
import Models exposing (..)
import Routing exposing (routeToString)
-- Core modules
import Date
-- External modules
import Html exposing (Html, text, div, ul, li, span, i, h3, h4, a, p, hr, strong)
import Html.Attributes exposing (class, classList, href)
import Html.Events exposing (onClick)
import Markdown
import Date.Extra
eventDetailView : EventSlug -> Model -> Html Msg
eventDetailView eventSlug model =
let
event =
model.events
|> List.filter (\e -> e.slug == eventSlug)
|> List.head
in
case event of
Just event ->
div [ class "row" ]
[ eventDetailContent event
, eventDetailSidebar event model
]
Nothing ->
div [ class "row" ]
[ h4 [] [ text "Event not found." ]
, a [ href "#" ] [ text "Click here to go the schedule overview." ]
]
eventDetailContent : Event -> Html Msg
eventDetailContent event =
div [ class "col-sm-9" ]
[ a [ onClick BackInHistory, classList [ ( "btn", True ), ( "btn-default", True ) ] ]
[ i [ classList [ ( "fa", True ), ( "fa-chevron-left", True ) ] ] []
, text " Back"
]
, h3 [] [ text event.title ]
, div [] [ Markdown.toHtml [] event.abstract ]
]
getSpeakersFromSlugs : List Speaker -> List SpeakerSlug -> List Speaker -> List Speaker
getSpeakersFromSlugs speakers slugs collectedSpeakers =
case speakers of
[] ->
collectedSpeakers
speaker :: rest ->
let
foundSlug =
slugs
|> List.filter (\slug -> slug == speaker.slug)
|> List.head
foundSpeaker =
case foundSlug of
Just slug ->
[ speaker ]
Nothing ->
[]
newSlugs =
case foundSlug of
Just slug ->
List.filter (\x -> x /= slug) slugs
Nothing ->
slugs
newCollectedSpeakers =
collectedSpeakers ++ foundSpeaker
in
case slugs of
[] ->
collectedSpeakers
_ ->
getSpeakersFromSlugs rest newSlugs newCollectedSpeakers
eventDetailSidebar : Event -> Model -> Html Msg
eventDetailSidebar event model =
let
videoRecordingLink =
case event.videoUrl of
Nothing ->
[]
Just url ->
[ a [ href url, classList [ ( "btn", True ), ( "btn-success", True ) ] ]
[ i [ classList [ ( "fa", True ), ( "fa-film", True ) ] ] []
, text " Watch recording here!"
]
]
eventInstances =
List.filter (\instance -> instance.eventSlug == event.slug) model.eventInstances
speakers =
getSpeakersFromSlugs model.speakers event.speakerSlugs []
in
div
[ classList
[ ( "col-sm-3", True )
, ( "schedule-sidebar", True )
, ( "sticky", True )
]
]
(videoRecordingLink
++ [ speakerSidebar speakers
, eventMetaDataSidebar event
, eventInstancesSidebar eventInstances
]
)
eventMetaDataSidebar : Event -> Html Msg
eventMetaDataSidebar event =
let
( showVideoRecoring, videoRecording ) =
case event.videoState of
"to-be-recorded" ->
( True, "Yes" )
"not-to-be-recorded" ->
( True, "No" )
_ ->
( False, "" )
in
div []
[ h4 [] [ text "Metadata" ]
, ul []
([ li [] [ strong [] [ text "Type: " ], text event.eventType ]
]
++ (case showVideoRecoring of
True ->
[ li [] [ strong [] [ text "Recording: " ], text videoRecording ] ]
False ->
[]
)
)
]
speakerSidebar : List Speaker -> Html Msg
speakerSidebar speakers =
div []
[ h4 []
[ text "Speakers" ]
, ul
[]
(List.map speakerDetail speakers)
]
speakerDetail : Speaker -> Html Msg
speakerDetail speaker =
li []
[ a [ href <| routeToString <| SpeakerRoute speaker.slug ] [ text speaker.name ]
]
eventInstancesSidebar : List EventInstance -> Html Msg
eventInstancesSidebar eventInstances =
div []
[ h4 []
[ text "This event will occur at:" ]
, ul
[]
(List.map eventInstanceItem eventInstances)
]
eventInstanceItem : EventInstance -> Html Msg
eventInstanceItem eventInstance =
let
toFormat =
if Date.day eventInstance.from == Date.day eventInstance.to then
"HH:mm"
else
"E HH:mm"
in
li []
[ text
((Date.Extra.toFormattedString "E HH:mm" eventInstance.from)
++ " to "
++ (Date.Extra.toFormattedString toFormat eventInstance.to)
)
]

View file

@ -0,0 +1,367 @@
module Views.FilterView exposing (filterSidebar, applyFilters, parseFilterFromQuery, filterToQuery, maybeFilteredOverviewRoute)
-- Local modules
import Messages exposing (Msg(..))
import Models exposing (Model, EventInstance, Filter, Day, FilterQuery, Route(OverviewRoute, OverviewFilteredRoute), FilterType(..), unpackFilterType, getSlugFromFilterType)
import Routing exposing (routeToString)
-- Core modules
import Regex
-- External modules
import Html exposing (Html, text, div, ul, li, span, i, h4, small, a)
import Html.Attributes exposing (class, classList, style, href)
import Html.Events exposing (onClick)
import Date.Extra exposing (Interval(..), equalBy)
applyFilters : Day -> Model -> List EventInstance
applyFilters day model =
let
slugs default filters =
List.map getSlugFromFilterType
(if List.isEmpty filters then
default
else
filters
)
types =
slugs model.eventTypes model.filter.eventTypes
locations =
slugs model.eventLocations model.filter.eventLocations
videoFilters =
slugs videoRecordingFilters model.filter.videoRecording
filteredEventInstances =
List.filter
(\eventInstance ->
(Date.Extra.equalBy Month eventInstance.from day.date)
&& (Date.Extra.equalBy Date.Extra.Day eventInstance.from day.date)
&& List.member eventInstance.location locations
&& List.member eventInstance.eventType types
&& List.member eventInstance.videoState videoFilters
)
model.eventInstances
in
filteredEventInstances
filterSidebar : Model -> Html Msg
filterSidebar model =
div
[ classList
[ ( "col-sm-3", True )
, ( "col-sm-push-9", True )
, ( "schedule-filter", True )
]
]
[ h4 [] [ text "Filter" ]
, div [ class "form-group" ]
[ filterView
"Type"
model.eventTypes
model.filter.eventTypes
model.eventInstances
.eventType
, filterView
"Location"
model.eventLocations
model.filter.eventLocations
model.eventInstances
.location
, filterView
"Video"
videoRecordingFilters
model.filter.videoRecording
model.eventInstances
.videoState
]
, icsButton model
]
icsButton : Model -> Html Msg
icsButton model =
let
filterString =
case filterToString model.filter of
"" ->
""
filter ->
"?" ++ filter
icsURL =
model.flags.ics_button_href ++ filterString
in
a
[ classList
[ ( "btn", True )
, ( "btn-default", True )
]
, href <| icsURL
]
[ i [ classList [ ( "fa", True ), ( "fa-calendar", True ) ] ]
[]
, text " ICS file with these filters"
]
videoRecordingFilters : List FilterType
videoRecordingFilters =
[ VideoFilter "Will not be recorded" "not-to-be-recorded"
, VideoFilter "Will be recorded" "to-be-recorded"
, VideoFilter "Has recording" "has-recording"
]
filterView :
String
-> List FilterType
-> List FilterType
-> List EventInstance
-> (EventInstance -> String)
-> Html Msg
filterView name possibleFilters currentFilters eventInstances slugLike =
div []
[ text (name ++ ":")
, ul []
(possibleFilters
|> List.map
(\filter ->
filterChoiceView
filter
currentFilters
eventInstances
slugLike
)
)
]
filterChoiceView :
FilterType
-> List FilterType
-> List EventInstance
-> (EventInstance -> String)
-> Html Msg
filterChoiceView filter currentFilters eventInstances slugLike =
let
active =
List.member filter currentFilters
notActive =
not active
( name, slug ) =
unpackFilterType filter
eventInstanceCount =
eventInstances
|> List.filter (\eventInstance -> slugLike eventInstance == slug)
|> List.length
buttonStyle =
case filter of
TypeFilter _ _ color lightText ->
[ style
[ ( "backgroundColor", color )
, ( "color"
, if lightText then
"#fff"
else
"#000"
)
, ( "border", "1px solid black" )
, ( "margin-bottom", "2px" )
]
]
_ ->
[]
locationIcon =
case filter of
LocationFilter _ _ icon ->
[ i
[ classList
[ ( "fa", True )
, ( "fa-" ++ icon, True )
, ( "pull-right", True )
]
]
[]
]
_ ->
[]
videoIcon =
case filter of
VideoFilter _ slug ->
let
icon =
case slug of
"has-recording" ->
"film"
"to-be-recorded" ->
"video-camera"
"not-to-be-recorded" ->
"ban"
_ ->
""
in
[ i
[ classList
[ ( "fa", True )
, ( "fa-" ++ icon, True )
, ( "pull-right", True )
]
]
[]
]
_ ->
[]
in
li
[]
[ div
([ classList
[ ( "btn", True )
, ( "btn-default", True )
, ( "filter-choice-active", active )
]
, onClick (ToggleFilter filter)
]
++ buttonStyle
)
[ span []
([ span [ classList [ ( "pull-left", True ) ] ]
[ i
[ classList
[ ( "fa", True )
, ( "fa-minus", active )
, ( "fa-plus", notActive )
]
]
[]
, text (" " ++ name)
, small [] [ text <| " (" ++ (toString eventInstanceCount) ++ ")" ]
]
]
++ locationIcon
++ videoIcon
)
]
]
findFilter : List FilterType -> String -> Maybe FilterType
findFilter modelItems filterSlug =
List.head
(List.filter
(\x ->
let
( _, slug ) =
unpackFilterType x
in
slug == filterSlug
)
modelItems
)
getFilter : String -> List FilterType -> String -> List FilterType
getFilter filterType modelItems query =
let
filterMatch =
query
|> Regex.find (Regex.AtMost 1) (Regex.regex (filterType ++ "=([\\w,_-]+)&*"))
|> List.concatMap .submatches
|> List.head
|> Maybe.withDefault Nothing
|> Maybe.withDefault ""
filterSlugs =
String.split "," filterMatch
in
List.filterMap (\x -> findFilter modelItems x) filterSlugs
parseFilterFromQuery : FilterQuery -> Model -> Filter
parseFilterFromQuery query model =
let
types =
getFilter "type" model.eventTypes query
locations =
getFilter "location" model.eventLocations query
videoFilters =
getFilter "video" videoRecordingFilters query
in
{ eventTypes = types
, eventLocations = locations
, videoRecording = videoFilters
}
filterToString : Filter -> String
filterToString filter =
let
typePart =
case String.join "," (List.map getSlugFromFilterType filter.eventTypes) of
"" ->
""
types ->
"type=" ++ types
locationPart =
case String.join "," (List.map getSlugFromFilterType filter.eventLocations) of
"" ->
""
locations ->
"location=" ++ locations
videoPart =
case String.join "," (List.map getSlugFromFilterType filter.videoRecording) of
"" ->
""
video ->
"video=" ++ video
in
String.join "&" (List.filter (\x -> x /= "") [ typePart, locationPart, videoPart ])
filterToQuery : Filter -> FilterQuery
filterToQuery filter =
let
result =
filterToString filter
in
routeToString <| OverviewFilteredRoute result
maybeFilteredOverviewRoute : Model -> Route
maybeFilteredOverviewRoute model =
case filterToString model.filter of
"" ->
OverviewRoute
query ->
OverviewFilteredRoute query

View file

@ -0,0 +1,104 @@
module Views.ScheduleOverview exposing (scheduleOverviewView)
-- Local modules
import Messages exposing (Msg(..))
import Models exposing (Model, Day, EventInstance, Filter, Route(EventRoute))
import Views.FilterView exposing (filterSidebar, applyFilters, parseFilterFromQuery)
import Routing exposing (routeToString)
-- External modules
import Html exposing (Html, text, div, ul, li, span, i, h4, p, small, a)
import Html.Lazy exposing (lazy, lazy2)
import Html.Attributes exposing (class, classList, href, style)
import Date.Extra
scheduleOverviewView : Model -> Html Msg
scheduleOverviewView model =
div [ class "row" ]
[ filterSidebar model
, div
[ classList
[ ( "col-sm-9", True )
, ( "col-sm-pull-3", True )
]
]
(List.map (\day -> lazy2 dayRowView day model) model.days)
]
dayRowView : Day -> Model -> Html Msg
dayRowView day model =
let
filteredEventInstances =
applyFilters day model
in
div []
[ h4 []
[ text day.repr ]
, div [ class "schedule-day-row" ]
(List.map (lazy dayEventInstanceView) filteredEventInstances)
]
dayEventInstanceView : EventInstance -> Html Msg
dayEventInstanceView eventInstance =
a
[ classList
[ ( "event", True )
, ( "event-in-overview", True )
]
, href <| routeToString <| EventRoute eventInstance.eventSlug
, style
[ ( "background-color", eventInstance.backgroundColor )
, ( "color", eventInstance.forgroundColor )
]
]
([ small []
[ text
((Date.Extra.toFormattedString "HH:mm" eventInstance.from)
++ " - "
++ (Date.Extra.toFormattedString "HH:mm" eventInstance.to)
)
]
]
++ (dayEventInstanceIcons eventInstance)
++ [ p
[]
[ text eventInstance.title ]
]
)
dayEventInstanceIcons : EventInstance -> List (Html Msg)
dayEventInstanceIcons eventInstance =
let
videoIcon =
case eventInstance.videoState of
"has-recording" ->
[ i
[ classList [ ( "fa", True ), ( "fa-film", True ), ( "pull-right", True ) ] ]
[]
]
"to-be-recorded" ->
[ i
[ classList [ ( "fa", True ), ( "fa-video-camera", True ), ( "pull-right", True ) ] ]
[]
]
"not-to-be-recorded" ->
[ i
[ classList [ ( "fa", True ), ( "fa-ban", True ), ( "pull-right", True ) ] ]
[]
]
_ ->
[]
in
[ i [ classList [ ( "fa", True ), ( "fa-" ++ eventInstance.locationIcon, True ), ( "pull-right", True ) ] ] []
]
++ videoIcon

View file

@ -0,0 +1,82 @@
module Views.SpeakerDetail exposing (..)
-- Local modules
import Messages exposing (Msg(BackInHistory))
import Models exposing (Model, SpeakerSlug, Speaker, Route(EventRoute), Event)
import Routing exposing (routeToString)
-- External modules
import Html exposing (Html, div, text, a, i, h3, img, ul, li, p)
import Html.Attributes exposing (classList, src, href)
import Html.Events exposing (onClick)
import Markdown
speakerDetailView : SpeakerSlug -> Model -> Html Msg
speakerDetailView speakerSlug model =
let
speaker =
model.speakers
|> List.filter (\speaker -> speaker.slug == speakerSlug)
|> List.head
image =
case speaker of
Just speaker ->
case speaker.smallPictureUrl of
Just smallPictureUrl ->
[ img [ src smallPictureUrl ] [] ]
Nothing ->
[]
Nothing ->
[]
in
case speaker of
Just speaker ->
div []
([ a [ onClick BackInHistory, classList [ ( "btn", True ), ( "btn-default", True ) ] ]
[ i [ classList [ ( "fa", True ), ( "fa-chevron-left", True ) ] ] []
, text " Back"
]
, h3 [] [ text speaker.name ]
, div [] [ Markdown.toHtml [] speaker.biography ]
, speakerEvents speaker model
]
++ image
)
Nothing ->
div [] [ text "Unknown speaker..." ]
speakerEvents : Speaker -> Model -> Html Msg
speakerEvents speaker model =
let
events =
model.events
|> List.filter (\event -> List.member speaker.slug event.speakerSlugs)
in
case events of
[] ->
p [] [ text "This speaker has no events!" ]
events ->
div []
[ h3 [] [ text "Events:" ]
, ul []
(List.map
eventItem
events
)
]
eventItem : Event -> Html Msg
eventItem event =
li []
[ a [ href <| routeToString <| EventRoute event.slug ] [ text event.title ] ]

View file

@ -0,0 +1,23 @@
module WebSocketCalls exposing (sendInitMessage)
-- Internal modules
import Messages exposing (Msg)
-- External modules
import WebSocket
import Json.Encode
sendInitMessage : String -> String -> Cmd Msg
sendInitMessage camp_slug scheduleServer =
WebSocket.send scheduleServer
(Json.Encode.encode 0
(Json.Encode.object
[ ( "action", Json.Encode.string "init" )
, ( "camp_slug", Json.Encode.string camp_slug )
]
)
)

View file

@ -127,16 +127,16 @@ urlpatterns = [
url( url(
r'^program/', include([ r'^program/', include([
url(
r'^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})/$',
ScheduleView.as_view(),
name='schedule_day'
),
url( url(
r'^$', r'^$',
ScheduleView.as_view(), ScheduleView.as_view(),
name='schedule_index' name='schedule_index'
), ),
url(
r'^noscript/$',
NoScriptScheduleView.as_view(),
name='noscript_schedule_index'
),
url( url(
r'^ics/', ICSView.as_view(), name="ics_view" r'^ics/', ICSView.as_view(), name="ics_view"
), ),

View file

@ -1,7 +1,7 @@
from channels.generic.websockets import JsonWebsocketConsumer from channels.generic.websockets import JsonWebsocketConsumer
from camps.models import Camp from camps.models import Camp
from .models import EventInstance, Favorite from .models import Event, EventInstance, Favorite, EventLocation, EventType, Speaker
class ScheduleConsumer(JsonWebsocketConsumer): class ScheduleConsumer(JsonWebsocketConsumer):
@ -10,34 +10,52 @@ class ScheduleConsumer(JsonWebsocketConsumer):
def connection_groups(self, **kwargs): def connection_groups(self, **kwargs):
return ['schedule_users'] return ['schedule_users']
def connect(self, message, **kwargs):
camp_slug = message.http_session['campslug']
try:
camp = Camp.objects.get(slug=camp_slug)
days = list(map(
lambda day:
{ 'repr': day.lower.strftime('%A %Y-%m-%d')
, 'iso': day.lower.strftime('%Y-%m-%d')
, 'day_name': day.lower.strftime('%A')
},
camp.get_days('camp')
))
event_instances_query_set = EventInstance.objects.filter(event__camp=camp)
event_instances = list(map(lambda x: x.to_json(user=message.user), event_instances_query_set))
self.send({
"accept": True,
"event_instances": event_instances,
"days": days,
"action": "init"
})
except Camp.DoesNotExist:
pass
def raw_receive(self, message, **kwargs): def raw_receive(self, message, **kwargs):
content = self.decode_json(message['text']) content = self.decode_json(message['text'])
action = content.get('action') action = content.get('action')
data = {} data = {}
if action == 'init':
camp_slug = content.get('camp_slug')
try:
camp = Camp.objects.get(slug=camp_slug)
days = list(map(
lambda day:
{
'repr': day.lower.strftime('%A %Y-%m-%d'),
'iso': day.lower.strftime('%Y-%m-%d'),
'day_name': day.lower.strftime('%A'),
},
camp.get_days('camp')
))
events_query_set = Event.objects.filter(camp=camp)
events = list([x.serialize() for x in events_query_set])
event_instances_query_set = EventInstance.objects.filter(event__camp=camp)
event_instances = list([x.serialize(user=message.user) for x in event_instances_query_set])
event_locations_query_set = EventLocation.objects.filter(camp=camp)
event_locations = list([x.serialize() for x in event_locations_query_set])
event_types_query_set = EventType.objects.filter()
event_types = list([x.serialize() for x in event_types_query_set])
speakers_query_set = Speaker.objects.filter(camp=camp)
speakers = list([x.serialize() for x in speakers_query_set])
data = {
"action": "init",
"events": events,
"event_instances": event_instances,
"event_locations": event_locations,
"event_types": event_types,
"speakers": speakers,
"days": days,
}
except Camp.DoesNotExist:
pass
if action == 'favorite': if action == 'favorite':
event_instance_id = content.get('event_instance_id') event_instance_id = content.get('event_instance_id')
event_instance = EventInstance.objects.get(id=event_instance_id) event_instance = EventInstance.objects.get(id=event_instance_id)
@ -52,6 +70,7 @@ class ScheduleConsumer(JsonWebsocketConsumer):
favorite = Favorite.objects.get(event_instance=event_instance, user=message.user) favorite = Favorite.objects.get(event_instance=event_instance, user=message.user)
favorite.delete() favorite.delete()
if data:
self.send(data) self.send(data)
def disconnect(self, message, **kwargs): def disconnect(self, message, **kwargs):

View file

@ -14,6 +14,8 @@ from django.dispatch import receiver
from django.utils.text import slugify from django.utils.text import slugify
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse_lazy, reverse
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.urls import reverse from django.urls import reverse
from django.apps import apps from django.apps import apps
@ -311,6 +313,13 @@ class EventLocation(CampRelatedModel):
class Meta: class Meta:
unique_together = (('camp', 'slug'), ('camp', 'name')) unique_together = (('camp', 'slug'), ('camp', 'name'))
def serialize(self):
return {
"name": self.name,
"slug": self.slug,
"icon": self.icon,
}
class EventType(CreatedUpdatedModel): class EventType(CreatedUpdatedModel):
""" Every event needs to have a type. """ """ Every event needs to have a type. """
@ -350,6 +359,14 @@ class EventType(CreatedUpdatedModel):
def __str__(self): def __str__(self):
return self.name return self.name
def serialize(self):
return {
"name": self.name,
"slug": self.slug,
"color": self.color,
"light_text": self.light_text,
}
class Event(CampRelatedModel): class Event(CampRelatedModel):
""" Something that is on the program one or more times. """ """ Something that is on the program one or more times. """
@ -413,6 +430,30 @@ class Event(CampRelatedModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy('event_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug}) return reverse_lazy('event_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug})
def serialize(self):
data = {
'title': self.title,
'slug': self.slug,
'abstract': self.abstract,
'speaker_slugs': [
speaker.slug
for speaker in self.speakers.all()
],
'event_type': self.event_type.name,
}
if self.video_url:
video_state = 'has-recording'
data['video_url'] = self.video_url
elif self.video_recording:
video_state = 'to-be-recorded'
elif not self.video_recording:
video_state = 'not-to-be-recorded'
data['video_state'] = video_state
return data
class EventInstance(CampRelatedModel): class EventInstance(CampRelatedModel):
""" An instance of an event """ """ An instance of an event """
@ -475,35 +516,32 @@ class EventInstance(CampRelatedModel):
ievent['location'] = icalendar.vText(self.location.name) ievent['location'] = icalendar.vText(self.location.name)
return ievent return ievent
def to_json(self, user=None): def serialize(self, user=None):
parser = CommonMark.Parser()
renderer = CommonMark.HtmlRenderer()
ast = parser.parse(self.event.abstract)
abstract = renderer.render(ast)
data = { data = {
'title': self.event.title, 'title': self.event.title,
'slug': self.event.slug + '-' + str(self.id),
'event_slug': self.event.slug, 'event_slug': self.event.slug,
'abstract': abstract,
'from': self.when.lower.astimezone().isoformat(), 'from': self.when.lower.astimezone().isoformat(),
'to': self.when.upper.astimezone().isoformat(), 'to': self.when.upper.astimezone().isoformat(),
'url': str(self.event.get_absolute_url()), 'url': str(self.event.get_absolute_url()),
'id': self.id, 'id': self.id,
'speakers': [
{'name': speaker.name, 'url': str(speaker.get_absolute_url())}
for speaker in self.event.speakers.all()
],
'bg-color': self.event.event_type.color, 'bg-color': self.event.event_type.color,
'fg-color': '#fff' if self.event.event_type.light_text else '#000', 'fg-color': '#fff' if self.event.event_type.light_text else '#000',
'event_type': self.event.event_type.slug, 'event_type': self.event.event_type.slug,
'location': self.location.slug, 'location': self.location.slug,
'location_icon': self.location.icon, 'location_icon': self.location.icon,
'timeslots': self.timeslots, 'timeslots': self.timeslots,
'video_recording': self.event.video_recording,
} }
if self.event.video_url: if self.event.video_url:
video_state = 'has-recording'
data['video_url'] = self.event.video_url data['video_url'] = self.event.video_url
elif self.event.video_recording:
video_state = 'to-be-recorded'
elif not self.event.video_recording:
video_state = 'not-to-be-recorded'
data['video_state'] = video_state
if user and user.is_authenticated: if user and user.is_authenticated:
is_favorited = user.favorites.filter(event_instance=self).exists() is_favorited = user.favorites.filter(event_instance=self).exists()
@ -589,6 +627,29 @@ class Speaker(CampRelatedModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy('speaker_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug}) return reverse_lazy('speaker_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug})
def get_picture_url(self, size):
return reverse('speaker_picture', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug, 'picture': size})
def get_small_picture_url(self):
return self.get_picture_url('thumbnail')
def get_large_picture_url(self):
return self.get_picture_url('large')
def serialize(self):
data = {
'name': self.name,
'slug': self.slug,
'biography': self.biography,
}
if self.picture_small and self.picture_large:
data['large_picture_url'] = self.get_large_picture_url()
data['small_picture_url'] = self.get_small_picture_url()
return data
class Favorite(models.Model): class Favorite(models.Model):
user = models.ForeignKey('auth.User', related_name='favorites') user = models.ForeignKey('auth.User', related_name='favorites')

File diff suppressed because one or more lines are too long

View file

@ -1,552 +0,0 @@
const webSocketBridge = new channels.WebSocketBridge();
var modals = {};
var EVENT_INSTANCES = [], DAYS = [], CONFIG = {};
function toggleFavoriteButton(button) {
if(button.getAttribute('data-state') == 'true') {
favorite_button.classList.remove('btn-success');
favorite_button.classList.add('btn-danger');
favorite_button.innerHTML = '<i class="fa fa-minus"></i> Remove favorite';
favorite_button.onclick = function(e) {
button.setAttribute('data-state', 'false')
webSocketBridge.send({action: 'unfavorite', event_instance_id: event_instance_id});
toggleFavoriteButton(button)
}
} else {
favorite_button.classList.remove('btn-danger');
favorite_button.classList.add('btn-success');
favorite_button.innerHTML = '<i class="fa fa-star"></i> Favorite';
favorite_button.onclick = function(e) {
button.setAttribute('data-state', 'true')
webSocketBridge.send({action: 'favorite', event_instance_id: event_instance_id});
toggleFavoriteButton(button)
}
}
}
function setup_websocket() {
webSocketBridge.connect('/schedule/');
webSocketBridge.listen(function(payload, stream) {
if(payload['action'] == 'init') {
EVENT_INSTANCES = payload['event_instances'];
DAYS = payload['days'];
render();
}
});
}
function init(config) {
CONFIG = config;
setup_websocket();
render();
}
function findGetParameter(parameterName) {
var result = null,
tmp = [];
location.search
.substr(1)
.split("&")
.forEach(function (item) {
tmp = item.split("=");
if (tmp[0] === parameterName) {
result = decodeURIComponent(tmp[1]);
}
});
return result;
}
function get_parameters() {
var day_parameter = findGetParameter('day');
var filter_day = day_parameter != null ? day_parameter.split(',') : [];
var type_parameter = findGetParameter('type');
var filter_types = type_parameter != null ? type_parameter.split(',') : [];
var location_parameter = findGetParameter('location')
var filter_locations = location_parameter != null ? location_parameter.split(',') : [];
return {
'day': filter_day[0],
'types': filter_types,
'locations': filter_locations
}
}
function render() {
parameters = get_parameters();
toggleFilterBoxes(parameters['types'], parameters['locations']);
render_day_menu(parameters['day']);
setICSButtonHref(location.search);
if(parameters['day'] != null) {
render_day(parameters['types'], parameters['locations'], parameters['day']);
} else {
render_schedule(parameters['types'], parameters['locations']);
}
}
function render_day_menu(active_iso) {
var container = document.getElementById('schedule-days');
container.innerHTML = '';
var mobile_container = document.getElementById('schedule-days-mobile');
mobile_container.innerHTML = '';
function set_btn_type(classList, primary) {
if(primary == true) {
classList.add('btn-primary');
} else {
classList.add('btn-default');
}
}
function dayEvent(e) {
setHistoryState({
'day': this.dataset.iso
});
render();
}
var all_days = document.createElement('a');
all_days.classList.add('btn');
set_btn_type(all_days.classList, active_iso == null);
all_days.innerHTML = 'All days';
all_days.dataset.iso = 'all-days';
all_days.addEventListener('click', dayEvent);
container.appendChild(all_days);
all_days_mobile = all_days.cloneNode(true);
all_days_mobile.addEventListener('click', dayEvent);
mobile_container.appendChild(all_days_mobile);
for(var day_id in DAYS) {
var day_link = document.createElement('a');
day_link.classList.add('btn');
set_btn_type(day_link.classList, DAYS[day_id]['iso'] == active_iso);
day_link.dataset.iso = DAYS[day_id]['iso'];
day_link.innerHTML = DAYS[day_id]['day_name'];
day_link.addEventListener('click', dayEvent);
container.appendChild(day_link);
day_link_mobile = day_link.cloneNode(true);
day_link_mobile.addEventListener('click', dayEvent);
mobile_container.appendChild(day_link_mobile);
}
}
function render_day(types, locations, day) {
function hoursTohhmm(hours){
var hour = Math.floor(Math.abs(hours));
var minutes = Math.floor((Math.abs(hours) * 60) % 60);
if(hour > 24) {
hour = hour - 24;
}
return (hour < 10 ? "0" : "") + hour + ":" + (minutes < 10 ? "0" : "") + minutes;
}
var event_instances = get_instances(types, locations, day);
var schedule_container = document.getElementById('schedule-container');
schedule_container.innerHTML = '';
var day_table = document.createElement('table');
schedule_container.appendChild(day_table);
day_table.classList.add('table');
day_table.classList.add('day-table');
day_table_body = document.createElement('tbody');
day_table.appendChild(day_table_body);
var array_length = (24*60)/CONFIG['schedule_timeslot_length_minutes'];
var timeslots_ = Array(array_length);
var timeslots = [];
for(var i=0; i<timeslots_.length; i++) {
timeslots.push(
{ 'offset': i * CONFIG['schedule_timeslot_length_minutes']
, 'minutes_since_midnight': (CONFIG['schedule_midnight_offset_hours'] * 60) + (i * CONFIG['schedule_timeslot_length_minutes'])
}
);
}
var timeslot_trs = {};
for(var timeslots_index in timeslots) {
var timeslot_tr = document.createElement('tr');
day_table_body.appendChild(timeslot_tr);
var timeslot_td = document.createElement('td');
timeslot_tr.appendChild(timeslot_td);
var minutes_since_midnight = timeslots[timeslots_index]['minutes_since_midnight'];
if(minutes_since_midnight / 60 % 1 == 0) {
timeslot_td.innerHTML = hoursTohhmm(minutes_since_midnight / 60);
}
timeslot_trs[hoursTohhmm(minutes_since_midnight / 60)] = timeslot_tr;
}
for(var event_instances_index in event_instances) {
var event_instance = event_instances[event_instances_index];
var event_instance_td = document.createElement('td');
event_instance_td.innerHTML = event_instance['title'];
event_instance_td.setAttribute('rowspan', event_instance['timeslots']);
event_instance_td.classList.add('event-td');
event_instance_td.setAttribute(
'style',
'background-color: ' + event_instance['bg-color'] +
'; color: ' + event_instance['fg-color']);
event_instance_td.onclick = openModal
event_instance_td.dataset.eventInstanceId = event_instance['id'];
var timeslot_tr = timeslot_trs[event_instance.from.slice(11, 16)];
timeslot_tr.appendChild(event_instance_td);
}
}
function render_schedule(types, locations) {
var event_instances = get_instances(types, locations);
var schedule_container = document.getElementById('schedule-container');
schedule_container.innerHTML = "";
var cloned_days = DAYS.slice(0);
var rendered_days = cloned_days.map(function(day) {
day_event_instances = event_instances.slice(0).filter(
function(event_instance) {
var event_day = event_instance['from'].slice(0, 10);
return event_day == day['iso'];
}
);
return render_schedule_day(day, day_event_instances);
});
for(day_id in rendered_days) {
schedule_container.appendChild(rendered_days[day_id]['label']);
schedule_container.appendChild(rendered_days[day_id]['element']);
}
}
function render_schedule_day(day, event_instances) {
var element = document.createElement('div');
element.classList.add('schedule-day-row');
var day_label = document.createElement('h4');
day_label.innerHTML = day['repr'];
element.appendChild(day_label);
for(event_instance_id in event_instances) {
var event_instance = event_instances[event_instance_id];
var rendered_event_instance = render_event_instance(event_instance);
element.appendChild(rendered_event_instance);
}
return {"label": day_label, "element": element};
}
function render_event_instance(event_instance) {
var element = document.createElement('a');
element.setAttribute(
'style',
'background-color: ' + event_instance['bg-color'] +
'; color: ' + event_instance['fg-color']);
element.classList.add('event');
element.setAttribute('href', event_instance['url']);
element.dataset.eventInstanceId = event_instance['id'];
time_element = document.createElement('small');
time_element.innerHTML = event_instance.from.slice(11, 16) + " - " + event_instance.to.slice(11, 16);
title_element = document.createElement('p');
title_element.innerHTML = event_instance['title'];
icon_element = document.createElement('i');
icon_element.classList.add('fa-' + event_instance['location_icon']);
icon_element.classList.add('fa');
icon_element.classList.add('pull-right');
if(event_instance['video_url'] != undefined) {
video_url_element = document.createElement('i');
video_url_element.classList.add('fa-film');
video_url_element.classList.add('fa');
video_url_element.classList.add('pull-right');
element.appendChild(video_url_element);
} else if(event_instance['video_recording'] == true) {
video_recording_element = document.createElement('i');
video_recording_element.classList.add('fa-video-camera');
video_recording_element.classList.add('fa');
video_recording_element.classList.add('pull-right');
element.appendChild(video_recording_element);
}
element.appendChild(time_element);
element.appendChild(icon_element);
element.appendChild(title_element);
element.onclick = openModal
return element
}
function get_instances(types, locations, day) {
var event_instances = EVENT_INSTANCES.slice(0);
if(day != undefined && day != null) {
event_instances = event_instances.filter(
function(event_instance) {
return event_instance.from.slice(0, 10) == day;
}
);
}
if(locations.length != 0) {
event_instances = event_instances.filter(
function(event_instance) {
return locations.includes(event_instance['location']);
}
);
}
if(types.length != 0) {
event_instances = event_instances.filter(
function(event_instance) {
return types.includes(event_instance['event_type']);
}
);
}
return event_instances
}
function openModal(e) {
e.preventDefault();
// Avoid that clicking the text in the event will bring up an empty modal
target = e.target;
if (e.target !== this) {
target = e.target.parentElement
}
event_instance_id = target.dataset.eventInstanceId;
event_instance = EVENT_INSTANCES.filter(
function(event_instance) {
return event_instance.id == event_instance_id
}
)[0];
modal = modals[event_instance_id];
if(modal == undefined) {
modal = document.createElement('div');
modal.classList.add('modal');
modal.setAttribute('tabindex', '-1');
modal.setAttribute('role', 'dialog');
modal_dialog = document.createElement('div');
modal_dialog.classList.add('modal-dialog');
modal.setAttribute('role', 'document');
modal.appendChild(modal_dialog);
modal_content = document.createElement('div');
modal_content.classList.add('modal-content');
modal_dialog.appendChild(modal_content);
modal_header = document.createElement('div');
modal_header.classList.add('modal-header');
modal_content.appendChild(modal_header);
modal_close_button = document.createElement('button');
modal_close_button.setAttribute('type', 'button');
modal_close_button.setAttribute('aria-label', 'Close');
modal_close_button.dataset.dismiss = 'modal';
modal_close_button.classList.add('close');
modal_close_button.innerHTML = '<span aria-hidden="true">&times;</span></button>';
modal_title = document.createElement('h4');
modal_title.classList.add('modal-title')
modal_title.innerHTML = event_instance['title'];
modal_header.appendChild(modal_close_button);
modal_header.appendChild(modal_title);
modal_body_content = document.createElement('div');
modal_body_content.classList.add('modal-body');
modal_body_content.classList.add('modal-body-content');
modal_body_content.innerHTML = event_instance['abstract'];
modal_content.appendChild(modal_body_content);
modal_body = document.createElement('div');
modal_body.classList.add('modal-body');
speakers_h4 = document.createElement('h4');
speakers_h4.innerHTML = 'Speaker(s):';
speakers_ul = document.createElement('ul');
speakers_ul.classList.add('speakers');
speakers = event_instance['speakers'];
for(speaker_id in speakers) {
var speaker = speakers[speaker_id];
var speaker_li = document.createElement('li');
var speaker_a = document.createElement('a');
speaker_a.setAttribute('href', speaker['url']);
speaker_a.appendChild(document.createTextNode(speaker['name']));
speaker_li.appendChild(speaker_a);
speakers_ul.appendChild(speaker_li);
}
video_recording_div = document.createElement('div');
video_recording_div.classList.add('alert');
video_recording_div.classList.add('alert-info');
video_recording_div.classList.add('video-recording');
if(event_instance['video_url'] != undefined) {
// We have an URL to the video
video_url_icon = document.createElement('i');
video_url_icon.classList.add('fa');
video_url_icon.classList.add('fa-film');
video_url_link = document.createElement('a');
video_url_link.setAttribute('href', event_instance['video_url']);
video_url_link.setAttribute('target', '_blank');
video_url_link.innerHTML = " Watch the video recording here!";
video_recording_div.appendChild(video_url_icon);
video_recording_div.appendChild(video_url_link);
} else if(event_instance['video_recording'] == true) {
// This instance will be recorded
video_notice_icon = document.createElement('i');
video_notice_icon.classList.add('fa');
video_notice_icon.classList.add('fa-camera');
video_notice_span = document.createElement('span');
video_notice_span.innerHTML = " This event will be recorded!";
video_recording_div.appendChild(video_notice_icon);
video_recording_div.appendChild(video_notice_span);
} else {
// This instance will NOT be recorded!
video_recording_element.remove();
}
modal_body.appendChild(speakers_h4);
modal_body.appendChild(speakers_ul);
modal_body.appendChild(video_recording_div);
modal_content.appendChild(modal_body);
modal_footer = document.createElement('div');
modal_footer.classList.add('modal-footer');
modal_content.appendChild(modal_footer);
close_button = document.createElement('button');
close_button.setAttribute('type', 'button');
close_button.classList.add('btn');
close_button.classList.add('btn-default');
close_button.classList.add('pull-left');
close_button.dataset.dismiss = "modal";
close_button.innerHTML = "Close";
modal_footer.appendChild(close_button);
favorite_button = document.createElement('a');
favorite_button.classList.add('btn');
favorite_button.classList.add('btn-success');
favorite_button.classList.add('favorite-button');
favorite_button.innerHTML = '<i class="fa fa-star"></i> Favorite</a>';
if(event_instance['is_favorited'] !== undefined) {
favorite_button.setAttribute('data-state', event_instance['is_favorited'])
toggleFavoriteButton(favorite_button);
} else {
favorite_button.remove();
}
modal_footer.appendChild(favorite_button);
more_button = document.createElement('a');
more_button.classList.add('btn');
more_button.classList.add('btn-info');
more_button.classList.add('more-button');
more_button.setAttribute('href', event_instance['url']);
more_button.innerHTML = '<i class="fa fa-info"></i> More</a>';
modal_footer.appendChild(more_button);
body = document.getElementsByTagName('body')[0];
body.appendChild(modal);
modal.setAttribute('id', 'event-modal-' + event_instance_id)
modals[event_instance_id] = modal;
}
$('#event-modal-' + event_instance_id).modal();
}
function init_modals(event_class_name) {
var event_elements = document.getElementsByClassName(event_class_name);
for (var event_id in event_elements) {
event_element = event_elements.item(event_id);
if(event_element != null) {
event_element.onclick = openModal
}
}
}
var filter = document.getElementById('filter')
filter.addEventListener('change', function(e) {
var type_input = Array.prototype.slice.call(document.querySelectorAll('.event-type-checkbox:checked'));
var types = type_input.map(function(box) {
return box.value
})
var location_input = Array.prototype.slice.call(document.querySelectorAll('.location-checkbox:checked'));
var locations = location_input.map(function(box) {
return box.value
})
toggleFilterBoxes(types, locations);
setHistoryState({
'types': types,
'locations': locations
});
render();
});
function setHistoryState(parts) {
var day = parts['day'];
var types = parts['types'];
var locations = parts['locations'];
var query = '?';
day = day == undefined ? findGetParameter('day') : day;
if(day != null && day != 'all-days') {
query = query + "day=" + day + "&";
}
types = types == undefined ? findGetParameter('type') : types.join(',');
if(types != null && types.length > 0) {
var type_part = 'type=' + types;
query = query + type_part + "&";
}
locations = locations == undefined ? findGetParameter('location') : locations.join(',');
if(locations != null && locations.length > 0) {
var location_part = 'location=' + locations;
query = query + location_part;
}
history.replaceState({}, '', query);
setICSButtonHref(query);
}
function setICSButtonHref(query) {
// Update ICS button as well
var ics_button = document.querySelector('#ics-button');
ics_button.setAttribute('href', CONFIG['ics_button_href'] + query);
}
function toggleFilterBoxes(types, locations) {
var type_input = Array.prototype.slice.call(document.querySelectorAll('.event-type-checkbox'));
type_input.map(function(box) {
if(types.includes(box.value)) {
box.checked = true;
}
return box;
});
var location_input = Array.prototype.slice.call(document.querySelectorAll('.location-checkbox'));
location_input.map(function(box) {
if(locations.includes(box.value)) {
box.checked = true;
}
return box;
});
}

View file

@ -0,0 +1,39 @@
{% extends 'program_base.html' %}
{% load commonmark %}
{% load staticfiles %}
{% block program_content %}
<div class="row">
<table class="table">
<thead>
<th>When?</th>
<th>What?</th>
<th>Where?</th>
</thead>
<tbody>
{% for instance in eventinstances %}
{% ifchanged instance.when.lower.date %}
<tr>
<td colspan=3><strong>{{ instance.when.lower.date|date:"l Y-m-d" }}</strong></td>
</tr>
{% endifchanged %}
<tr>
<td>{{ instance.when.lower|date:"H:i" }}-{{ instance.when.upper|date:"H:i" }}</td>
<td><a href="{% url 'event_detail' camp_slug=camp.slug slug=instance.event.slug %}">{{ instance.event.title }}</a></td>
<td>{{ instance.location.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -2,6 +2,17 @@
{% load commonmark %} {% load commonmark %}
{% block program_content %} {% block program_content %}
<div class="row">
<noscript>
<a href="{% url "noscript_schedule_index" camp_slug=camp.slug %}" class="btn btn-primary">
Back to noscript schedule
</a>
<hr />
</noscript>
</div>
<div class="row">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading" ><span style="font-size: x-large"><span style="background-color: {{ event.event_type.color }}; border: 0; color: {% if event.event_type.light_text %}white{% else %}black{% endif %}; display: inline-block; padding: 5px;">{{ event.event_type.name }}</span> {{ event.title }}</span></div> <div class="panel-heading" ><span style="font-size: x-large"><span style="background-color: {{ event.event_type.color }}; border: 0; color: {% if event.event_type.light_text %}white{% else %}black{% endif %}; display: inline-block; padding: 5px;">{{ event.event_type.name }}</span> {{ event.title }}</span></div>
<div class="panel-body"> <div class="panel-body">
@ -32,5 +43,6 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
{% endblock program_content %} {% endblock program_content %}

View file

@ -1,22 +1,44 @@
{% extends 'schedule_base.html' %} {% extends 'program_base.html' %}
{% load commonmark %} {% load commonmark %}
{% load staticfiles %} {% load staticfiles %}
{% block schedule_content %} {% block extra_head %}
<noscript>
<meta http-equiv="refresh" content="0; url={% url "noscript_schedule_index" camp_slug=camp.slug %}" />
</noscript>
{% endblock %}
{% block program_content %}
<noscript>
<div class="row">
<p>
No javascript? Don't worry, we have a HTML only version of the schedule! Redirecting you there now.
</p>
<p>
<a href="{% url "noscript_schedule_index" camp_slug=camp.slug %}">
Click here if you are not redirected.
</a>
</p>
</div>
</noscript>
<div id="schedule-container"></div> <div id="schedule-container"></div>
<script src="{% static "channels/js/websocketbridge.js" %}"></script> <script src="{% static "js/elm_based_schedule.js" %}"></script>
<script src="{% static "js/event_instance_websocket.js" %}"></script>
<script> <script>
init( var container = document.getElementById('schedule-container');
var elm_app = Elm.Main.embed(
container,
{ 'schedule_timeslot_length_minutes': Number('{{ schedule_timeslot_length_minutes }}') { 'schedule_timeslot_length_minutes': Number('{{ schedule_timeslot_length_minutes }}')
, 'schedule_midnight_offset_hours': Number('{{ schedule_midnight_offset_hours }}') , 'schedule_midnight_offset_hours': Number('{{ schedule_midnight_offset_hours }}')
, 'ics_button_href': "{% url 'ics_view' camp_slug=camp.slug %}" , 'ics_button_href': "{% url 'ics_view' camp_slug=camp.slug %}"
, 'camp_slug': "{{ camp.slug }}"
, 'websocket_server': "ws://" + window.location.host + "/schedule/"
} }
); );
</script> </script>
{% endblock schedule_content %} {% endblock %}

View file

@ -11,6 +11,7 @@ from django.utils.decorators import method_decorator
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages from django.contrib import messages
from django.urls import reverse from django.urls import reverse
from django.db.models import Q
import icalendar import icalendar
@ -37,27 +38,45 @@ logger = logging.getLogger("bornhack.%s" % __name__)
class ICSView(CampViewMixin, View): class ICSView(CampViewMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
eventinstances = models.EventInstance.objects.filter(event__camp=self.camp) eventinstances = models.EventInstance.objects.filter(event__camp=self.camp)
type_ = request.GET.get('type', None)
location = request.GET.get('location', None)
if type_: # Type query
try: type_query = request.GET.get('type', None)
eventtype = models.EventType.objects.get( if type_query:
slug=type_ type_slugs = type_query.split(',')
types = models.EventType.objects.filter(
slug__in=type_slugs
) )
eventinstances = eventinstances.filter(event__event_type=eventtype) eventinstances = eventinstances.filter(event__event_type__in=types)
except models.EventType.DoesNotExist:
raise Http404
if location: # Location query
try: location_query = request.GET.get('location', None)
eventlocation = models.EventLocation.objects.get( if location_query:
slug=location, location_slugs = location_query.split(',')
locations = models.EventLocation.objects.filter(
slug__in=location_slugs,
camp=self.camp, camp=self.camp,
) )
eventinstances = eventinstances.filter(location__slug=location) eventinstances = eventinstances.filter(location__in=locations)
except models.EventLocation.DoesNotExist:
raise Http404 # Video recording query
video_query = request.GET.get('video', None)
if video_query:
video_states = video_query.split(',')
query_kwargs = {}
if 'has-recording' in video_states:
query_kwargs['event__video_url__isnull'] = False
if 'to-be-recorded' in video_states:
query_kwargs['event__video_recording'] = True
if 'not-to-be-recorded' in video_states:
if 'event__video_recording' in query_kwargs:
del query_kwargs['event__video_recording']
else:
query_kwargs['event__video_recording'] = False
eventinstances = eventinstances.filter(**query_kwargs)
cal = icalendar.Calendar() cal = icalendar.Calendar()
for event_instance in eventinstances: for event_instance in eventinstances:
@ -263,57 +282,22 @@ class EventDetailView(CampViewMixin, DetailView):
################## schedule ############################################# ################## schedule #############################################
class NoScriptScheduleView(CampViewMixin, TemplateView):
template_name = "noscript_schedule_view.html"
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(**kwargs)
context['eventinstances'] = models.EventInstance.objects.filter(event__camp=self.camp).order_by('when')
return context
class ScheduleView(CampViewMixin, TemplateView): class ScheduleView(CampViewMixin, TemplateView):
def get_template_names(self): template_name = 'schedule_overview.html'
if 'day' in self.kwargs:
return 'schedule_day.html'
return 'schedule_overview.html'
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
context = super(ScheduleView, self).get_context_data(**kwargs) context = super(ScheduleView, self).get_context_data(**kwargs)
# Do stuff if we are dealing with a day schedule
if 'day' in kwargs:
when = datetime.datetime(year=int(self.kwargs['year']), month=int(self.kwargs['month']), day=int(self.kwargs['day']))
eventinstances = models.EventInstance.objects.filter(event__in=self.camp.events.all())
skip = []
for ei in eventinstances:
if ei.schedule_date != when.date():
skip.append(ei.id)
else:
if 'type' in self.request.GET:
eventtype = models.EventType.objects.get(
slug=self.request.GET['type']
)
if ei.event.event_type != eventtype:
skip.append(ei.id)
eventinstances = eventinstances.exclude(id__in=skip).order_by('event__event_type')
if 'location' in self.request.GET:
eventlocation = models.EventLocation.objects.get(
camp=self.camp,
slug=self.request.GET['location']
)
eventinstances = eventinstances.filter(location=eventlocation)
context['eventinstances'] = eventinstances
start = when + datetime.timedelta(hours=settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS)
timeslots = []
# calculate how many timeslots we have in the schedule based on the lenght of the timeslots in minutes,
# and the number of minutes in 24 hours
for i in range(0,int((24*60)/settings.SCHEDULE_TIMESLOT_LENGTH_MINUTES)):
timeslot = start + datetime.timedelta(minutes=i*settings.SCHEDULE_TIMESLOT_LENGTH_MINUTES)
timeslots.append(timeslot)
context['timeslots'] = timeslots
# include the components to make the urls
context['urlyear'] = self.kwargs['year']
context['urlmonth'] = self.kwargs['month']
context['urlday'] = self.kwargs['day']
context['schedule_timeslot_length_minutes'] = settings.SCHEDULE_TIMESLOT_LENGTH_MINUTES;
context['schedule_midnight_offset_hours'] = settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS; context['schedule_midnight_offset_hours'] = settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS;
return context return context

View file

@ -1,6 +1,7 @@
body { body {
margin-top: 85px; margin-top: 85px;
margin-bottom: 35px; margin-bottom: 35px;
overflow: scroll;
} }
* { * {
@ -148,27 +149,43 @@ footer {
.event { .event {
padding: 5px; padding: 5px;
vertical-align: top; vertical-align: top;
margin: 5px;
width: 200px;
max-width: 200px;
min-width: 200px;
min-height: 100px;
flex-grow: 1; flex-grow: 1;
border: 1px solid black; border: 1px solid black;
cursor: pointer; cursor: pointer;
} }
.event-td { .event-in-overview {
padding: 5px; min-height: 100px;
vertical-align: top; margin: 5px;
max-width: 200px; max-width: 200px;
min-width: 200px; min-width: 200px;
flex-grow: 1;
cursor: pointer;
} }
.event-td a { .location-column {
display: block; position: relative;
margin: 0 2px;
background-color: #f5f5f5;
}
.location-column-header {
text-align: center;
font-weight: bold;
line-height: 50px;
font-size: 20px;
background-color: #eee;
border-bottom: 1px solid #fff;
}
.location-column-slot {
border-bottom: 1px solid #fff;
}
.day-view-gutter {
text-align: right;
}
.event-in-dayview {
position: absolute;
} }
@ -179,16 +196,12 @@ footer {
} }
} }
.event:hover, .event-td:hover { .event:hover {
background-color: black !important; background-color: black !important;
color: white !important; color: white !important;
text-decoration: none !important; text-decoration: none !important;
} }
.event-td a:hover {
text-decoration: none !important;
}
.fa-select { .fa-select {
font-family: 'FontAwesome','Helvetica Neue',Helvetica,Arial,sans-serif; font-family: 'FontAwesome','Helvetica Neue',Helvetica,Arial,sans-serif;
} }
@ -208,13 +221,28 @@ footer {
padding: 0; padding: 0;
} }
.schedule-filter .btn {
width: 100%;
text-align: left;
}
@media (min-width: 520px) { @media (min-width: 520px) {
.schedule-filter { .schedule-sidebar {
border-left: 1px solid #eee; border-left: 1px solid #eee;
} }
} }
.sticky {
position: sticky;
background-color: #fff;
z-index: 9999;
}
#daypicker {
top: 80px;
}
#schedule-days { #schedule-days {
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -231,28 +259,7 @@ footer {
flex-wrap: wrap; flex-wrap: wrap;
} }
.form-group .schedule-checkbox { .filter-choice-active {
display: none;
}
.form-group input[type="checkbox"] + .btn-group > label span {
width: 20px;
}
.form-group input[type="checkbox"] + .btn-group > label span i:first-child {
display: none;
}
.form-group input[type="checkbox"] + .btn-group > label span i:last-child {
display: inline-block;
}
.form-group input[type="checkbox"]:checked + .btn-group > label span i:first-child {
display: inline-block;
}
.form-group input[type="checkbox"]:checked + .btn-group > label span i:last-child {
display: none;
}
.form-group input[type="checkbox"]:checked + .btn-group > label {
color: #333; color: #333;
background-color: #e6e6e6; background-color: #e6e6e6;
border-color: #adadad; border-color: #adadad;