diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 904ee86..8ca0f17 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -9,6 +9,8 @@ on: jobs: build: env: + CAPTIVATE_USERNAME: ${{ secrets.CAPTIVATE_USERNAME }} + CAPTIVATE_TOKEN: ${{ secrets.CAPTIVATE_TOKEN }} GOOGLE_CALENDAR_API_KEY: ${{ secrets.GOOGLE_CALENDAR_API_KEY }} runs-on: ubuntu-latest steps: diff --git a/bin/flutter_spaces.dart b/bin/flutter_spaces.dart index f09533a..9584571 100644 --- a/bin/flutter_spaces.dart +++ b/bin/flutter_spaces.dart @@ -1,7 +1,13 @@ +import 'dart:io'; + import 'package:flutter_spaces/flutter_spaces.dart'; import 'package:static_shock/static_shock.dart'; Future main(List arguments) async { + // Load Captivate credentials. + final podcastShowId = "3743ba71-859c-4164-99ff-999b525ccf48"; + final (captivateUsername, captivateApiKey) = _loadCaptivateCredentials(); + // Configure the static website generator. final staticShock = StaticShock() ..pick(DirectoryPicker.parse("images")) @@ -10,8 +16,34 @@ Future main(List arguments) async { ..plugin(const PrettyUrlsPlugin()) ..plugin(const SassPlugin()) // Load data for the next scheduled Flutter space. - ..loadData(const FlutterSpacesCalendarLoader()); + ..loadData(const FlutterSpacesCalendarLoader()) + // Load all episodes. + ..loadData(EpisodeLoader( + captivateUsername: captivateUsername, + captivateApiToken: captivateApiKey, + showId: podcastShowId, + )); // Generate the static website. await staticShock.generateSite(); } + +(String username, String apiToken) _loadCaptivateCredentials() { + // REQUIRED: Environment variable called "captivate_username" + final captivateUsername = Platform.environment["CAPTIVATE_USERNAME"] ?? Platform.environment["captivate_username"]; + + // REQUIRED: Environment variable called "captivate_token" + final captivateApiToken = Platform.environment["CAPTIVATE_TOKEN"] ?? Platform.environment["captivate_token"]; + + if (captivateUsername == null) { + print("Missing environment variable for Captivate username."); + exit(-1); + } + + if (captivateApiToken == null) { + print("Missing environment variable for Captivate API token."); + exit(-1); + } + + return (captivateUsername, captivateApiToken); +} diff --git a/lib/flutter_spaces.dart b/lib/flutter_spaces.dart index b80d987..3eef18a 100644 --- a/lib/flutter_spaces.dart +++ b/lib/flutter_spaces.dart @@ -1 +1,2 @@ export 'src/google_calendar.dart'; +export 'src/podcast_episodes.dart'; diff --git a/lib/src/podcast_episodes.dart b/lib/src/podcast_episodes.dart new file mode 100644 index 0000000..13b40f4 --- /dev/null +++ b/lib/src/podcast_episodes.dart @@ -0,0 +1,67 @@ +import 'package:captivate/captivate.dart'; +import 'package:intl/intl.dart'; +import 'package:static_shock/static_shock.dart'; + +class EpisodeLoader implements DataLoader { + EpisodeLoader({ + required this.captivateUsername, + required this.captivateApiToken, + required this.showId, + }); + + final _client = CaptivateClient(); + final String captivateUsername; + final String captivateApiToken; + final String showId; + + @override + Future> loadData(StaticShockPipelineContext context) async { + context.log.info("Loading podcast episodes..."); + + context.log.detail("Authenticating with Captivate..."); + final userPayload = await _client.authenticate( + username: captivateUsername, + apiToken: captivateApiToken, + ); + final authToken = userPayload!.user.token; + context.log.detail("Done authenticating with Captivate"); + + context.log.detail("Requesting all episodes from Captivate..."); + final response = await _client.getEpisodes(authToken, showId); + if (response == null) { + context.log.warn("Failed to load episodes for show: $showId"); + return { + "episodes": [], + }; + } + context.log.detail("Loaded ${response.episodes.length} episodes."); + + context.log.info("Done loading episodes from Captivate."); + + return { + // Ordered from most recent to least recent. + "loadedEpisodes": [ + for (final episode in response.episodes) // + { + "id": episode.id, + "title": episode.title, + "date": episode.formattedPublishedDate, + } + ], + }; + } +} + +extension on Episode { + static final captivateDateFormat = DateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + static final presentationDateFormat = DateFormat("MMMM dd, yyyy"); + + String get formattedPublishedDate { + if (publishedDate == null) { + return "Now Available"; + } + + final dateTime = captivateDateFormat.parse(publishedDate!); + return presentationDateFormat.format(dateTime); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7f91364..52d55ef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -62,6 +62,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + captivate: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "5b4f2fcbff928758048837bf489307617d748e1d" + url: "https://github.com/Flutter-Bounty-Hunters/captivate" + source: git + version: "0.1.0" charcode: dependency: transitive description: @@ -279,7 +288,7 @@ packages: source: hosted version: "4.3.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf @@ -346,10 +355,10 @@ packages: dependency: transitive description: name: markdown - sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "7.3.0" mason_logger: dependency: transitive description: @@ -514,10 +523,10 @@ packages: dependency: transitive description: name: sass - sha256: c6abff6269edf6dc6767fde2ead064cbf939b8982656bd6a2097eda5d9c8f42b + sha256: "4577992b848d9bc5f4934dc30a73bb078d3f1f73c16d7b36bffdce220c1733c8" url: "https://pub.dev" source: hosted - version: "1.83.1" + version: "1.83.4" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ab28a7c..74453b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,12 @@ dependencies: googleapis: ^13.2.0 googleapis_auth: ^1.6.0 + intl: ^0.19.0 + + captivate: + git: + url: https://github.com/Flutter-Bounty-Hunters/captivate + dev_dependencies: lints: ^2.0.0 diff --git a/source/images/homepage/dash-astronaut.png b/source/images/homepage/dash-astronaut.png new file mode 100644 index 0000000..22bd27c Binary files /dev/null and b/source/images/homepage/dash-astronaut.png differ diff --git a/source/index.jinja b/source/index.jinja index fe500ef..98238c5 100644 --- a/source/index.jinja +++ b/source/index.jinja @@ -36,79 +36,64 @@
-
-
- - -
WEEKLY FLUTTER SPACE
- - - -
 
- -
Join us every week for any and all Flutter topics!
- -
-

HOSTS

-
-
- -

Matt Carroll

-
-
- -

Ray Li

-
-
+
+
 
+ +
+ +

Flutter Spaces

+
 
+ Weekly Flutter Conversations +
+ +
 
+ +
+
+
-
-
-
 
-
- - -
-

NEXT SPACE

- -

 

-
+
 
+ +
+
+
📅
+
+

Next Space

+

 

-
 
+ Add to Calendar
-
-
-
- -
-
+
 
- - -
- {% for episode in episodes %} - - {% if episode.podcast_id %} -
- {% endif %} - -


- {% endfor %} +
+

Built with ❤️ by Matt Carroll

+
+
- +
+
+ {% for episode in loadedEpisodes %} +
+
+

{{ episode.title }}

+

{{ episode.date }}

+
+
+ {% endfor %} +
- diff --git a/source/styles/homepage.scss b/source/styles/homepage.scss index 8308288..12d65e4 100644 --- a/source/styles/homepage.scss +++ b/source/styles/homepage.scss @@ -1,271 +1,388 @@ -$screenBackground: #48B3E0; +$screenBackground: #14171A; $breakpointSmall: 1100px; $breakpointMedium: 1300px; $breakpointWide: 1600px; html, body { + position: relative; + background: $screenBackground; } -header { - position: relative; +//---------- LANDSCAPE LAYOUT ----------// +body { + @media (aspect-ratio > 1) { - padding-top: 24px; + main { + display: flex; + flex-direction: row; + width: 100vw; + height: 100vh; + } - color: WHITE; - text-align: center; + #mainPane { + flex: 1; - background-image: url("/images/homepage/top-light.png"); - background-repeat: no-repeat; - background-position: center top; + position: relative; + display: flex; + flex-direction: column; + align-items: center; - .foreground { - position: relative; - z-index: 100; + header { + padding: 0 128px; - .logo { - margin-bottom: 48px; - } + text-align: center; - .title { - font-size: 24px; - font-weight: bold; + #mascot { + margin-bottom: 20px; + height: 128px; + opacity: 0.5; + } - text-shadow: 0px 3px #3BA3D0; - } + h1 { + text-align: center; - .spacer-for-narrow { - height: 0px; + color: WHITE; + text-transform: uppercase; + letter-spacing: 0.4em; + } - @media (max-width: $breakpointSmall) { - height: 475px; - } - } + .divider { + height: 1px; + max-width: 600px; + margin: 16px 48px; - .upsell { - margin: auto; - margin-top: 122px; - margin-bottom: 122px; + background: #252525; + } - max-width: 600px; - padding-left: 24px; - padding-right: 24px; + .subtitle { + text-align: center; - @media (max-width: $breakpointSmall) { - margin-bottom: 72px; + color: #949494; + text-transform: uppercase; + letter-spacing: 0.2em; + } } - font-size: 36px; - font-weight: bold; - font-style: italic; + #episodePlayer { + width: 600px; - text-shadow: 0px 5px #3BA3D0; + #playerContainer { + background: WHITE; + border-radius: 6px; + } + } - white-space: pre-line; - } + #nextShowCalendar { + text-align: center; - .hosts { - margin-bottom: 90px; + h3 { + margin: 0; - text-align: center; + text-align: left; - .title { - font-size: 20px; - font-weight: bold; - text-shadow: none; - } + color: #3C3C3C; + font-size: 12px; + font-weight: bold; + text-transform: uppercase; + } - .host-container { - display: grid; - grid-template-columns: repeat(17, 10px); - grid-template-rows: 1fr; + #nextShowDate { + margin: 0; + margin-bottom: 8px; - margin: auto; - width: 170px; + color: #656565; + font-size: 14px; + font-weight: bold; + } + + a { + color: #4DBEFF; + font-size: 12px; + text-transform: uppercase; + text-decoration: none; + } } - .host { - width: 90px; + #attribution { + margin-bottom: 48px; - font-size: 14px; + color: #394B5D; + font-size: 12px; font-weight: bold; - p { - margin-bottom: 0; + a { + color: #4DBEFF; + text-decoration: none; } } + } - .host.matt { - grid-column: 0; - z-index: 1; - } - .host.ray { - grid-column: 9; + #episodeDirectoryPane { + width: 300px; + padding: 8px; + + #episodeDirectoryWindow { + height: 100%; + + background: WHITE; + border-radius: 8px; + padding: 8px; + + overflow-y: auto; + + .episode-list-item { + position: relative; + padding-top: 4px; + padding-bottom: 4px; + + &:not(:last-child)::after { + content: ""; /* Creates the divider */ + position: absolute; + bottom: 0; /* Position it at the bottom of the list item */ + left: 12px; /* Add padding to the left of the divider */ + right: 12px; /* Add padding to the right of the divider */ + height: 1px; /* Thickness of the divider */ + background-color: #EEE; /* Divider color */ + } + + & > .content { + padding: 8px; + border-radius: 4px; + + cursor: pointer; + + h4 { + margin-bottom: 0.2em; + + color: BLACK; + font-size: 13px; + font-weight: bold; + } + + .date { + margin-bottom: 0; + + color: #A7A7A7; + font-size: 10px; + } + } + + &:hover > .content { + background: #EEEEEE; + } + + &.selected > .content { + background: #4CBBF7; + + cursor: default; + + h4 { + color: WHITE; + } + + .date { + color: #2A8EC4; + } + } + } } } - } - .background { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; + } // End media query for aspect ratio +} // End "body" +//-------- END LANDSCAPE LAYOUT ----------// + + + +// ----------------------------------------------------------- - z-index: 1; - @media (max-width: $breakpointSmall) { - top: 96px; + +//---------- PORTRAIT LAYOUT ----------// +body { + @media (aspect-ratio <= 1) { + + main { + display: flex; + flex-direction: column; + width: 100vw; } - .waves-and-phone { + #mainPane { position: relative; display: flex; - flex-flow: row; + flex-direction: column; + align-items: center; - margin-top: 96px; + padding: 0 48px; - .sound-waves-left { - flex: 6; + header { + text-align: center; - background-image: url("/images/homepage/sound-wave_left.png"); - background-repeat: repeat no-repeat; - background-position: right; - } + #mascot { + margin-top: 24px; + margin-bottom: 20px; + height: 128px; + opacity: 0.5; + } - .sound-waves-right { - flex: 1; + h1 { + text-align: center; - background-image: url("/images/homepage/sound-wave_right.png"); - background-repeat: repeat no-repeat; - background-position: left; - } + color: WHITE; + text-transform: uppercase; + letter-spacing: 0.4em; + } + + .divider { + height: 1px; + max-width: 600px; + margin: 16px 48px; - @media (max-width: $breakpointWide) { - .sound-waves-left { - flex: 10; + background: #252525; } - } - @media (max-width: $breakpointMedium) { - .sound-waves-left { - flex: 40; + .subtitle { + text-align: center; + + color: #949494; + text-transform: uppercase; + letter-spacing: 0.2em; } } - @media (max-width: $breakpointSmall) { - .sound-waves-left { - flex: 1; + #episodePlayer { + position: fixed; + left: 0; + right: 0; + bottom: 0; + + background: WHITE; + border-top: 2px solid #DDDDDD; + + z-index: 9999; + + #playerContainer { + background: WHITE; + border-radius: 0px; } } - .phone { - position: relative; + #nextShowCalendar { + margin-bottom: 24px; - padding-left: 24px; - padding-right: 24px; + text-align: center; - .next-show-date { - position: absolute; - top: 64px; - left: 0px; - width: 330px; + h3 { + margin: 0; - padding-top: 8px; - padding-bottom: 8px; + text-align: left; - background: WHITE; - border-radius: 12px; + color: #3C3C3C; + font-size: 12px; + font-weight: bold; + text-transform: uppercase; + } - p.title { - margin: 0; - margin-bottom: 0.25em; + #nextShowDate { + margin: 0; + margin-bottom: 8px; - color: #BDB3E5; - font-size: 12px; - font-weight: bold; - } - - p.date { - margin: 0; + color: #656565; + font-size: 14px; + font-weight: bold; + text-align: left; + } - color: #7856FF; - font-size: 18px; - font-weight: bold; - line-height: 1.2em; - } + a { + color: #4DBEFF; + font-size: 12px; + text-transform: uppercase; + text-decoration: none; } } + #attribution { + margin-bottom: 48px; + + color: #394B5D; + font-size: 12px; + font-weight: bold; + + a { + color: #4DBEFF; + text-decoration: none; + } + } } - } -} -#x-divider { - .logo { - display: block; - margin: auto; - } -} + #episodeDirectoryPane { + width: 100%; + + #episodeDirectoryWindow { + background: WHITE; + padding: 8px; + + .episode-list-item { + position: relative; + padding-top: 4px; + padding-bottom: 4px; + + &:not(:last-child)::after { + content: ""; /* Creates the divider */ + position: absolute; + bottom: 0; /* Position it at the bottom of the list item */ + left: 12px; /* Add padding to the left of the divider */ + right: 12px; /* Add padding to the right of the divider */ + height: 1px; /* Thickness of the divider */ + background-color: #EEE; /* Divider color */ + } -.divider { - width: 100%; - height: 2px; - background: #77C4E5; - border: none; -} + & > .content { + padding: 8px; + border-radius: 4px; -.follow-hosts { - margin-top: 24px; + cursor: pointer; - text-align: center; + h4 { + margin-bottom: 0.2em; - color: #87DBFF; - font-size: 18px; - font-weight: bold; + color: BLACK; + font-size: 13px; + font-weight: bold; + } - a { - color: WHITE; - } -} + .date { + margin-bottom: 0; -#post-timeline { - padding-top: 48px; - padding-bottom: 48px; - padding-left: 24px; - padding-right: 24px; + color: #A7A7A7; + font-size: 10px; + } + } - text-align: center; + &:hover > .content { + background: #EEEEEE; + } - .twitter-tweet { - margin: auto; - margin-bottom: 48px !important; - } -} + &.selected > .content { + background: #4CBBF7; -footer { - text-align: center; - - h4 { - color: #87D7FA; - font-size: 12px; - font-weight: bold; - } - - a { - display: inline-block; - @media (max-width: $breakpointSmall) { - display: block; - margin: auto; - margin-bottom: 1em; - } + cursor: default; - width: 175px; - text-align: center; + h4 { + color: WHITE; + } + + .date { + color: #2A8EC4; + } + } + } + } + } - color: WHITE; - font-size: 14px; - font-weight: bold; - } -} \ No newline at end of file + } // End media query for aspect ratio +} // End "body" +//-------- END LANDSCAPE LAYOUT ----------// \ No newline at end of file