Merge branch 'feed-plugin' into 'master'

Creating feed plugin

See merge request jaywll/ja.son-williams.ca!1
This commit is contained in:
Jason Williams 2020-10-23 22:54:30 +00:00
commit 98ff78321b
71 changed files with 1753 additions and 88 deletions

View File

@ -0,0 +1,39 @@
<?php namespace jasonwilliams\feed;
use System\Classes\PluginBase;
use JasonWilliams\Feed\Classes\SocialApis;
class Plugin extends PluginBase
{
public function registerSettings()
{
return [
'settings' => [
'label' => 'Feed',
'description' => 'Manage API keys and settings related to the feed plugin',
'icon' => 'icon-newspaper-o',
'class' => 'JasonWilliams\Feed\Models\Settings',
'order' => 500,
]
];
}
public function registerComponents()
{
return [
'\JasonWilliams\Feed\Components\ShortFeed' => 'ShortFeed',
'\JasonWilliams\Feed\Components\LazyLoadShortFeed' => 'LazyLoadShortFeed',
'\JasonWilliams\Feed\Components\ChannelList' => 'ChannelList',
'\JasonWilliams\Feed\Components\TagList' => 'TagList'
];
}
public function registerSchedule($schedule)
{
$schedule->call(function() {
SocialApis::updateTwitter();
SocialApis::updateInstagram();
SocialApis::updateFoursquare();
})->everyThirtyMinutes();
}
}

View File

@ -0,0 +1,10 @@
$(() => {
var obj = {};
if (typeof renderPartial !== 'string') obj = { '@feed': '#feedarea' }
else obj[renderPartial] = '#feedarea'
$.request('LazyLoadShortFeed::onUpdateRequested', {
update: obj,
complete: () => { $(window).trigger('feedLoaded') }
})
})

View File

@ -0,0 +1,287 @@
<?php namespace JasonWilliams\Feed\Classes;
use JasonWilliams\Feed\Models\Settings;
use JasonWilliams\Feed\Models\FeedItem;
use October\Rain\Network\Http;
class SocialApis
{
// Update the database with the latest tweets
public static function updateTwitter()
{
$tweets = self::fetchTwitter();
if (sizeof($tweets) > 0)
{
foreach ($tweets as $tweet)
{
// Skip tweets created by IFTTT if they have an image attached (because they came from Instagram)
if (!isset($tweet['retweet_status']) && strpos($tweet['source'], "IFTTT") && isset($tweet['entities']['media'])) continue;
// Skip any tweets created by Foursquare
if (!isset($tweet['retweet_status']) && strpos($tweet['source'], "Foursquare")) continue;
$extradata = array();
// Get the timestamp before we go down a layer to retweets
$timestamp = strtotime($tweet['created_at']);
// Set extra data if this is a retweet
if (isset($tweet['retweeted_status']))
{
$tweet = $tweet['retweeted_status'];
$extradata['opuser'] = $tweet['user']['screen_name'];
$extradata['opname'] = $tweet['user']['name'];
$extradata['opimg'] = $tweet['user']['profile_image_url_https'];
}
// Set extra data if there's an attached image
if (isset($tweet['entities']['media'])) $extradata['img'] = $tweet['entities']['media'][0]['media_url_https'];
// Convert any extra data into a JSON object
$extradata = (sizeof($extradata) > 0) ? json_encode($extradata) : "";
// Create a new FeedItem
$feeditem = new FeedItem;
$feeditem->timestamp = $timestamp;
$feeditem->channel_id = 2;
$feeditem->title = $tweet['full_text'];
$feeditem->link = "https://twitter.com/i/web/status/".$tweet['id_str'];
$feeditem->extra = $extradata;
$feeditem->save();
// Are there links to include?
if (sizeof($tweet['entities']['urls']) > 0)
{
foreach ($tweet['entities']['urls'] as $url)
{
$feeditem->links()->create([
'addr' => $url['url'],
'display' =>$url['display_url']
]);
}
}
// Are there tags to include?
if (sizeof($tweet['entities']['hashtags']))
{
foreach ($tweet['entities']['hashtags'] as $tag)
{
$feeditem->tags()->create([
'tag' => $tag['text']
]);
}
}
}
// Set the latest item ID
Settings::set('twitter_latest_tweet', $tweets[sizeof($tweets) - 1]['id_str']);
}
}
// Update the database with the latest instagram posts
public static function updateInstagram()
{
$posts = self::fetchInstagram();
if (sizeof($posts) > 0)
{
foreach ($posts as $post)
{
// Skip videos and albums
if ($post['media_type'] != "IMAGE") continue;
// Create a new FeedItem
$feeditem = new FeedItem;
$feeditem->timestamp = strtotime($post['timestamp']);
$feeditem->channel_id = 3;
$feeditem->title = (isset($post['caption'])) ? $post['caption'] : "";
$feeditem->link = $post['permalink'];
$feeditem->extra = json_encode(['img' => $post['media_url']]);
$feeditem->save();
// Are there tags to include?
if (isset($post['caption']) && strpos($post['caption'], '#'))
{
preg_match_all('%\B#\w*[a-zA-Z]+\w*%', $post['caption'], $hashtags);
foreach ($hashtags[0] as $tag)
{
$feeditem->tags()->create([
'tag' => substr($tag, 1)
]);
}
}
}
// Set the latest item ID
Settings::set('instagram_latest_post', strtotime(end($posts)['timestamp']));
}
}
// Update the database with the latest foursquare posts
public static function updateFoursquare()
{
$posts = self::fetchFoursquare();
if (sizeof($posts) > 0)
{
foreach ($posts as $post)
{
$extradata = [
'lat' => $post['venue']['location']['lat'],
'lng' => $post['venue']['location']['lng'],
'address' => []
];
// Build the address
if (isset($post['venue']['location']['address'])) $extradata['address'][] = $post['venue']['location']['address'];
if (isset($post['venue']['location']['city'])) $extradata['address'][] = $post['venue']['location']['city'];
if (isset($post['venue']['location']['country'])) $extradata['address'][] = $post['venue']['location']['country'];
$extradata['address'] = implode(', ', $extradata['address']);
// Create a new FeedItem
$feeditem = new FeedItem;
$feeditem->timestamp = $post['createdAt'];
$feeditem->channel_id = 6;
$feeditem->title = $post['venue']['name'];
$feeditem->body = (isset($post['shout'])) ? $post['shout'] : "";
$feeditem->extra = json_encode($extradata);
$feeditem->save();
// Are there tags to include?
if (isset($post['shout']) && strpos($post['shout'], '#'))
{
preg_match_all('%\B#\w*[a-zA-Z]+\w*%', $post['shout'], $hashtags);
foreach ($hashtags[0] as $tag)
{
$feeditem->tags()->create([
'tag' => substr($tag, 1)
]);
}
}
}
// Set the latest item ID
Settings::set('foursquare_latest_post', end($posts)['createdAt']);
}
}
// Fetch and return the latest tweets
private static function fetchTwitter()
{
// Set the API endpoint
$endpoint = "https://api.twitter.com/1.1/statuses/user_timeline.json";
// Set request parameteres
$req_params = array(
'screen_name' => Settings::get('twitter_username'),
'count' => 200,
'since_id' => Settings::get('twitter_latest_tweet'),
'tweet_mode' => 'extended',
'exclude_replies' => true,
'include_rts' => true
);
// Set oauth parameters
self::$authobject = array(
'oauth_token' => Settings::get('twitter_access_token'),
'oauth_consumer_key' => Settings::get('twitter_consumer_key'),
'oauth_nonce' => time(),
'oauth_signature_method' => 'HMAC-SHA1',
'oauth_timestamp' => time(),
'oauth_version' => '1.0'
);
// Add parameters to oauth array
self::$authobject = array_merge(self::$authobject, $req_params);
// Generate the oauth signautre
$base_string = self::buildBaseString($endpoint, "GET", self::$authobject);
$composite_key = rawurlencode(Settings::get('twitter_consumer_secret'))."&".rawurlencode(Settings::get('twitter_access_token_secret'));
self::$authobject['oauth_signature'] = base64_encode(hash_hmac('sha1', $base_string, $composite_key, true));
// Create the URL by adding the parameters to the endpoint
$endpoint = $endpoint."?".http_build_query($req_params);
$tweets = array_reverse(json_decode(Http::get($endpoint, function($http)
{
$http->header(self::buildAuthorizationHeader(self::$authobject));
}), true));
return $tweets;
}
// Fetch and return the latest instagram posts
private static function fetchInstagram()
{
$endpoint = "https://graph.instagram.com/me/media";
// Set the fields we want back
$fields = [
'id',
'ig-id',
'caption',
'media_type',
'media_url',
'permalink',
'timestamp'
];
// Create the URL by adding the parameters to the endpoint
$endpoint = $endpoint."?".http_build_query([
'access_token' => Settings::get('instagram_access_token'),
'fields' => implode(',', $fields),
'limit' => 100
]);
$posts = json_decode(Http::get($endpoint), true);
$posts = array_reverse($posts['data']);
foreach($posts as $postkey => $post)
{
if (strtotime($post['timestamp']) <= Settings::get('instagram_latest_post')) unset($posts[$postkey]);
}
return $posts;
}
// Fetch and return the latest foursquare checkins
private static function fetchFoursquare()
{
$endpoint = "https://api.foursquare.com/v2/users/self/checkins";
// Create the URL by adding the parameters to the endpoint
$endpoint = $endpoint."?".http_build_query([
'oauth_token' => Settings::get('foursquare_access_token'),
'v' => date('Ymd'),
'limit' => 250,
'sort' => 'oldestfirst',
'afterTimestamp' => Settings::get('foursquare_latest_post') + 1
]);
$posts = json_decode(Http::get($endpoint), true);
return $posts['response']['checkins']['items'];
}
// Function to build a base string for the twitter API
private static function buildBaseString($baseURI, $method, $params) {
$r = array();
ksort($params);
foreach ($params as $key => $value) {
$r[] = $key.'='.rawurlencode($value);
}
return $method.'&'.rawurlencode($baseURI).'&'.rawurlencode(implode('&', $r));
}
// Function to build the OAuth authorization header for twitter
private static function buildAuthorizationHeader($oauth) {
$r = 'Authorization: OAuth ';
$values = array();
foreach($oauth as $key=>$value)
$values[] = $key.'="'.rawurlencode($value).'"';
$r .= implode(', ', $values);
return $r;
}
private static $authobject;
}

View File

@ -0,0 +1,41 @@
<?php namespace JasonWilliams\Feed\Components;
use Cms\Classes\ComponentBase;
use JasonWilliams\Feed\Models\Channels;
class ChannelList extends ComponentBase
{
public function componentDetails()
{
return [
'name' => 'Channel List',
'description' => 'Displays a list of feed channels.'
];
}
public function defineProperties()
{
return [
'includeEmpty' => [
'title' => 'Include Empty?',
'description' => 'Include channels with no posts?',
'type' => 'checkbox',
'default' => true
]
];
}
public function onRun()
{
$this->page['channels'] = Channels::orderBy('id')->withCount('feeditems')->get();
// If applicable, remove empty channels
if (!$this->property('includeEmpty'))
{
foreach($this->page['channels'] as $key => $value)
{
if (!$value->feeditems_count) unset($this->page['channels'][$key]);
}
}
}
}

View File

@ -0,0 +1,47 @@
<?php namespace JasonWilliams\Feed\Components;
use Cms\Classes\ComponentBase;
use JasonWilliams\Feed\Models\FeedItem;
class LazyLoadShortFeed extends ComponentBase
{
public function componentDetails()
{
return [
'name' => 'Short Feed (Lazy Load)',
'description' => 'Displays a mini-feed of the most recent feed items, retrieved after other page content has been loaded'
];
}
public function defineProperties()
{
return [
'maxItems' => [
'title' => 'Max items',
'description' => 'How many feed items should be displayed?',
'default' => 10,
'type' => 'string',
'validationPattern' => '^[0-9]+$',
'validationMessage' => 'The Max items property must be numeric'
],
'renderPartial' => [
'title' => 'Render partial',
'description' => 'The path to the partial that will be used as a template to render feed items. @feed is provided by the plugin itself.',
'default' => '@feed',
'type' => 'string'
]
];
}
public function onRun()
{
$this->page['renderPartial'] = $this->property('renderPartial');
$this->addJs('/plugins/jasonwilliams/feed/assets/javascript/lazyloadshortfeed.js');
}
public function onUpdateRequested()
{
date_default_timezone_set('America/Edmonton');
$this->page['posts'] = FeedItem::orderBy('timestamp', 'desc')->take($this->property('maxItems'))->get();
}
}

View File

@ -0,0 +1,79 @@
<?php namespace JasonWilliams\Feed\Components;
use Cms\Classes\ComponentBase;
use JasonWilliams\Feed\Models\FeedItem;
use JasonWilliams\Feed\Models\Tags;
class ShortFeed extends ComponentBase
{
private $channelfilter;
public function componentDetails()
{
return [
'name' => 'Short Feed',
'description' => 'Displays a mini-feed of the most recent feed items, loaded with other page content'
];
}
public function defineProperties()
{
return [
'channelFilter' => [
'title' => 'Channels',
'description' => 'A comma-separated list of channels to include in feed.',
'type' => 'string',
'default' => ''
],
'tagFilter' => [
'title' => 'Tag',
'description' => 'The tag to display in the feed.',
'type' => 'string',
'default' => ''
],
'maxItems' => [
'title' => 'Max items',
'description' => 'How many feed items should be displayed?',
'default' => 10,
'type' => 'string',
'validationPattern' => '^[0-9]+$',
'validationMessage' => 'The Max items property must be numeric'
]
];
}
public function onRun()
{
date_default_timezone_set('America/Edmonton');
// Set up the results query
$results = FeedItem::orderBy('timestamp', 'desc')->take($this->property('maxItems'));
// Do we need to filter based on a tag?
if ($this->property('tagFilter') != null)
{
$results->join('jasonwilliams_feed_tags', 'jasonwilliams_feed_.id', '=', 'jasonwilliams_feed_tags.feed_item_id');
$results->where('tag', $this->property('tagFilter'));
}
// Do we need to filter based on the channel?
if ($this->property('channelFilter') != null && $this->property('channelFilter') != 'all')
{
$this->channelfilter = explode(',', $this->property('channelFilter'));
$results->whereHas('channel', function($q) {
$firstchannel = true;
foreach ($this->channelfilter as $channel)
{
if ($firstchannel) $q->where('slug', $channel);
else $q->orWhere('slug', $channel);
$firstchannel = false;
}
});
}
$this->page['posts'] = $results->get();
}
}

View File

@ -0,0 +1,26 @@
<?php namespace JasonWilliams\Feed\Components;
use Db;
use Cms\Classes\ComponentBase;
use JasonWilliams\Feed\Models\Tags;
class TagList extends ComponentBase
{
public function componentDetails()
{
return [
'name' => 'Tag List',
'description' => 'Displays a list of feed tags.'
];
}
public function defineProperties()
{
return [];
}
public function onRun()
{
$this->page['tags'] = Tags::groupBy('tag')->select(Db::raw('tag, count(*) as count'))->orderBy('count', 'desc')->get();
}
}

View File

@ -0,0 +1,5 @@
<ul>
{% for channel in channels %}
<li><a href="/feed/{{ channel.slug }}">{{ channel.title }}</a></li>
{% endfor %}
</ul>

View File

@ -0,0 +1,4 @@
<div id="feedarea">Loading...</div>
{% put scripts %}
<script type="text/javascript">const renderPartial = '{{ renderPartial }}'</script>
{% endput %}

View File

@ -0,0 +1,9 @@
<ul>
{% for post in posts %}
{% if post.title %}
<li>{{ post.title | raw }}</li>
{% else %}
<li>Post</li>
{% endif %}
{% endfor %}
</ul>

View File

@ -0,0 +1,9 @@
<ul>
{% for post in posts %}
{% if post.title %}
<li>{{ post.title | raw }}</li>
{% else %}
<li>Post</li>
{% endif %}
{% endfor %}
</ul>

View File

@ -0,0 +1,5 @@
<ul>
{% for tag in tags %}
<li><a href="{{ tag.tag }}">{{ tag.tag }}</a></li>
{% endfor %}
</ul>

View File

@ -0,0 +1,19 @@
<?php namespace jasonwilliams\feed\Controllers;
use Backend\Classes\Controller;
use BackendMenu;
class Channels extends Controller
{
public $implement = [ 'Backend\Behaviors\ListController', 'Backend\Behaviors\FormController', 'Backend\Behaviors\ReorderController' ];
public $listConfig = 'config_list.yaml';
public $formConfig = 'config_form.yaml';
public $reorderConfig = 'config_reorder.yaml';
public function __construct()
{
parent::__construct();
BackendMenu::setContext('jasonwilliams.feed', 'main-menu-item');
}
}

View File

@ -0,0 +1,19 @@
<?php namespace jasonwilliams\feed\Controllers;
use Backend\Classes\Controller;
use BackendMenu;
class Feed extends Controller
{
public $implement = [ 'Backend\Behaviors\ListController', 'Backend\Behaviors\FormController', 'Backend\Behaviors\ReorderController' ];
public $listConfig = 'config_list.yaml';
public $formConfig = 'config_form.yaml';
public $reorderConfig = 'config_reorder.yaml';
public function __construct()
{
parent::__construct();
BackendMenu::setContext('jasonwilliams.feed', 'main-menu-item');
}
}

View File

@ -0,0 +1,19 @@
<div data-control="toolbar">
<a href="<?= Backend::url('jasonwilliams/feed/channels/create') ?>" class="btn btn-primary oc-icon-plus"><?= e(trans('backend::lang.form.create')) ?></a>
<a href="<?= Backend::url('jasonwilliams/feed/channels/reorder') ?>" class="btn btn-default oc-icon-list"><?= e(trans('backend::lang.reorder.default_title')) ?></a>
<button
class="btn btn-default oc-icon-trash-o"
disabled="disabled"
onclick="$(this).data('request-data', {
checked: $('.control-list').listWidget('getChecked')
})"
data-request="onDelete"
data-request-confirm="<?= e(trans('backend::lang.list.delete_selected_confirm')) ?>"
data-trigger-action="enable"
data-trigger=".control-list input[type=checkbox]"
data-trigger-condition="checked"
data-request-success="$(this).prop('disabled', true)"
data-stripe-load-indicator>
<?= e(trans('backend::lang.list.delete_selected')) ?>
</button>
</div>

View File

@ -0,0 +1,3 @@
<div data-control="toolbar">
<a href="<?= Backend::url('jasonwilliams/feed/channels') ?>" class="btn btn-primary oc-icon-caret-left"><?= e(trans('backend::lang.form.return_to_list')) ?></a>
</div>

View File

@ -0,0 +1,10 @@
name: Channels
form: $/jasonwilliams/feed/models/channels/fields.yaml
modelClass: jasonwilliams\feed\Models\Channels
defaultRedirect: jasonwilliams/feed/channels
create:
redirect: 'jasonwilliams/feed/channels/update/:id'
redirectClose: jasonwilliams/feed/channels
update:
redirect: jasonwilliams/feed/channels
redirectClose: jasonwilliams/feed/channels

View File

@ -0,0 +1,12 @@
title: Channels
modelClass: jasonwilliams\feed\Models\Channels
list: $/jasonwilliams/feed/models/channels/columns.yaml
recordUrl: 'jasonwilliams/feed/channels/update/:id'
noRecordsMessage: 'backend::lang.list.no_records'
recordsPerPage: 20
showSetup: true
showCheckboxes: true
toolbar:
buttons: list_toolbar
search:
prompt: 'backend::lang.list.search_prompt'

View File

@ -0,0 +1,4 @@
title: Channels
modelClass: jasonwilliams\feed\Models\Channels
toolbar:
buttons: reorder_toolbar

View File

@ -0,0 +1,46 @@
<?php Block::put('breadcrumb') ?>
<ul>
<li><a href="<?= Backend::url('jasonwilliams/feed/channels') ?>">Channels</a></li>
<li><?= e($this->pageTitle) ?></li>
</ul>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?= Form::open(['class' => 'layout']) ?>
<div class="layout-row">
<?= $this->formRender() ?>
</div>
<div class="form-buttons">
<div class="loading-indicator-container">
<button
type="submit"
data-request="onSave"
data-hotkey="ctrl+s, cmd+s"
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
class="btn btn-primary">
<?= e(trans('backend::lang.form.create')) ?>
</button>
<button
type="button"
data-request="onSave"
data-request-data="close:1"
data-hotkey="ctrl+enter, cmd+enter"
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
class="btn btn-default">
<?= e(trans('backend::lang.form.create_and_close')) ?>
</button>
<span class="btn-text">
<?= e(trans('backend::lang.form.or')) ?> <a href="<?= Backend::url('jasonwilliams/feed/channels') ?>"><?= e(trans('backend::lang.form.cancel')) ?></a>
</span>
</div>
</div>
<?= Form::close() ?>
<?php else: ?>
<p class="flash-message static error"><?= e(trans($this->fatalError)) ?></p>
<p><a href="<?= Backend::url('jasonwilliams/feed/channels') ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')) ?></a></p>
<?php endif ?>

View File

@ -0,0 +1 @@
<?= $this->listRender() ?>

View File

@ -0,0 +1,22 @@
<?php Block::put('breadcrumb') ?>
<ul>
<li><a href="<?= Backend::url('jasonwilliams/feed/channels') ?>">Channels</a></li>
<li><?= e($this->pageTitle) ?></li>
</ul>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<div class="form-preview">
<?= $this->formRenderPreview() ?>
</div>
<?php else: ?>
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
<?php endif ?>
<p>
<a href="<?= Backend::url('jasonwilliams/feed/channels') ?>" class="btn btn-default oc-icon-chevron-left">
<?= e(trans('backend::lang.form.return_to_list')) ?>
</a>
</p>

View File

@ -0,0 +1,8 @@
<?php Block::put('breadcrumb') ?>
<ul>
<li><a href="<?= Backend::url('jasonwilliams/feed/channels') ?>">Channels</a></li>
<li><?= e($this->pageTitle) ?></li>
</ul>
<?php Block::endPut() ?>
<?= $this->reorderRender() ?>

View File

@ -0,0 +1,54 @@
<?php Block::put('breadcrumb') ?>
<ul>
<li><a href="<?= Backend::url('jasonwilliams/feed/channels') ?>">Channels</a></li>
<li><?= e($this->pageTitle) ?></li>
</ul>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?= Form::open(['class' => 'layout']) ?>
<div class="layout-row">
<?= $this->formRender() ?>
</div>
<div class="form-buttons">
<div class="loading-indicator-container">
<button
type="submit"
data-request="onSave"
data-request-data="redirect:0"
data-hotkey="ctrl+s, cmd+s"
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
class="btn btn-primary">
<?= e(trans('backend::lang.form.save')) ?>
</button>
<button
type="button"
data-request="onSave"
data-request-data="close:1"
data-hotkey="ctrl+enter, cmd+enter"
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
class="btn btn-default">
<?= e(trans('backend::lang.form.save_and_close')) ?>
</button>
<button
type="button"
class="oc-icon-trash-o btn-icon danger pull-right"
data-request="onDelete"
data-load-indicator="<?= e(trans('backend::lang.form.deleting')) ?>"
data-request-confirm="<?= e(trans('backend::lang.form.confirm_delete')) ?>">
</button>
<span class="btn-text">
<?= e(trans('backend::lang.form.or')) ?> <a href="<?= Backend::url('jasonwilliams/feed/channels') ?>"><?= e(trans('backend::lang.form.cancel')) ?></a>
</span>
</div>
</div>
<?= Form::close() ?>
<?php else: ?>
<p class="flash-message static error"><?= e(trans($this->fatalError)) ?></p>
<p><a href="<?= Backend::url('jasonwilliams/feed/channels') ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')) ?></a></p>
<?php endif ?>

View File

@ -0,0 +1,19 @@
<div data-control="toolbar">
<a href="<?= Backend::url('jasonwilliams/feed/feed/create') ?>" class="btn btn-primary oc-icon-plus"><?= e(trans('backend::lang.form.create')) ?></a>
<a href="<?= Backend::url('jasonwilliams/feed/feed/reorder') ?>" class="btn btn-default oc-icon-list"><?= e(trans('backend::lang.reorder.default_title')) ?></a>
<button
class="btn btn-default oc-icon-trash-o"
disabled="disabled"
onclick="$(this).data('request-data', {
checked: $('.control-list').listWidget('getChecked')
})"
data-request="onDelete"
data-request-confirm="<?= e(trans('backend::lang.list.delete_selected_confirm')) ?>"
data-trigger-action="enable"
data-trigger=".control-list input[type=checkbox]"
data-trigger-condition="checked"
data-request-success="$(this).prop('disabled', true)"
data-stripe-load-indicator>
<?= e(trans('backend::lang.list.delete_selected')) ?>
</button>
</div>

View File

@ -0,0 +1,3 @@
<div data-control="toolbar">
<a href="<?= Backend::url('jasonwilliams/feed/feed') ?>" class="btn btn-primary oc-icon-caret-left"><?= e(trans('backend::lang.form.return_to_list')) ?></a>
</div>

View File

@ -0,0 +1,10 @@
name: Feed
form: $/jasonwilliams/feed/models/feeditem/fields.yaml
modelClass: jasonwilliams\feed\Models\FeedItem
defaultRedirect: jasonwilliams/feed/feed
create:
redirect: 'jasonwilliams/feed/feed/update/:id'
redirectClose: jasonwilliams/feed/feed
update:
redirect: jasonwilliams/feed/feed
redirectClose: jasonwilliams/feed/feed

View File

@ -0,0 +1,12 @@
title: Feed
modelClass: jasonwilliams\feed\Models\FeedItem
list: $/jasonwilliams/feed/models/feeditem/columns.yaml
recordUrl: 'jasonwilliams/feed/feed/update/:id'
noRecordsMessage: 'backend::lang.list.no_records'
recordsPerPage: 20
showSetup: true
showCheckboxes: true
toolbar:
buttons: list_toolbar
search:
prompt: 'backend::lang.list.search_prompt'

View File

@ -0,0 +1,4 @@
title: Feed
modelClass: jasonwilliams\feed\Models\FeedItem
toolbar:
buttons: reorder_toolbar

View File

@ -0,0 +1,46 @@
<?php Block::put('breadcrumb') ?>
<ul>
<li><a href="<?= Backend::url('jasonwilliams/feed/feed') ?>">Feed</a></li>
<li><?= e($this->pageTitle) ?></li>
</ul>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?= Form::open(['class' => 'layout']) ?>
<div class="layout-row">
<?= $this->formRender() ?>
</div>
<div class="form-buttons">
<div class="loading-indicator-container">
<button
type="submit"
data-request="onSave"
data-hotkey="ctrl+s, cmd+s"
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
class="btn btn-primary">
<?= e(trans('backend::lang.form.create')) ?>
</button>
<button
type="button"
data-request="onSave"
data-request-data="close:1"
data-hotkey="ctrl+enter, cmd+enter"
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
class="btn btn-default">
<?= e(trans('backend::lang.form.create_and_close')) ?>
</button>
<span class="btn-text">
<?= e(trans('backend::lang.form.or')) ?> <a href="<?= Backend::url('jasonwilliams/feed/feed') ?>"><?= e(trans('backend::lang.form.cancel')) ?></a>
</span>
</div>
</div>
<?= Form::close() ?>
<?php else: ?>
<p class="flash-message static error"><?= e(trans($this->fatalError)) ?></p>
<p><a href="<?= Backend::url('jasonwilliams/feed/feed') ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')) ?></a></p>
<?php endif ?>

View File

@ -0,0 +1 @@
<?= $this->listRender() ?>

View File

@ -0,0 +1,22 @@
<?php Block::put('breadcrumb') ?>
<ul>
<li><a href="<?= Backend::url('jasonwilliams/feed/feed') ?>">Feed</a></li>
<li><?= e($this->pageTitle) ?></li>
</ul>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<div class="form-preview">
<?= $this->formRenderPreview() ?>
</div>
<?php else: ?>
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
<?php endif ?>
<p>
<a href="<?= Backend::url('jasonwilliams/feed/feed') ?>" class="btn btn-default oc-icon-chevron-left">
<?= e(trans('backend::lang.form.return_to_list')) ?>
</a>
</p>

View File

@ -0,0 +1,8 @@
<?php Block::put('breadcrumb') ?>
<ul>
<li><a href="<?= Backend::url('jasonwilliams/feed/feed') ?>">Feed</a></li>
<li><?= e($this->pageTitle) ?></li>
</ul>
<?php Block::endPut() ?>
<?= $this->reorderRender() ?>

View File

@ -0,0 +1,54 @@
<?php Block::put('breadcrumb') ?>
<ul>
<li><a href="<?= Backend::url('jasonwilliams/feed/feed') ?>">Feed</a></li>
<li><?= e($this->pageTitle) ?></li>
</ul>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?= Form::open(['class' => 'layout']) ?>
<div class="layout-row">
<?= $this->formRender() ?>
</div>
<div class="form-buttons">
<div class="loading-indicator-container">
<button
type="submit"
data-request="onSave"
data-request-data="redirect:0"
data-hotkey="ctrl+s, cmd+s"
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
class="btn btn-primary">
<?= e(trans('backend::lang.form.save')) ?>
</button>
<button
type="button"
data-request="onSave"
data-request-data="close:1"
data-hotkey="ctrl+enter, cmd+enter"
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
class="btn btn-default">
<?= e(trans('backend::lang.form.save_and_close')) ?>
</button>
<button
type="button"
class="oc-icon-trash-o btn-icon danger pull-right"
data-request="onDelete"
data-load-indicator="<?= e(trans('backend::lang.form.deleting')) ?>"
data-request-confirm="<?= e(trans('backend::lang.form.confirm_delete')) ?>">
</button>
<span class="btn-text">
<?= e(trans('backend::lang.form.or')) ?> <a href="<?= Backend::url('jasonwilliams/feed/feed') ?>"><?= e(trans('backend::lang.form.cancel')) ?></a>
</span>
</div>
</div>
<?= Form::close() ?>
<?php else: ?>
<p class="flash-message static error"><?= e(trans($this->fatalError)) ?></p>
<p><a href="<?= Backend::url('jasonwilliams/feed/feed') ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')) ?></a></p>
<?php endif ?>

View File

@ -0,0 +1,6 @@
<?php return [
'plugin' => [
'name' => 'Social Feed',
'description' => 'Captures social media posts from a variety of services and consolidates them into a single feed.'
]
];

View File

@ -0,0 +1,33 @@
<?php namespace jasonwilliams\feed\Models;
use Model;
/**
* Model
*/
class Channels extends Model
{
use \October\Rain\Database\Traits\Validation;
/*
* Disable timestamps by default.
* Remove this line if timestamps are defined in the database table.
*/
public $timestamps = false;
/**
* @var string The database table used by the model.
*/
public $table = 'jasonwilliams_feed_channels';
/**
* @var array Validation rules
*/
public $rules = [
];
public $hasMany = [
'feeditems' => ['jasonwilliams\feed\Models\FeedItem', 'key' => 'channel_id']
];
}

View File

@ -0,0 +1,65 @@
<?php namespace jasonwilliams\feed\Models;
use Model;
/**
* Model
*/
class FeedItem extends Model
{
use \October\Rain\Database\Traits\Validation;
/*
* Disable timestamps by default.
* Remove this line if timestamps are defined in the database table.
*/
public $timestamps = false;
/**
* @var string The database table used by the model.
*/
public $table = 'jasonwilliams_feed_';
/**
* @var array Validation rules
*/
public $rules = [
];
public $hasMany = [
'links' => 'jasonwilliams\feed\Models\Links',
'tags' => 'jasonwilliams\feed\Models\Tags'
];
public $belongsTo = [
'channel' => ['jasonwilliams\feed\Models\Channels', 'key' => 'channel_id']
];
public function afterFetch()
{
// Add clickable hashtags to the title field
$this->title = preg_replace('/#(\w*[a-zA-Z]+\w*)/', '<a href="/feed/all/$1">#$1</a>', $this->title);
// Add a friendlytime field
$this->friendlytime = date('M j, Y', $this->timestamp);
// Expand extras to an object
if ($this->extra) $this->extra = json_decode($this->extra);
// Add twitter user links
if ($this->channel_id == 2) $this->title = preg_replace('/@([a-zA-Z0-9_]+)/', '<a href="http://twitter.com/$1">@$1</a>', $this->title);
// Add instagram user links
if ($this->channel_id == 3) $this->title = preg_replace('/@([a-zA-Z0-9_]+)/', '<a href="https://www.instagram.com/$1">@$1</a>', $this->title);
// Add foursquare user Links
if ($this->channel_id == 6 && !empty($this->body)) $this->body = preg_replace('/@([a-zA-Z0-9_]+)/', '<a href="https://www.foursquare.com/$1">@$1</a>', $this->body);
// Find and replace links
foreach($this->links as $link)
{
$this->title = str_replace($link->addr, "<a href=\"".$link->addr."\">".$link->display."</a>", $this->title);
}
}
}

View File

@ -0,0 +1,35 @@
<?php namespace jasonwilliams\feed\Models;
use Model;
/**
* Model
*/
class Links extends Model
{
use \October\Rain\Database\Traits\Validation;
/*
* Disable timestamps by default.
* Remove this line if timestamps are defined in the database table.
*/
public $timestamps = false;
/**
* @var string The database table used by the model.
*/
public $table = 'jasonwilliams_feed_links';
protected $fillable = ['addr', 'display'];
/**
* @var array Validation rules
*/
public $rules = [
];
public $belongsTo = [
'links' => 'jasonwilliams\feed\Models\FeedItem'
];
}

View File

@ -0,0 +1,14 @@
<?php namespace JasonWilliams\Feed\Models;
use Model;
class Settings extends Model
{
public $implement = ['System.Behaviors.SettingsModel'];
// A unique code
public $settingsCode = 'jasonwilliams_feed_settings';
// Reference to field configuration
public $settingsFields = 'fields.yaml';
}

View File

@ -0,0 +1,40 @@
<?php namespace jasonwilliams\feed\Models;
use Model;
/**
* Model
*/
class Tags extends Model
{
use \October\Rain\Database\Traits\Validation;
/*
* Disable timestamps by default.
* Remove this line if timestamps are defined in the database table.
*/
public $timestamps = false;
/**
* @var string The database table used by the model.
*/
public $table = 'jasonwilliams_feed_tags';
protected $fillable = ['tag'];
/**
* @var array Validation rules
*/
public $rules = [
];
public $belongsTo = [
'feeditem' => ['jasonwilliams\feed\Models\FeedItem', 'key' => 'feed_item_id']
];
public function afterFetch()
{
//dd($this->feeditem);
}
}

View File

@ -0,0 +1,10 @@
columns:
id:
label: 'Channel ID'
type: number
sortable: true
title:
label: Title
type: text
searchable: true
sortable: false

View File

@ -0,0 +1,21 @@
fields:
title:
label: 'Channel Title'
span: left
type: text
slug:
label: Slug
span: right
preset:
field: title
type: slug
type: text
icon:
label: Icon
span: left
type: text
comment: 'Icon code from FontAwesome'
colour:
label: 'Icon Colour'
span: auto
type: colorpicker

View File

@ -0,0 +1,9 @@
columns:
title:
label: 'Post Title'
type: text
searchable: true
timestamp:
label: 'UNIX Timestamp'
type: number
sortable: true

View File

@ -0,0 +1,20 @@
fields:
title:
label: 'Post Title'
span: full
type: text
body:
label: 'Post Body'
size: ''
span: full
type: richeditor
timestamp:
label: 'UNIX Timestamp'
span: auto
type: number
channel:
label: Channel
nameFrom: title
descriptionFrom: description
span: auto
type: relation

View File

@ -0,0 +1,51 @@
fields:
twitter_section:
label: Twitter API Settings
type: section
twitter_username:
label: Username
description: Your twitter username, excluding the @
span: left
twitter_latest_tweet:
label: Latest Tweet ID
description: The ID of the most recent tweet already fetched and stored
span: right
twitter_consumer_key:
label: Consumer Key
description: Your consumer key for accessing the twitter API
span: left
twitter_consumer_secret:
label: Consumer Key Secret
description: Your consumer key secret for accessing the twitter API
span: right
twitter_access_token:
label: Access Token
description: Your access token for accessing the twitter API
span: left
twitter_access_token_secret:
label: Access Token Secret
description: Your access token secret for accessing the twitter API
span: right
instagram_section:
label: Instagram API Settings
type: section
instagram_access_token:
label: Access Token
description: Your access token for accessing the instagram API
span: full
instagram_latest_post:
label: Latest Post Timestamp
description: The unix timestamp of the most recent post already fetched and stored
span: left
foursquare_section:
label: Foursquare API Settings
type: section
foursquare_access_token:
description: Your access token for accessing the foursquare API
span: full
foursquare_latest_post:
label: Latest Checkin Timestamp
description: The unix timestamp of the most recent checkin already fetched and stored
span: left

View File

@ -0,0 +1,20 @@
plugin:
name: 'jasonwilliams.feed::lang.plugin.name'
description: 'jasonwilliams.feed::lang.plugin.description'
author: 'Jason Williams'
icon: oc-icon-newspaper-o
homepage: 'https://ja.son-williams.ca/'
navigation:
main-menu-item:
label: Feed
url: jasonwilliams/feed/feed
icon: icon-newspaper-o
sideMenu:
side-menu-item:
label: 'Social Feed'
url: jasonwilliams/feed/feed
icon: icon-sitemap
side-menu-item2:
label: 'Social Channels'
url: jasonwilliams/feed/channels
icon: icon-sitemap

View File

@ -0,0 +1,57 @@
<?php namespace jasonwilliams\feed\Updates;
use Schema;
use October\Rain\Database\Updates\Migration;
class JasonwilliamsFeedInitialSetup extends Migration
{
public function up()
{
Schema::create('jasonwilliams_feed_', function($table)
{
$table->engine = 'InnoDB';
$table->increments('id')->unsigned();
$table->integer('timestamp')->unsigned();
$table->integer('channel_id')->unsigned();
$table->string('title', 512)->nullable();
$table->string('link', 256)->nullable();
$table->text('body')->nullable();
$table->text('extra');
});
Schema::create('jasonwilliams_feed_tags', function($table)
{
$table->engine = 'InnoDB';
$table->increments('id')->unsigned();
$table->string('tag', 128);
$table->integer('feed_item_id')->unsigned();
});
Schema::create('jasonwilliams_feed_links', function($table)
{
$table->engine = 'InnoDB';
$table->increments('id')->unsigned();
$table->string('addr', 256);
$table->string('display', 256);
$table->integer('feed_item_id')->unsigned();
});
Schema::create('jasonwilliams_feed_channels', function($table)
{
$table->engine = 'InnoDB';
$table->increments('id')->unsigned();
$table->string('title', 64);
$table->string('slug', 64);
$table->string('icon', 128)->nullable();
$table->string('colour', 7)->nullable();
});
}
public function down()
{
Schema::dropIfExists('jasonwilliams_feed_');
Schema::dropIfExists('jasonwilliams_feed_tags');
Schema::dropIfExists('jasonwilliams_feed_links');
Schema::dropIfExists('jasonwilliams_feed_channels');
}
}

View File

@ -0,0 +1,3 @@
1.0.1:
- 'Initialize plugin and create database tables.'
- jasonwilliams_feed_initial_setup.php

View File

@ -1,23 +1,7 @@
h1.master {
margin: 0;
font-family: var(--title-font);
font-size: 3.5rem;
word-spacing: -0.15em;
}
h2 {
font-size: 1.5rem;
}
h1 + h2, h2:first-child {
margin-top: 0;
}
h3 {
font-size: 1rem;
color: var(--dark-alt-accent-color);
}
section h1:first-child {
margin-top: 0;
}
@ -26,14 +10,6 @@ section + section {
margin-top: 3rem;
}
.main {
font-size: 1.2rem;
}
.card {
margin-bottom: 2rem;
}
.card ul {
margin: 0;
padding-left: 1rem;

View File

@ -1,7 +1,7 @@
/* Variables */
:root {
--main-bg-color: #E7D9D1; /*#F0E4D1; #E8D5B5; #e7d9d1;*/
--main-accent-color: #302b44;
--main-bg-color: #F2EDEA;
--main-accent-color: #302B44;
--light-accent-color: #686283;
--xlight-accent-color: #88839C;
--xxlight-accent-color: #FFFFFF;
@ -87,6 +87,22 @@ footer {
}
/* Standard element styles */
h1.master {
margin: 0;
font-family: var(--title-font);
font-size: 3.5rem;
word-spacing: -0.15em;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1rem;
color: var(--dark-alt-accent-color);
}
a {
color: var(--alt-accent-color);
text-decoration: underline;
@ -98,6 +114,10 @@ a.icon-link {
color: var(--accent-color);
}
a.btn {
text-decoration: none;
}
a:hover {
color: var(--dark-alt-accent-color);
text-decoration: none;
@ -142,6 +162,18 @@ html::-webkit-scrollbar-thumb {
color: var(--xxlight-accent-color);
}
.main, .card p.main, .card ul.main {
font-size: 1.2rem;
}
.col-container {
margin: 0 -15px;
}
.card {
margin-bottom: 2rem;
}
.social-icons {
font-size: 0;
}
@ -177,6 +209,57 @@ html::-webkit-scrollbar-thumb {
font-size: 0.9rem;
}
.card .feedcard-footer p, .card .feedcard-footer a {
margin-bottom: 0;
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.5);
text-decoration: none;
}
.card .feedcard-footer img {
height: 0.75rem;
}
.card .feedcard-footer .feedcard-itemtype {
width: 33%;
text-align: right;
}
.card .feedcard-footer .feedcard-attribution {
width: 67%;
}
.altaccent {
color: var(--alt-accent-color);
}
}
/* Tag cloud styles */
div.jqcloud {
font-size: 0.5rem;
line-height: normal;
}
div.jqcloud a {
color: inherit;
font-size: inherit;
text-decoration: none
}
div.jqcloud a:hover {
color: var(--dark-alt-accent-color);
}
div.jqcloud span.w10 {font-size:550%; color: rgba(176, 92, 113, 1);}
div.jqcloud span.w9 {font-size:500%; color: rgba(176, 92, 113, 0.95);}
div.jqcloud span.w8 {font-size:450%; color: rgba(176, 92, 113, 0.9);}
div.jqcloud span.w7 {font-size:400%; color: rgba(176, 92, 113, 0.85);}
div.jqcloud span.w6 {font-size:350%; color: rgba(176, 92, 113, 0.8);}
div.jqcloud span.w5 {font-size:300%; color: rgba(176, 92, 113, 0.75);}
div.jqcloud span.w4 {font-size:250%; color: rgba(176, 92, 113, 0.7);}
div.jqcloud span.w3 {font-size:200%; color: rgba(176, 92, 113, 0.65);}
div.jqcloud span.w2 {font-size:150%; color: rgba(176, 92, 113, 0.6);}
div.jqcloud span.w1 {font-size:100%; color: rgba(176, 92, 113, 0.55);}
div.jqcloud{overflow:hidden;position:relative}
div.jqcloud span{padding:0}

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="160px" height="20px" viewBox="0 0 128 16" xml:space="preserve"><path fill="#a8a6b1" fill-opacity="0.42" d="M6.4,4.8A3.2,3.2,0,1,1,3.2,8,3.2,3.2,0,0,1,6.4,4.8Zm12.8,0A3.2,3.2,0,1,1,16,8,3.2,3.2,0,0,1,19.2,4.8ZM32,4.8A3.2,3.2,0,1,1,28.8,8,3.2,3.2,0,0,1,32,4.8Zm12.8,0A3.2,3.2,0,1,1,41.6,8,3.2,3.2,0,0,1,44.8,4.8Zm12.8,0A3.2,3.2,0,1,1,54.4,8,3.2,3.2,0,0,1,57.6,4.8Zm12.8,0A3.2,3.2,0,1,1,67.2,8,3.2,3.2,0,0,1,70.4,4.8Zm12.8,0A3.2,3.2,0,1,1,80,8,3.2,3.2,0,0,1,83.2,4.8ZM96,4.8A3.2,3.2,0,1,1,92.8,8,3.2,3.2,0,0,1,96,4.8Zm12.8,0A3.2,3.2,0,1,1,105.6,8,3.2,3.2,0,0,1,108.8,4.8Zm12.8,0A3.2,3.2,0,1,1,118.4,8,3.2,3.2,0,0,1,121.6,4.8Z"/><g><path fill="#302b44" fill-opacity="1" d="M-42.7,3.84A4.16,4.16,0,0,1-38.54,8a4.16,4.16,0,0,1-4.16,4.16A4.16,4.16,0,0,1-46.86,8,4.16,4.16,0,0,1-42.7,3.84Zm12.8-.64A4.8,4.8,0,0,1-25.1,8a4.8,4.8,0,0,1-4.8,4.8A4.8,4.8,0,0,1-34.7,8,4.8,4.8,0,0,1-29.9,3.2Zm12.8-.64A5.44,5.44,0,0,1-11.66,8a5.44,5.44,0,0,1-5.44,5.44A5.44,5.44,0,0,1-22.54,8,5.44,5.44,0,0,1-17.1,2.56Z"/><animateTransform attributeName="transform" type="translate" values="23 0;36 0;49 0;62 0;74.5 0;87.5 0;100 0;113 0;125.5 0;138.5 0;151.5 0;164.5 0;178 0" calcMode="discrete" dur="1170ms" repeatCount="indefinite"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,17 @@
$(() => {
// Enable any tooltips on the page
$('[data-toggle="tooltip"]').tooltip()
// Enable masonry layout
$('.masonry').each(function() {
var opts = { itemSelector: '.masonry-item' }
if ($(this).hasClass('masonry-horizontal')) opts.horizontalOrder = true
$(this).masonry(opts)
})
// Resize textareas to fit their content
$('textarea').each(function() {
$(this).height($(this)[0].scrollHeight)
})
});

View File

@ -0,0 +1,45 @@
(function($) {
"use strict"; // Start of use strict
// If the feed is loaded immediadely on page load (i.e. we're not using ajax), reset the layout on each image load
$('.masonry img').each(function() {
$(this).one('load', () => {
$('.masonry').masonry()
})
})
// Apply masonry layout to feed cards after content (including images) has been loaded
$(window).on('feedLoaded', function() {
if (!feedItemClass) feedItemClass = 'col-sm-12 col-md-6 col-xl-4'
$('.masonry-item').addClass(feedItemClass)
$('.show-onfeedloaded').removeClass('d-none')
$('.masonry').masonry('reloadItems')
$('.masonry').masonry()
// Every time an image loads, reset the layout
$('.masonry img').each(function() {
$(this).one('load', () => {
$('.masonry').masonry()
})
})
})
// If there's tag data set once the page is loaded, create a tag cloud
$(function() {
if(typeof tagData !== 'undefined') {
var cloudWords = []
tagData.forEach((tag) => {
cloudWords.push({text: tag.tag, weight: tag.count, link: '/feed/all/' + tag.tag})
})
$('#tagcloud').jQCloud(cloudWords, {
autoResize: true,
removeOverflowing: true,
delay: 2
})
}
})
})(jQuery); // End of use strict

View File

@ -1,23 +1,22 @@
(function($) {
"use strict"; // Start of use strict
// Apply masonry layout to cards
$('.masonry').each(function() {
var opts = { itemSelector: '.masonry-wrapper' };
if ($(this).children().length < 6) opts.horizontalOrder = true;
$(this).masonry(opts);
});
// Add reorder icon to .card-inline
$('.card-inline').prepend('<div class="inline-icon" data-toggle="tooltip" title="Toggle bullet view"><i class="fas fa-stream"></i></div>');
$('.card-inline').prepend('<div class="inline-icon" data-toggle="tooltip" title="Toggle bullet view"><i class="fas fa-stream"></i></div>')
$('.card-inline .inline-icon').click(function() {
var that = $(this);
that.closest('.card').toggleClass('inline').closest('.masonry').masonry();
// Define a function to toggle the view
const toggleBulletView = function() {
var that = $(this)
that.closest('.card').toggleClass('inline').closest('.masonry').masonry()
setTimeout(() => {
that.tooltip('hide');
that.tooltip('hide')
}, 2000);
});
return false
}
// Add the function to clicks on the menu button, and/or taps anywhere on the card
$('.card-inline .inline-icon').on('click', toggleBulletView)
$('.card-inline').on('touchstart', toggleBulletView)
// Smooth scrolling using jQuery easing
$('a.js-scroll-trigger[href*="#"]:not([href="#"])').click(function() {

View File

@ -1,3 +0,0 @@
$(() => {
$('[data-toggle="tooltip"]').tooltip()
});

View File

@ -45,6 +45,7 @@ description = "Default Layout"
<li><a href="/resume">Résumé</a></li>
<li><a href="/feed">Feed</a></li>
<li><a href="/contact">Contact Me</a></li>
<li><a href="/pgp">PGP Public Key</a></li>
</ul>
{% component 'CurrentInfo' %}
<p>Made with <i class="fas fa-heart"></i> in #YYC<br><a href="https://www.madeinyyc.com/"><img src="{{ 'assets/images/madeinyyc.png'|theme }}"></a></p>
@ -63,6 +64,8 @@ description = "Default Layout"
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha256-OFRAJNoaD8L3Br5lglV7VyLRf0itmoBzWUoM+Sji4/8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.0/js/all.min.js" integrity="sha256-KzZiKy0DWYsnwMF+X1DvQngQ2/FxF7MF3Ff72XcpuPs=" crossorigin="anonymous"></script>
{% framework extras %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/masonry/4.2.2/masonry.pkgd.min.js"></script>
<script src="{{ 'assets/javascript/common.js' | theme }}"></script>
{% scripts %}
</body>
</html>

View File

@ -43,6 +43,8 @@ description = "Page with a Submenu"
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha256-OFRAJNoaD8L3Br5lglV7VyLRf0itmoBzWUoM+Sji4/8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.0/js/all.min.js" integrity="sha256-KzZiKy0DWYsnwMF+X1DvQngQ2/FxF7MF3Ff72XcpuPs=" crossorigin="anonymous"></script>
{% framework extras %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/masonry/4.2.2/masonry.pkgd.min.js"></script>
<script src="{{ 'assets/javascript/common.js' | theme }}"></script>
{% scripts %}
</body>
</html>

View File

@ -28,18 +28,22 @@ emails_date_format = "Y-m-d"
<div class="col-lg-7">
<h1>Contact Me</h1>
<p>The simplest and quickest way to contact me is by completing the form below.</p>
{% component 'contactform' %}
{% component 'contactform' %}
</div>
<div class="col-lg-5">
<h2>Old School</h2>
<p>Sometimes the more traditional ways of getting in touch are just better...</p>
<p>IMG</p>
<p>4<sup>th</sup> Floor, 1207 11 Ave SW,<br>
Calgary, AB, T3C 0M5</p>
<p><img style="width: 100%;" src="https://api.mapbox.com/styles/v1/jason32dev/ckgehopjc2o1y19o09aqs4x11/static/pin-l+B05C71(-113.9771279,51.1112454)/-113.9771279,51.1112454,14,0/545x150@2x?access_token=pk.eyJ1IjoiamFzb24zMmRldiIsImEiOiJjazFsNHd5djcwMXptM2htbW8zM3MyZGxuIn0.-TtNNGFysQPQRfGR1P8DUA"></p>
<p>3699 63 Ave NW,<br>
Calgary, AB, T3J 0G7</p>
<p><a href="tel:+18252575025">(825) 257-5025</a></p>
<h2>New Cool</h2>
<p>...and sometimes it's better to keep up with the times.</p>
{% partial 'social' nocode = true nocontact = true %}
</div>
<div class="col-lg-7">
<h2>Encryption</h2>
<p>If you wish, you may use my <a href="/pgp"><i class="fas fa-key"></i> PGP public key</a> to encrypt anything that you send me.</p>
</div>
</div>

View File

@ -1,6 +1,43 @@
title = "Feed"
url = "/feed"
url = "/feed/:channel?/:tag?/"
layout = "default"
is_hidden = 0
[ShortFeed]
channelFilter = "{{ :channel }}"
tagFilter = "{{ :tag }}"
maxItems = 50
[ChannelList]
includeEmpty = 0
[TagList]
==
<p>Feed</p>
<?php
function onStart()
{
$this->addJs('https://cdnjs.cloudflare.com/ajax/libs/jqcloud/1.0.3/jqcloud.min.js');
$this->addJs('assets/javascript/feedlayout.js');
//$this->addCss('https://cdnjs.cloudflare.com/ajax/libs/jqcloud/1.0.3/jqcloud.min.css');
}
?>
==
<div class="row">
<div class="col-lg-9">
<h1>Feed</h1>
<div id="feedarea" class="col-container masonry">
{% partial 'feed/template' feeditemclass='col-sm-12 col-md-6 col-xl-4' %}
</div>
</div>
<div class="col-lg-3">
<h2>Channels</h2>
{% component 'ChannelList' %}
<h2>Tags</h2>
<div id="tagcloud" style="height: 500px;"></div>
</div>
</div>
{% put scripts %}
<script type="text/javascript">const tagData = {{ tags | raw }};</script>
{% endput %}

View File

@ -0,0 +1,29 @@
title = "ja.son-williams.ca"
url = "/maintenance"
is_hidden = 0
==
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>son-williams.ca</title>
<link href='https://fonts.googleapis.com/css?family=Source+Sans+Pro|Merriweather:300italic,300' rel='stylesheet' type='text/css'>
<style type="text/css">
* {margin: 0; padding: 0; box-sizing: border-box;}
html, body {height: 100%; color: #000000;}
#view {height: 100%; width: 100%; overflow: hidden;}
#verticalcenter {margin: 0; position: absolute; top: 50%; width: 100%; transform: translateY(-50%);}
#verticalcenter p {font-family: 'Merriweather', serif; text-align: center; font-size: 24px; font-weight: 300; font-style: italic;}
#view p#pilink {position: absolute; bottom: 20px; right: 20px; font-size: 20px;}
#view p#pilink a {text-decoration: none; color: #BBC3D0;}
</style>
</head>
<body>
<div id="view">
<div id="verticalcenter">
<p>ja.son-williams.ca</p>
</div>
<p id="pilink"><a href="/backend">&pi;</a></p>
</div>
</body>
</html>

View File

@ -0,0 +1,17 @@
title = "PGP Public Key"
url = "/pgp"
layout = "default"
is_hidden = 0
==
<div class="row">
<div class="col-lg-7">
<h1>PGP Public Key</h1>
{% partial 'pgpkey' %}
</div>
<div class="col-lg-5">
<h2>About</h2>
<p>If you wish, you may use my PGP public key here to encrypt anything that you send me. Of course I'll need a way to get my hands on your public key in order to be able to decrypt it, so please make sure you provide that or that you've published it to a well-known public directory like <a href="https://keyserver.pgp.com">keyserver.pgp.com</a>.</p>
<h2>Contact Me</h2>
{% partial 'social' noinfrequent = true nocode = true %}
</div>
</div>

View File

@ -9,7 +9,6 @@ function onStart()
$this['sideMenu'] = 'resumemenu';
$this->addCss('assets/css/resume.css');
$this->addJs('https://cdnjs.cloudflare.com/ajax/libs/jquery-easing/1.4.1/jquery.easing.min.js');
$this->addJs('https://cdnjs.cloudflare.com/ajax/libs/masonry/4.2.2/masonry.pkgd.min.js');
$this->addJs('assets/javascript/resume.js');
}
?>
@ -31,8 +30,8 @@ function onStart()
<section class="resume-section d-flex align-items-center" id="skills">
<div class="w-100">
<h1>Skills &amp; Expertise</h1>
<div class="masonry">
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="col-container masonry masonry-horizontal">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card card-inline inline">
<div class="card-body">
<h2 class="card-title">Product Management</h2>
@ -49,7 +48,7 @@ function onStart()
</div>
</div>
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card card-inline inline">
<div class="card-body">
<h2 class="card-title">Project Management</h2>
@ -69,7 +68,7 @@ function onStart()
</div>
</div>
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card card-inline inline">
<div class="card-body">
<h2 class="card-title">Lean Six-Sigma</h2>
@ -86,7 +85,7 @@ function onStart()
</div>
</div>
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card card-inline inline">
<div class="card-body">
<h2 class="card-title">Business Analysis</h2>
@ -103,7 +102,7 @@ function onStart()
</div>
</div>
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card card-inline inline">
<div class="card-body">
<h2 class="card-title">Business Technology</h2>
@ -162,7 +161,7 @@ function onStart()
<p>My primary focus was leading projects within the retail arm of the bank, I also acted as a single point of contact representing the retail banking business unit on large-scale, enterprise wide initiatives, playing a leading role in stakeholder, resource and organizational change management.</p>
</div>
<div class="resume-date text-md-right">
<span class="text-primary">March 2013 - September 2017</span>
<span class="text-primary">November 2014 - September 2017</span>
</div>
</div>
@ -185,7 +184,7 @@ function onStart()
<p>Acted as the primary point of contact for small and medium business customers based throughout the UK and beyond, providing banking support and services. Assisted with transactional queries both simple and complex. Sold and advised clients on a wide variety of products and services. Demonstrated a commitment to providing industry leading levels of customer service, and received multiple individual awards for excellent customer satisfaction scores.</p>
</div>
<div class="resume-date text-md-right">
<span class="text-primary">March 2013 - Present</span>
<span class="text-primary">March 2010 - July 2010</span>
</div>
</div>
</div>
@ -263,10 +262,10 @@ function onStart()
<section class="resume-section d-flex align-items-center" id="press">
<div class="w-100">
<h1>Press</h1>
<div class="masonry" data-blah="blah">
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="col-container masonry masonry-horizontal">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card">
<img class="card-img-top" src="{{ 'istock-1051617224-100798650-large.jpg'|media }}">
<img class="card-img-top" src="{{ 'istock-1051617224-100798650-large.jpg' | media }}">
<div class="card-body">
<h2 class="card-title">Connected RPA Delivers A Digital Workforce</h2>
<h3>Computerworld · June 2019</h3>
@ -276,9 +275,9 @@ function onStart()
</div>
</div>
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card">
<img class="card-img-top" src="{{ 'disrupting-industry.png'|media }}">
<img class="card-img-top" src="{{ 'disrupting-industry.png' | media }}">
<div class="card-body">
<h2 class="card-title">This Canadian financial institution disrupted their industry with AI!</h2>
<h3>AI Week · August 2018</h3>
@ -288,9 +287,9 @@ function onStart()
</div>
</div>
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card">
<img class="card-img-top" src="{{ 'atb-financial-g-suite.jpg'|media }}">
<img class="card-img-top" src="{{ 'atb-financial-g-suite.jpg' | media }}">
<div class="card-body">
<h2 class="card-title">Why ATB Financial moved its entire organization to G Suite</h2>
<h3>TechRepublic · March 2018</h3>
@ -300,9 +299,9 @@ function onStart()
</div>
</div>
<div class="masonry-wrapper col-sm-6 col-xl-4">
<div class="masonry-item col-sm-6 col-xl-4">
<div class="card">
<img class="card-img-top" src="{{ 'bryan-kenny.jpg'|media }}">
<img class="card-img-top" src="{{ 'bryan-kenny.jpg' | media }}">
<div class="card-body">
<h2 class="card-title">High-tech bank helps Edmonton's homeless access financial services</h2>
<h3>CBC · September 2017</h3>

View File

@ -0,0 +1,6 @@
title = "Shrapnel"
url = "/feed/shrapnel/:post/"
layout = "default"
is_hidden = 0
==
<h1>Shrapnel Post</h1>

View File

@ -0,0 +1,8 @@
title = "Test"
url = "/test"
is_hidden = 0
[ShortFeed]
maxItems = 10
==
{% component 'ShortFeed' %}

View File

@ -0,0 +1,15 @@
title = "Uh oh"
url = "/404"
layout = "default"
is_hidden = 0
==
<div class="row">
<div class="col-lg-7">
<h1>Uh oh</h1>
<iframe width="100%" height="300" scrolling="no" frameborder="no" allow="autoplay" src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/412499226&color=%23ff5500&auto_play=true&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true"></iframe>
</div>
<div class="col-lg-5">
<p style="margin-top: 6rem;">Well this is embarassing for one of us. The page you asked for is nowhere to be found. Bad link? Mistyped address? I'm not sure.</p>
<p>There's not really all that much stuff here, so you could always try making a selection from the menu at the top right of the page. If you think something's wrong, <a href="/contact">contact me</a>.
</div>
</div>

View File

@ -2,6 +2,17 @@ title = "Welcome"
url = "/"
layout = "default"
is_hidden = 0
[LazyLoadShortFeed]
maxItems = 5
renderPartial = "feed/template"
==
<?php
function onStart()
{
$this->addJs('assets/javascript/feedlayout.js');
}
?>
==
<div class="row">
<div class="col-lg-5 order-lg-2">
@ -15,5 +26,7 @@ is_hidden = 0
</div>
<div class="col-lg-7 order-lg-1">
<h1>Feed</h1>
{% partial 'feed/placeholder' feeditemclass='col-sm-12 col-md-6' %}
<a class="btn btn-outline-primary d-none show-onfeedloaded" href="/feed" role="button">View More...</a>
</div>
</div>

View File

@ -7,24 +7,14 @@ description = "Contact Me Form"
<div id="{{ __SELF__ }}_forms_flash"></div>
<div class="form-group">
<!--<label for="name">Name:</label>
<input type="text" id="name" name="name" class="form-control">-->
<input type="text" id="name" name="name" class="form-control" placeholder="Your Name" required autofocus>
</div>
<div class="form-group">
<!--<label for="email">Email:</label>
<input type="text" id="email" name="email" class="form-control">-->
<input type="email" id="email" name="email" class="form-control" placeholder="Email Address" required>
</div>
<!--<div class="form-group">
<label for="subject">Subject:</label>
<input type="text" id="subject" name="subject" class="form-control">
</div>-->
<div class="form-group">
<!--<label for="message">Message:</label>-->
<textarea id="message" name="message" rows="8" class="form-control" placeholder="Message"></textarea>
</div>
@ -33,6 +23,6 @@ description = "Contact Me Form"
</div>
<button id="simpleContactSubmitButton" type="submit" class="btn btn-outline-primary">Send</button>
<button type="reset" class="btn btn-outline-danger">Cancel</button>
<button type="reset" class="btn btn-outline-secondary">Cancel</button>
</form>

View File

@ -0,0 +1,5 @@
==
<div id="feedarea" class="col-container masonry"><img src="{{ 'assets/images/loading.svg' | theme }}"></div>
{% put scripts %}
<script type="text/javascript">const renderPartial = '{{ renderPartial }}'; var feedItemClass = '{{ feeditemclass }}'</script>
{% endput %}

View File

@ -0,0 +1,45 @@
==
{% if feeditemclass is empty %}
{% set feeditemclass = 'masonry-item' %}
{% else %}
{% set feeditemclass = 'masonry-item ' ~ feeditemclass %}
{% endif %}
{% if posts is empty %}
<p>No feed items have been found.</p>
{% endif %}
{% for post in posts %}
<div class="{{ feeditemclass }}">
<div class="card">
{% if post.extra.img %}
<img class="card-img-top" src="{{ post.extra.img }}">
{% elseif post.extra.lat %}
<!--<div>MAP!</div>
<img class="card-img-top" src="https://api.mapbox.com/styles/v1/mapbox/light-v10/static/{{ post.extra.lng }},{{ post.extra.lat }},14,0/725x400@2x?access_token=pk.eyJ1IjoiamFzb24zMmRldiIsImEiOiJjazFsNHd5djcwMXptM2htbW8zM3MyZGxuIn0.-TtNNGFysQPQRfGR1P8DUA">-->
<img class="card-img-top" src="https://api.mapbox.com/styles/v1/jason32dev/ckgehopjc2o1y19o09aqs4x11/static/pin-l+B05C71({{ post.extra.lng }},{{ post.extra.lat }})/{{ post.extra.lng }},{{ post.extra.lat }},14,0/545x300@2x?access_token=pk.eyJ1IjoiamFzb24zMmRldiIsImEiOiJjazFsNHd5djcwMXptM2htbW8zM3MyZGxuIn0.-TtNNGFysQPQRfGR1P8DUA">
{% endif %}
<div class="card-body">
{% if post.channel.slug == 'twitter' or post.channel.slug == 'instagram' %}
{% if post.title | striptags | length < 100 %}
<h2 class="card-title">{{ post.title | raw | nl2br }}</h2>
{% elseif post.title | striptags | length < 150 %}
<p class="card-text main">{{ post.title | raw | nl2br }}</p>
{% else %}
<p class="card-text">{{ post.title | raw | nl2br }}</p>
{% endif %}
{% elseif post.channel.slug == 'foursquare' %}
{% if post.body %}<p class="card-text main">{{ post.body | raw | nl2br }}</p>{% endif %}
<h2 class="card-title mb-1">{{ post.title }}</h2>
<p class="card-text">{{ post.extra.address }}</p>
{% endif %}
<div class="feedcard-footer">
<p class="float-right feedcard-itemtype">{{ post.friendlytime }} <i class="{{ post.channel.icon }}" style="color: {{ post.channel.colour }};"></i></p>
{% if post.channel.slug == 'twitter' and post.extra.opuser %}
<p class="float-left feedcard-attribution"><i class="fas fa-retweet"></i> <a href="https://twitter.com/{{ post.extra.opuser }}">{{ post.extra.opname }}</a> <img src="{{ post.extra.opimg }}"></p>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}

View File

@ -0,0 +1,32 @@
==
<textarea readonly style="width: 100%; height: 500px; font-family: 'Courier New', Courier, monospace">
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v2
mQENBFXSMV4BCADJvhoUfskuz9lpiSYy/1uBo0C0j++onaqN0Cwv/fcDvG2NkdV6
WMoltJwpUgc4qTwnGroa8Vnf/jXDJnpvvaf4h/zg2O47pwAFsX7eZ9+Djdc5BQhV
lD7YX2OtBM5RybnhQAiJgZEi6OFhZnPQAHD6//7E5PhPHRQIs4sezZdrepJw8Bre
8qHdM0EsUgiB/9fUmC7yXFsPDmTBpq2iVMf1qdet3Cfc7M17mSZY5RABZVErz12U
1TNx+Ecr4cY2MWRNUxpt3ncOK8c9hgcc7WoNjlfL0kc3z/3zW5dNXICYktUAiH47
0YymTywq2Aeqf7ZQpu9A5F85qwlDWrUk1V4hABEBAAG0Ikphc29uIFdpbGxpYW1z
IDxqQHNvbi13aWxsaWFtcy5jYT6JATkEEwEIACMFAlXSMV4CGwMHCwkIBwMCAQYV
CAIJCgsEFgIDAQIeAQIXgAAKCRDXYAQ92dYLDJjUB/472nPkJDBVnZi57afMLbYm
D4sBcxoMLtp4RgKV0fNLkzxvReXPsRyODKLbfkpcPLlHp3Jene/oaNyvMmO2TE/i
k87cjGzdAuYQMmo2GlxrGodx2eK1qPugBZtGkpHHttAOL3RD3TUzAO3l68SH855h
AYcrPh8emBaudvEnT36JjdWkR/k0WOhIQjRGt5SLw3cZ/Icz5tfRl0Ak/KTwroqa
W12Ttu6o0vYS3jtTRZ6w/+qH806cET2vou9GXcB4xks6op8wrpfHqyWRHwVuhLc5
1q4ieWJftIw0C4EjvTzhdoo3OoXvEYx+Riy1eW0XdklbD9ckVCvaD3M5ptAnxEGR
uQENBFXSMV4BCADhgLB/wH1+V+CmVQlbSbWKqc3Z/Nsmm+u09ENX94dKOYiuH9EU
Br2Oj7bXeRkUSooQbU7xGznokf63u4XkUA+uN+eDA/qZoIPJ5yi7hULcj37tVFbU
4bWgJKqpKHg04x+reT5KgV40xjXBqINqevwwdu4R6QO1RpCG9or9HMoaWUP5ROr4
Kp75dNPsXMNnZ7UwDhCp/s0sfGROiBT7SAQzmEBLmlBosT/ah7LXvbxUuI3K08Qe
K2dWc+yWPM3ykS4z8Sp0l4CrxfGpnL9DV+DvA3ntzA9cQ5Kt6209FsXjHRexxB3h
ZsK58E8qlcH8ZKoGtDL5NctBxASE529lX7K7ABEBAAGJAR8EGAEIAAkFAlXSMV4C
GwwACgkQ12AEPdnWCwxEoggAmM+QEtuHVH92ZWwXNda2zNUpbizHLoCBJvrf0lEM
DpWKCfrZAo3q0D85Xx9vq2QV/nFkMzv0PsIA8tJiEFtwZBmXxbm+YBWrLxDX+QM4
KyLGqxWKbLOKmxpqkRO+PmNY4EjNFeu95TeLeeBsohhGbrfKK2K+Kbw57PV9Ppwy
5gZuHRO7xpJMBzc04uVVTmnafU6Ee0scbuwCZTB9VLlDdj5/cjQOcANs/Cu6l58z
61sfQcmbk/xXXdgB0EYMdNxNTG4bjdUZOAFqviH4ve5kwff6AzdfCTK/aDPc9+5Z
jUajxravR3TUzFdhAT2D92TPpmXG0zIYpsGpNLWvQbtjJA==
=Spm3
-----END PGP PUBLIC KEY BLOCK-----</textarea>

View File

@ -1,12 +1,5 @@
description = "Social Media Links"
==
<?php
function onStart()
{
$this->addJs('assets/javascript/tooltips.js');
}
?>
==
<div class="social-icons">
{% if not nosocial %}
<a href="https://www.linkedin.com/in/jaywll/" data-toggle="tooltip" title="LinkedIn">
@ -25,9 +18,17 @@ function onStart()
<a href="https://www.facebook.com/jaywll/" data-toggle="tooltip" title="Facebook">
<i class="fab fa-facebook-f"></i>
</a>
<a href="https://foursquare.com/jaywll" data-toggle="tooltip" title="Foursquare">
<i class="fab fa-foursquare"></i>
</a>
{% if not nocontact %}
<a href="https://foursquare.com/jaywll" data-toggle="tooltip" title="Foursquare">
<i class="fab fa-foursquare"></i>
</a>
<a href="https://www.goodreads.com/jaywll" data-toggle="tooltip" title="Goodreads">
<i class="fab fa-goodreads-g"></i>
</a>
<a href="https://www.vivino.com/users/jaywll" data-toggle="tooltip" title="Vivino">
<i class="fas fa-wine-glass-alt"></i>
</a>
{% endif %}
{% endif %}
{% endif %}
{% if not nocode %}