Browse Source

Venue, Home, SiteLink, RSS Feeds

- Multi-channel support for Venue
- Venue channel editor and management for Admin
- Venue UI components for channel card, channel list item, and channel
grid
- Home page fixes and updates
- Image display and responsiveness for home page down to mobile
- SiteLink model, service, etc., for adding links to your Site
- Many navbar enhancements and fixes
- RSS, ATOM, and JSON feed generators
pull/24/head
Rob Colbert 1 year ago
parent
commit
ea38cf6be6
  1. 3
      app/controllers/admin.js
  2. 2
      app/controllers/admin/site-link.js
  3. 5
      app/controllers/admin/venue.js
  4. 92
      app/controllers/feed.js
  5. 17
      app/controllers/user.js
  6. 3
      app/models/venue-channel.js
  7. 61
      app/services/feed.js
  8. 52
      app/services/user.js
  9. 71
      app/services/venue.js
  10. 5
      app/views/admin/index.pug
  11. 134
      app/views/admin/venue/channel/editor.pug
  12. 20
      app/views/admin/venue/channel/index.pug
  13. 16
      app/views/admin/venue/index.pug
  14. 5
      app/views/components/button-icon.pug
  15. 6
      app/views/components/library.pug
  16. 50
      app/views/components/navbar.pug
  17. 6
      app/views/components/page-sidebar.pug
  18. 11
      app/views/feed/index.pug
  19. 2
      app/views/index.pug
  20. 4
      app/views/post/components/featured-item.pug
  21. 14
      app/views/post/components/list-item.pug
  22. 14
      app/views/venue/components/channel-card.pug
  23. 30
      app/views/venue/components/channel-grid.pug
  24. 11
      app/views/venue/components/channel-list-item.pug
  25. 31
      app/views/venue/index.pug
  26. 6
      app/workers/venue.js
  27. 26
      client/js/site-admin-app.js

3
app/controllers/admin.js

@ -79,6 +79,7 @@ class AdminController extends SiteController {
const {
coreNode: coreNodeService,
dashboard: dashboardService,
venue: venueService,
} = this.dtp.services;
res.locals.stats = {
@ -87,6 +88,8 @@ class AdminController extends SiteController {
constellation: await coreNodeService.getConstellationStats(),
};
res.locals.channels = await venueService.getChannels();
res.render('admin/index');
}
}

2
app/controllers/admin/site-link.js

@ -6,7 +6,7 @@
const express = require('express');
const { SiteController, SiteError } = require('../../../lib/site-lib');
const { SiteController } = require('../../../lib/site-lib');
class SiteLinkAdminController extends SiteController {

5
app/controllers/admin/venue.js

@ -106,8 +106,7 @@ class VenueAdminController extends SiteController {
async getHomeView (req, res, next) {
const { venue: venueService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.channels = await venueService.getChannels(res.locals.pagination);
res.locals.channels = await venueService.getChannels();
res.render('admin/venue/index');
} catch (error) {
this.log.error('failed to present the Venue Admin home view', { error });
@ -120,7 +119,7 @@ class VenueAdminController extends SiteController {
try {
const displayList = this.createDisplayList('delete-channel');
await venueService.removeChannel(res.locals.channel);
displayList.reload();
displayList.navigateTo('/admin/venue');
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to delete channel', { error });

92
app/controllers/feed.js

@ -0,0 +1,92 @@
// feed.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const express = require('express');
const { SiteController } = require('../../lib/site-lib');
class SiteFeedController extends SiteController {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
const router = express.Router();
this.dtp.app.use('/feed', router);
router.use(async (req, res, next) => {
res.locals.currentView = 'feed';
return next();
});
router.get('/rss', this.getRssFeed.bind(this));
router.get('/atom', this.getAtomFeed.bind(this));
router.get('/json', this.getJsonFeed.bind(this));
router.get('/', this.getHome.bind(this));
return router;
}
async getRssFeed (req, res) {
const { feed: feedService } = this.dtp.services;
try {
const feed = await feedService.getSiteFeed();
const rss = feed.rss2();
res.set('Content-Type', 'application/rss+xml');
res.set('Content-Length', rss.length);
res.send(rss);
} catch (error) {
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getAtomFeed (req, res) {
const { feed: feedService } = this.dtp.services;
try {
const feed = await feedService.getSiteFeed();
const atom = feed.atom1();
res.set('Content-Type', 'application/rss+xml');
res.set('Content-Length', atom.length);
res.send(atom);
} catch (error) {
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getJsonFeed (req, res) {
const { feed: feedService } = this.dtp.services;
try {
const feed = await feedService.getSiteFeed();
const json = feed.json1();
res.set('Content-Type', 'application/json');
res.set('Content-Length', json.length);
res.send(json);
} catch (error) {
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getHome (req, res) {
res.render('feed/index');
}
}
module.exports = {
slug: 'feed',
name: 'feed',
create: async (dtp) => { return new SiteFeedController(dtp); },
};

17
app/controllers/user.js

@ -60,6 +60,7 @@ class UserController extends SiteController {
return next();
}
router.param('username', this.populateUsername.bind(this));
router.param('userId', this.populateUser.bind(this));
router.param('coreUserId', this.populateCoreUser.bind(this));
@ -131,7 +132,7 @@ class UserController extends SiteController {
this.getUserSettingsView.bind(this),
);
router.get(
'/:userId',
'/:username',
limiterService.createMiddleware(limiterService.config.user.getUserProfile),
authRequired,
otpMiddleware,
@ -147,6 +148,20 @@ class UserController extends SiteController {
);
}
async populateUsername (req, res, next, username) {
const { user: userService } = this.dtp.services;
try {
res.locals.userProfile = await userService.getPublicProfile('User', username);
if (!res.locals.userProfile) {
throw new SiteError(404, 'Member not found');
}
return next();
} catch (error) {
this.log.error('failed to populate username with public profile', { username, error });
return next(error);
}
}
async populateUser (req, res, next, userId) {
const { user: userService } = this.dtp.services;
try {

3
app/models/venue-channel.js

@ -17,7 +17,8 @@ const VenueChannelSchema = new Schema({
ownerType: { type: String, enum: ['CoreUser','User'], required: true },
owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' },
slug: { type: String, lowercase: true, unique: true, index: 1 },
lastStatus: { type: Schema.ObjectId, ref: 'VenueChannelStatus' },
name: { type: String, required: true, index: 1 },
sortOrder: { type: Number, default: 0, required: true },
credentials: { type: ChannelCredentialsSchema, required: true, select: false },
});

61
app/services/feed.js

@ -8,8 +8,11 @@ const mongoose = require('mongoose');
const Feed = mongoose.model('Feed');
const FeedEntry = mongoose.model('FeedEntry');
const moment = require('moment');
const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib');
const { read: feedReader } = require('feed-reader');
const { Feed: FeedGenerator } = require('feed');
class FeedService extends SiteService {
@ -162,6 +165,64 @@ class FeedService extends SiteService {
{ upsert: true },
);
}
async getSiteFeed ( ) {
const { post: postService } = this.dtp.services;
const posts = await postService.getPosts({ skip: 0, cpp: 10 });
const siteDomain = this.dtp.config.site.domain;
const siteDomainKey = this.dtp.config.site.domainKey;
const siteUrl = `https://${siteDomain}`;
const cardUrl = `${siteUrl}/img/social-cards/${siteDomainKey}.png`;
const iconUrl = `${siteUrl}/img/icon/${siteDomainKey}/icon-512x512.png`;
const feed = new FeedGenerator({
title: this.dtp.config.site.name,
description: this.dtp.config.site.description,
id: siteUrl,
link: siteUrl,
language: 'en-US',
image: cardUrl,
favicon: iconUrl,
copyright: `Copyright © ${moment(new Date()).format('YYYY')}, ${this.dtp.config.site.company}`,
updated: posts[0].updated || posts[0].created,
generator: 'DTP Sites',
feedLinks: {
json: `${siteUrl}/feed/json`,
atom: `${siteUrl}/feed/json`,
},
});
const authorsAttributed = { };
posts.forEach((post) => {
const postUrl = `${siteUrl}/post/${post.slug}`;
if (!authorsAttributed[post.author._id]) {
authorsAttributed[post.author._id] = true;
feed.addContributor({
name: post.author.displayName || post.author.username,
link: `${siteUrl}/user/${post.author.username}`,
});
}
feed.addItem({
title: post.title,
id: postUrl,
link: postUrl,
date: post.updated || post.created,
description: post.summary,
image: post.image ? `${siteUrl}/image/${post.image._id}` : `${siteUrl}/img/default-poster.jpg`,
content: post.content,
author: [
{
name: post.author.displayName || post.author.username,
link: `${siteUrl}/user/${post.author.username}`,
},
],
});
});
return feed;
}
}
module.exports = {

52
app/services/user.js

@ -32,9 +32,11 @@ class UserService extends SiteService {
this.populateUser = [
{
path: 'picture.large',
strictPopulate: false,
},
{
path: 'picture.small',
strictPopulate: false,
},
];
}
@ -542,7 +544,7 @@ class UserService extends SiteService {
return user;
}
async getPublicProfile (username) {
async getPublicProfile (type, username) {
if (!username || (typeof username !== 'string')) {
throw new SiteError(406, 'Invalid username');
}
@ -552,28 +554,32 @@ class UserService extends SiteService {
throw new SiteError(406, 'Invalid username');
}
/**
* Try to resolve the user as a CoreUser
*/
let user = await CoreUser
.findOne({ username_lc: username })
.select('_id created username username_lc displayName bio picture header core')
.populate(this.populateUser)
.lean();
if (user) {
user.type = 'CoreUser';
} else {
/*
* Try to resolve the user as a local User
*/
user = await User
.findOne({ username_lc: username })
.select('_id created username username_lc displayName bio picture header')
.populate(this.populateUser)
.lean();
if (user) {
user.type = 'User';
}
let user;
switch (type) {
case 'CoreUser':
user = await CoreUser
.findOne({ username_lc: username })
.select('_id created username username_lc displayName bio picture header core')
.populate(this.populateUser)
.lean();
if (user) {
user.type = 'CoreUser';
}
break;
case 'User':
user = await User
.findOne({ username_lc: username })
.select('_id created username username_lc displayName bio picture header')
.populate(this.populateUser)
.lean();
if (user) {
user.type = 'User';
}
break;
default:
throw new SiteError(400, 'Invalid user account type');
}
return user;

71
app/services/venue.js

@ -39,9 +39,6 @@ class VenueService extends SiteService {
path: 'owner',
select: userService.USER_SELECT,
},
{
path: 'lastStatus',
},
];
}
@ -49,10 +46,16 @@ class VenueService extends SiteService {
return async (req, res, next) => {
try {
res.locals.venue = res.locals.venue || { };
res.locals.venue.channels = await VenueChannel
res.locals.venue.channels = [ ];
await VenueChannel
.find()
.populate(this.populateVenueChannel)
.lean();
.lean()
.cursor()
.eachAsync(async (channel) => {
channel.currentStatus = await this.updateChannelStatus(channel);
res.locals.venue.channels.push(channel);
});
return next();
} catch (error) {
this.log.error('failed to populate Soapbox channel feed', { error });
@ -68,6 +71,7 @@ class VenueService extends SiteService {
channel.owner = owner._id;
channel.slug = this.getChannelSlug(channelDefinition.url);
channel.sortOrder = parseInt(channelDefinition.sortOrder || '0', 10);
if (!channelDefinition['credentials.streamKey'] || (channelDefinition['credentials.streamKey'] === '')) {
throw new SiteError(400, 'Must provide a stream key');
@ -82,6 +86,10 @@ class VenueService extends SiteService {
widgetKey: channelDefinition['credentials.widgetKey'].trim(),
};
const status = await this.updateChannelStatus(channel);
channel.name = status.name;
channel.description = status.description;
await channel.save();
await this.updateChannelStatus(channel);
@ -92,6 +100,11 @@ class VenueService extends SiteService {
const updateOp = { $set: { } };
updateOp.$set.slug = this.getChannelSlug(channelDefinition.url);
updateOp.$set.sortOrder = parseInt(channelDefinition.sortOrder || '0', 10);
const status = await this.updateChannelStatus(channel);
updateOp.$set.name = status.name;
updateOp.$set.description = status.description;
if (!channelDefinition['credentials.streamKey'] || (channelDefinition['credentials.streamKey'] === '')) {
throw new SiteError(400, 'Must provide a stream key');
@ -108,21 +121,26 @@ class VenueService extends SiteService {
}
async getChannels (pagination, options) {
options = Object.assign({ withCredentials: false }, options);
pagination = Object.assign({ skip: 0, cpp: 10 }, pagination);
options = Object.assign({ featuredOnly: false, withCredentials: false }, options);
const search = { };
let q = VenueChannel
.find(search)
.sort({ slug: 1 })
.skip(pagination.skip)
.limit(pagination.cpp);
.sort({ sortOrder: 1, name: 1, slug: 1 });
if (pagination) {
q = q.skip(pagination.skip).limit(pagination.cpp);
}
if (options.withCredentials) {
q = q.select('+credentials');
}
return q.populate(this.populateVenueChannel).lean();
const channels = await q.populate(this.populateVenueChannel).lean();
for await (const channel of channels) {
channel.currentStatus = await this.updateChannelStatus(channel);
}
return channels;
}
async getChannelById (channelId, options) {
@ -131,7 +149,9 @@ class VenueService extends SiteService {
if (options.withCredentials) {
q = q.select('+credentials');
}
return q.populate(this.populateVenueChannel).lean();
const channel = await q.populate(this.populateVenueChannel).lean();
channel.currentStatus = await this.updateChannelStatus(channel);
return channel;
}
async getChannelBySlug (channelSlug, options) {
@ -140,12 +160,15 @@ class VenueService extends SiteService {
if (options.withCredentials) {
q = q.select('+credentials');
}
return q.populate(this.populateVenueChannel).lean();
const channel = await q.populate(this.populateVenueChannel).lean();
channel.currentStatus = await this.updateChannelStatus(channel);
return channel;
}
async getChannelFeed (channelSlug, options) {
async getChannelFeed (channel, options) {
const { cache: cacheService } = this.dtp.services;
const cacheKey = `venue:ch:${channelSlug}`;
const cacheKey = `venue:ch:${channel.slug}`;
options = Object.assign({ allowCache: true }, options);
let json;
@ -154,8 +177,8 @@ class VenueService extends SiteService {
if (json) { return json; }
}
const requestUrl = `https://${this.soapboxDomain}/channel/${channelSlug}/feed/json`;
this.log.info('fetching Shing channel feed', { channelSlug, requestUrl });
const requestUrl = `https://${this.soapboxDomain}/channel/${channel.slug}/feed/json`;
this.log.info('fetching Shing channel feed', { slug: channel.slug, requestUrl });
const response = await fetch(requestUrl, {
agent: this.httpsAgent,
});
@ -183,14 +206,7 @@ class VenueService extends SiteService {
throw new Error(`failed to fetch channel status: ${json.message}`);
}
let status = new VenueChannelStatus(json.channel);
status.created = new Date();
status.channel = channel._id;
await status.save();
await VenueChannel.updateOne({ _id: channel._id }, { $set: { lastStatus: status._id } });
return status.toObject();
return json.channel;
}
getChannelSlug (channelUrl) {
@ -207,6 +223,11 @@ class VenueService extends SiteService {
return slug(striptags(channelUrlParts[1].trim()));
}
async removeChannel (channel) {
await VenueChannelStatus.deleteMany({ channel: channel._id });
await VenueChannel.deleteOne({ _id: channel._id });
}
}
module.exports = {

5
app/views/admin/index.pug

@ -1,6 +1,8 @@
extends layouts/main
block content
include ../venue/components/channel-grid
div(uk-grid)
div(class="uk-width-1-1 uk-width-auto@m")
h3= site.name
@ -25,6 +27,9 @@ block content
h3 Hourly Sign-Ups
canvas(id="hourly-signups")
if Array.isArray(channels) && (channels.length > 0)
+renderVenueChannelGrid(channels)
block viewjs
script(src="/chart.js/chart.min.js")
script(src="/chartjs-adapter-moment/chartjs-adapter-moment.min.js")

134
app/views/admin/venue/channel/editor.pug

@ -1,37 +1,147 @@
extends ../../layouts/main
block content
h1 VenueChannel Editor
- var formAction = channel ? `/admin/venue/channel/${channel._id}` : '/admin/venue/channel';
form(method="POST", action= formAction).uk-form
form(method="POST", action= formAction, onsubmit="return dtp.channelEditor.submitForm(event);").uk-form
.uk-card.uk-card-secondary.uk-card-small
.uk-card-header
h1.uk-card-title= channel ? 'Update Channel' : 'Create Channel'
p You are linking a Shing.tv stream channel to your Venue. Venue wants the Shing channel information, but wants the local user information for the owner of the channel. You are tying a remote Shing channel to a local user of #{site.name}.
.uk-card-body
.uk-margin
label(for="slug").uk-form-label Channel URL
input(type="url", name="url", placeholder="Paste Shing.tv channel URL", value= channel ? `https://${dtp.services.venue.soapboxDomain}/channel/${channel.slug}` : undefined).uk-input
.uk-text-small.uk-text-muted #{site.name} integrates #{dtp.services.venue.soapboxDomain} and wants the channel URL from there.
.uk-margin
label(for="owner").uk-form-label Owner
input(type="text", name="owner", placeholder=`Enter channel owner's local username (here on ${site.name})`, value= channel ? channel.owner.username : 'rob').uk-input
.uk-text-small.uk-text-muted Enter the user's username here on #{site.name}, not from Shing.tv.
div(uk-grid)
div(class="uk-width-1-1 uk-width-2-3@m")
label(for="stream-key").uk-form-label Stream Key
input(id="stream-key", name="credentials.streamKey", type="text", placeholder="Paste Shing.tv stream key", value= channel ? channel.credentials.streamKey : undefined).uk-input
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-expand
input(
id="stream-key",
name="credentials.streamKey",
type="text",
placeholder="Paste Shing.tv stream key",
data-key-value= channel ? channel.credentials.streamKey : undefined,
).uk-input
.uk-width-auto
button(
type="button",
onclick="return dtp.channelEditor.toggleKeyValue(event, 'stream-key');",
).uk-button.uk-button-text
span
i.button-icon.fas.fa-lock
span.button-label.uk-margin-small-left Show
div(class="uk-width-1-1 uk-width-1-3@m")
label(for="widget-key").uk-form-label Widget Key
input(id="widget-key", name="credentials.widgetKey", type="text", placeholder="Paste Shing.tv widget key", value= channel ? channel.credentials.widgetKey : undefined).uk-input
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-expand
input(
id="widget-key",
name="credentials.widgetKey",
type="text",
placeholder="Paste Shing.tv widget key",
data-key-value= channel ? channel.credentials.widgetKey : undefined,
).uk-input
.uk-width-auto
button(
type="button",
onclick="return dtp.channelEditor.toggleKeyValue(event, 'widget-key');",
).uk-button.uk-button-text
span
i.button-icon.fas.fa-lock
span.button-label.uk-margin-small-left Show
.uk-card-footer.uk-flex.uk-flex-between
.uk-width-auto
div(uk-grid).uk-flex-middle
div(class="uk-width-1-1 uk-width-1-2@s")
label(for="owner").uk-form-label Owner
.uk-form-controls
input(type="text", name="owner", placeholder=`Enter channel owner's local username (here on ${site.name})`, value= channel ? channel.owner.username : 'rob').uk-input
div(class="uk-width-1-1 uk-width-1-2@s")
label(for="sort-order").uk-form-label Sort order
.uk-form-controls
input(id="sort-order", name="sortOrder", type="number", min=0, step=1, placeholder="Enter channel sort order", value= channel ? channel.sortOrder : 0).uk-input
div(uk-grid).uk-card-footer
.uk-width-expand
+renderBackButton({ includeLabel: true, label: 'Cancel' })
if channel
.uk-width-auto
button(
type="button",
data-channel={ _id: channel._id, name: channel.name },
onclick="return dtp.adminApp.deleteVenueChannel(event);",
).uk-button.dtp-button-danger.uk-border-rounded
span
i.fas.fa-trash
span.uk-margin-small-left DELETE
.uk-width-auto
button(type="submit").uk-button.dtp-button-primary.uk-border-rounded= channel ? 'Update Channel' : 'Create Channel'
button(type="submit").uk-button.dtp-button-primary.uk-border-rounded
span
i.fas.fa-save
span.uk-margin-small-left= channel ? 'Update' : 'Create'
block viewjs
script.
(async ( ) => {
window.dtp = window.dtp || { };
window.dtp.channelEditor = { };
window.dtp.channelEditor.toggleKeyValue = async (event, fieldId) => {
event.preventDefault();
event.stopPropagation();
const button = event.currentTarget || event.target;
const target = document.getElementById(fieldId);
target.toggleAttribute('data-masked');
if (target.hasAttribute('data-masked')) {
target.value = target.getAttribute('data-masked-value');
target.toggleAttribute('data-masked', true);
target.toggleAttribute('disabled', true);
button.querySelector('.button-label').textContent = 'SHOW';
const icon = button.querySelector('.button-icon');
icon.classList.remove('fa-lock-open');
icon.classList.add('fa-lock');
} else {
target.value = target.getAttribute('data-key-value');
target.toggleAttribute('disabled', false);
button.querySelector('.button-label').textContent = 'HIDE';
const icon = button.querySelector('.button-icon');
icon.classList.remove('fa-lock');
icon.classList.add('fa-lock-open');
}
};
window.dtp.channelEditor.submitForm = async (event) => {
// simply restore the values back to their key values, and allow the
// form to submit per normal processing.
window.dtp.channelEditor.keyInputs.forEach((input) => {
if (input.hasAttribute('data-masked')) {
input.value = input.getAttribute('data-key-value');
input.toggleAttribute('data-masked', false);
input.toggleAttribute('disabled', false);
}
input.style.visibility = 'hidden'; // since we are "showing" the keys
});
};
window.dtp.channelEditor.keyInputs = document.querySelectorAll("input[data-key-value]");
window.dtp.channelEditor.keyInputs.forEach((input) => {
const keyValue = input.getAttribute('data-key-value');
const maskedValue = keyValue.split('').map((char) => '*').join('');
input.setAttribute('data-masked-value', maskedValue);
input.toggleAttribute('data-masked', true);
input.toggleAttribute('disabled', true);
input.value = maskedValue;
});
})();

20
app/views/admin/venue/channel/index.pug

@ -5,13 +5,15 @@ block content
var onlineChannels = channels.filter((channel) => channel.lastUpdate && (channel.lastUpdate.status === 'live'))
var offlineChannels = channels.filter((channel) => !channel.lastUpdate || (channel.lastUpdate.status !== 'live'))
h1 Manage Your Venue Channels
a(href="/admin/venue/channel/create").uk-button.dtp-button-primary.uk-border-rounded Add Channel
.uk-margin
h1 Manage Your Venue Channels
a(href="/admin/venue/channel/create").uk-button.dtp-button-primary.uk-border-rounded Add Channel
if Array.isArray(offlineChannels) && (channels.length > 0)
uk.uk-list.uk-list-divider
each channel of offlineChannels
li
+renderVenueChannelListItem(channel, { baseUrl: '/admin/venue/channel' })
else
div There are no channels integrated with #{site.name}.
.uk-margin
if Array.isArray(offlineChannels) && (channels.length > 0)
uk.uk-list.uk-list-divider
each channel of offlineChannels
li
+renderVenueChannelListItem(channel, { baseUrl: '/admin/venue/channel' })
else
div There are no channels integrated with #{site.name}.

16
app/views/admin/venue/index.pug

@ -3,18 +3,24 @@ block content
include ../../venue/components/channel-list-item
h1 Manage Your DTP Venue
-
var onlineChannels = channels.filter((channel) => channel.lastUpdate && (channel.lastUpdate.status === 'live'))
var offlineChannels = channels.filter((channel) => !channel.lastUpdate || (channel.lastUpdate.status !== 'live'))
.uk-margin
div(uk-grid)
div(class="uk-width-1-1 uk-width-expand@m")
.uk-text-large #{site.name} Stream Channels
div(class="uk-width-1-1 uk-width-auto@m")
a(href="/admin/venue/channel").uk-button.dtp-button-default.uk-button-small.uk-border-rounded
span
i.fas.fa-cog
span.uk-margin-small-left Manage Channels
if Array.isArray(offlineChannels) && (channels.length > 0)
uk.uk-list.uk-list-divider
each channel of offlineChannels
li
+renderVenueChannelListItem(channel, { baseUrl: '/admin/venue/channel' })
else
div There are no channels integrated with #{site.name}.
pre= JSON.stringify(channels, null, 2)
div There are no channels integrated with #{site.name}.

5
app/views/components/button-icon.pug

@ -1,5 +1,10 @@
mixin renderButtonIcon (buttonClass, buttonLabel)
span
i(class=`fas ${buttonClass}`)
if buttonLabel
span(class="uk-visible@m").uk-margin-small-left= buttonLabel
mixin renderButtonImage (imageUrl, buttonLabel)
img(src= imageUrl, alt= `Image icon for button ${buttonLabel || 'unnamed'}`)
if buttonLabel
span(class="uk-visible@m").uk-margin-small-left= buttonLabel

6
app/views/components/library.pug

@ -42,11 +42,11 @@ include section-title
if (user.core) {
return `/user/core/${user._id}`;
}
return `/user/${user._id}`;
return `/user/${user.username}`;
}
mixin renderCell (label, value, className)
div(title=`${label}: ${numeral(value).format('0,0')}`).uk-tile.uk-tile-default.uk-padding-remove.no-select
div(title=`${label}: ${numeral(value).format('0,0')}`).no-select
div(class=className)!= value
.uk-text-muted.uk-text-small= label
@ -62,4 +62,4 @@ mixin renderUserLink (user)
if user.coreUserId
a(href=`/user/core/${user._id}`)= `${user.username}@${user.core.meta.domainKey}`
else
a(href=`/user/${user._id}`)= user.displayName || user.username
a(href=`/user/${user.username}`)= user.displayName || user.username

50
app/views/components/navbar.pug

@ -1,38 +1,36 @@
include ../user/components/profile-icon
- var isLive = venue && !!venue.channels.find((channel) => channel.lastStatus && (channel.lastStatus.status === 'live'));
-
var haveVenueChannels = venue && Array.isArray(venue.channels) && (venue.channels.length > 0);
var isLive = haveVenueChannels && !!venue.channels.find((channel) => channel.currentStatus && (channel.currentStatus.status === 'live'));
nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top
.uk-navbar-left
.uk-navbar-item
button(type="button", uk-toggle="target: #dtp-offcanvas").uk-button.uk-button-link.uk-padding-small
i.fas.fa-bars
a(href="", uk-toggle="target: #dtp-offcanvas").uk-link-reset.uk-navbar-item
i.fas.fa-bars
div(class="uk-visible@m").uk-navbar-left
//- Site icon
a(href="/").uk-navbar-item
img(src=`/img/icon/${site.domainKey}/icon-36x36.png`)
//- Site name
ul.uk-navbar-nav
li(class={ 'uk-active': currentView === 'home' })
a(href="/", title= "Home")
+renderButtonIcon('fa-home', 'Home')
a(href="/", title="Home").uk-navbar-item
+renderButtonImage(`/img/icon/${site.domainKey}/icon-36x36.png`, 'Home')
if user
li(class={ 'uk-active': currentView === 'chat' })
a(href="/chat", title= "chat")
+renderButtonIcon('fa-comment-alt', 'Chat')
if site.shingWidgetKey && site.shingChannelSlug
if haveVenueChannels
li(class={ 'uk-active': currentView === 'venue' })
a(href="/venue", title= "Live")
a(href="/venue", title=`Channel lineup for ${site.name}`)
if isLive
span 🔴
else
span
i.fas.fa-tv
span(class="uk-visible@m").uk-margin-small-left= isLive ? 'On Air' : 'Channels'
span.uk-margin-small-left= isLive ? 'On Air' : 'Channels'
div(class="uk-visible@m").uk-navbar-left
//- Site name
ul.uk-navbar-nav
if user
li(class={ 'uk-active': currentView === 'chat' })
a(href="/chat", title= "chat")
+renderButtonIcon('fa-comment-alt', 'Chat')
each menuItem in mainMenu
li(class={ 'uk-active': (pageSlug === menuItem.slug) })
@ -57,16 +55,6 @@ nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top
)
.uk-width-expand= link.label
div(class="uk-hidden@m").uk-navbar-center
//- Site name
ul.uk-navbar-nav
li
a(href="/").uk-navbar-item
img(src=`/img/icon/${site.domainKey}/icon-36x36.png`)
each menuItem in mainMenu
li
a(href= menuItem.url, title= menuItem.label)= menuItem.label
.uk-navbar-right
if user
ul.uk-navbar-nav
@ -87,7 +75,7 @@ nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top
li.uk-nav-heading.uk-text-center= user.displayName || user.username
li.uk-nav-divider
li
a(href= user.core ? `/user/core/${user._id}` : `/user/${user._id}`)
a(href= user.core ? `/user/core/${user._id}` : `/user/${user.username}`)
span.nav-item-icon
i.fas.fa-user
span Profile

6
app/views/components/page-sidebar.pug

@ -37,15 +37,15 @@ mixin renderPageSidebar ( )
//-
-
var onlineChannels = venue.channels.filter((channel) => channel.lastStatus && (channel.lastStatus.status === 'live'));
var offlineChannels = venue.channels.filter((channel) => !channel.lastStatus || (channel.lastStatus.status !== 'live'));
var onlineChannels = venue.channels.filter((channel) => channel.currentStatus && (channel.currentStatus.status === 'live'));
var offlineChannels = venue.channels.filter((channel) => !channel.currentStatus || (channel.currentStatus.status !== 'live'));
if Array.isArray(onlineChannels) && (onlineChannels.length > 0)
each channel of onlineChannels
.uk-margin-medium
+renderSectionTitle('Live Now!', {
label: 'Tune In',
title: channel.lastStatus.name,
title: channel.name,
url: '/venue',
})
+renderVenueChannelCard(channel)

11
app/views/feed/index.pug

@ -0,0 +1,11 @@
extends ../layouts/main-sidebar
block content
h1 #{site.name} Feeds
ul.uk-list
li
a(href='/feed/rss') RSS
li
a(href='/feed/atom') ATOM
li
a(href='/feed/json') JSON

2
app/views/index.pug

@ -19,7 +19,7 @@ block content
.uk-margin
div(uk-grid).uk-grid-small
each post in featuredPosts
.uk-width-1-2
div(class="uk-width-1-1 uk-width-1-2@s")
+renderBlogPostFeaturedItem(post)
//- Blog Posts

4
app/views/post/components/featured-item.pug

@ -1,13 +1,13 @@
mixin renderBlogPostFeaturedItem (post)
a(href=`/post/${post.slug}`).uk-display-block.uk-link-reset
div(class='uk-visible@m').uk-margin-small
.uk-margin-small
if post.image
img(src= `/image/${post.image._id}`).responsive
else
img(src="/img/default-poster.jpg").responsive
article.uk-article
h4(style="line-height: 1.1;", uk-tooltip= post.title).uk-article-title.uk-margin-small.uk-text-truncate= post.title
h3(style="line-height: 1;", uk-tooltip= post.title).uk-margin-remove.uk-text-truncate= post.title
.uk-article-meta
if post.updated
span updated: #{moment(post.updated).format("MMM DD YYYY HH:MM a")}

14
app/views/post/components/list-item.pug

@ -2,18 +2,18 @@ mixin renderBlogPostListItem (post, postIndex = 1, postIndexModulus = 3)
a(href=`/post/${post.slug}`).uk-display-block.uk-link-reset
div(uk-grid).uk-grid-small
div(class='uk-visible@m', class={
'uk-flex-first': ((postIndex % postIndexModulus) === 0),
'uk-flex-last': ((postIndex % postIndexModulus) !== 0),
}).uk-width-1-3
div(class="uk-width-1-1 uk-width-1-3@s uk-flex-first", class={
'uk-flex-first@m': ((postIndex % postIndexModulus) === 0),
'uk-flex-last@m': ((postIndex % postIndexModulus) !== 0),
})
if post.image
img(src= `/image/${post.image._id}`).responsive
else
img(src="/img/default-poster.jpg").responsive
div(class='uk-width-1-1 uk-width-2-3@m', class={
'uk-flex-first': ((postIndex % postIndexModulus) !== 0),
'uk-flex-last': ((postIndex % postIndexModulus) === 0),
div(class='uk-width-1-1 uk-width-2-3@s', class="uk-flex-last", class={
'uk-flex-first@m': ((postIndex % postIndexModulus) !== 0),
'uk-flex-last@m': ((postIndex % postIndexModulus) === 0),
})
article.uk-article
h4(style="line-height: 1.1;").uk-article-title.uk-margin-small= post.title

14
app/views/venue/components/channel-card.pug

@ -1,21 +1,21 @@
mixin renderVenueChannelCard (channel)
.uk-card.uk-card-default.uk-card-small.uk-card-hover.uk-margin
if channel.lastStatus && channel.lastStatus.liveThumbnail
if channel.currentStatus && channel.currentStatus.liveThumbnail
.uk-card-media-top
a(href=`/venue/${channel.slug}`)
img(
src= channel.lastStatus.liveThumbnail.url,
onerror=`this.src = '${channel.lastStatus.thumbnailUrl}';`,
src= channel.currentStatus.liveThumbnail.url,
onerror=`this.src = '${channel.currentStatus.thumbnailUrl}';`,
title="Tune in now",
)
if channel.lastStatus && channel.lastStatus.liveEpisode && channel.lastStatus.liveEpisode.title
if channel.currentStatus && channel.currentStatus.liveEpisode && channel.currentStatus.liveEpisode.title
.uk-card-body
.uk-text-bold.uk-text-truncate
a(href="/venue", uk-tooltip= `Watch "${channel.lastStatus.liveEpisode.title}" now!`)= channel.lastStatus.liveEpisode.title
a(href="/venue", uk-tooltip= `Watch "${channel.currentStatus.liveEpisode.title}" now!`)= channel.currentStatus.liveEpisode.title
.uk-text-small
div(uk-grid).uk-grid-small.uk-flex-between
.uk-width-auto
div Started: #{moment(channel.lastStatus.liveEpisode.created).fromNow()}
.uk-width-auto #[i.fas.fa-eye] #{formatCount(channel.lastStatus.liveEpisode.stats.currentViewerCount)}
div Started: #{moment(channel.currentStatus.liveEpisode.created).fromNow()}
.uk-width-auto #[i.fas.fa-eye] #{formatCount(channel.currentStatus.liveEpisode.stats.currentViewerCount)}

30
app/views/venue/components/channel-grid.pug

@ -0,0 +1,30 @@
include ./channel-card
include ./channel-list-item
mixin renderVenueChannelGrid (channels)
-
var onlineChannels = channels.filter((channel) => channel.currentStatus && (channel.currentStatus.status === 'live'))
var offlineChannels = channels.filter((channel) => !channel.currentStatus || (channel.currentStatus.status !== 'live'))
div(uk-grid)
div(class="uk-width-1-1 uk-width-2-3@s")
+renderSectionTitle('Live Channels')
.uk-margin-small
if Array.isArray(onlineChannels) && (onlineChannels.length > 0)
div(uk-grid).uk-grid-small
each channel of onlineChannels
div(class="uk-width-1-1 uk-width-1-2@s uk-width-1-3@l")
+renderVenueChannelCard(channel)
else
div There are no live channels. Please check back later!
div(class="uk-width-1-1 uk-width-1-3@s")
+renderSectionTitle('Offline Channels')
.uk-margin-small
if Array.isArray(offlineChannels) && (offlineChannels.length > 0)
ul.uk-list.uk-list-divider
each channel of offlineChannels
li
+renderVenueChannelListItem(channel)
else
div There are no offline channels.

11
app/views/venue/components/channel-list-item.pug

@ -7,18 +7,15 @@ mixin renderVenueChannelListItem (channel, options)
.uk-width-expand
.uk-margin-small
if channel.lastStatus
.uk-text-bold
a(href=`${options.baseUrl}/${channel.slug}`, uk-tooltip=`Visit ${channel.lastStatus.name}`)= channel.lastStatus.name
else
div ...waiting for first channel status update to arrive
.uk-text-bold
a(href=`${options.baseUrl}/${channel.slug}`, uk-tooltip=`Visit ${channel.name}`)= channel.name || 'STATUS UPDATE PENDING...'
.uk-text-small.uk-text-meta
div(uk-grid).uk-grid-small.uk-grid-divider
.uk-width-expand.uk-text-truncate
+renderUserLink(channel.owner)
.uk-width-auto
if channel.lastStatus.status === 'live'
if channel.currentStatus.status === 'live'
span.uk-text-success LIVE
else
span= moment(channel.lastStatus.lastLive).fromNow()
span= moment(channel.currentStatus.lastLive).fromNow()

31
app/views/venue/index.pug

@ -2,37 +2,10 @@ extends ../layouts/main
block content
include ../components/pagination-bar
-
var onlineChannels = channels.filter((channel) => channel.lastStatus && (channel.lastStatus.status === 'live'))
var offlineChannels = channels.filter((channel) => !channel.lastStatus || (channel.lastStatus.status !== 'live'))
include components/channel-grid
section.uk-section.uk-section-default.uk-section-small
.uk-container.uk-container-expand
.uk-margin-large
div(uk-grid)
.uk-width-2-3
+renderSectionTitle('Live Channels')
.uk-margin-small
if Array.isArray(onlineChannels) && (onlineChannels.length > 0)
div(uk-grid).uk-grid-small
each channel of onlineChannels
.uk-width-1-3
+renderVenueChannelCard(channel)
else
div There are no live channels. Please check back later!
.uk-width-1-3
+renderSectionTitle('Offline Channels')
.uk-margin-small
if Array.isArray(offlineChannels) && (offlineChannels.length > 0)
ul.uk-list.uk-list-divider
each channel of offlineChannels
li
+renderVenueChannelListItem(channel)
else
div There are no offline channels.
//- pre= JSON.stringify(onlineChannels, null, 2)
//- pre= JSON.stringify(offlineChannels, null, 2)
+renderVenueChannelGrid(channels)

6
app/workers/venue.js

@ -25,6 +25,12 @@ module.config = {
module.config.site = require(path.join(module.rootPath, 'config', 'site'));
module.config.http = require(path.join(module.rootPath, 'config', 'http'));
/**
* This worker was mothballed in favor of just live-requesting the status from
* the Soapbox back-end. This worker doesn't need to be started or running for
* now. There will be uses for it, but current channel status is not one of
* them.
*/
class VenueWorker extends SiteWorker {
constructor (dtp) {

26
client/js/site-admin-app.js

@ -451,7 +451,7 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp {
const link = JSON.parse(target.getAttribute('data-link'));
try {
await UIkit.modal.confirm(`Are you sure you want to remove sit elink "${link.label}"?`);
await UIkit.modal.confirm(`Are you sure you want to remove site link "${link.label}"?`);
} catch (error) {
// canceled
return false;
@ -466,6 +466,30 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp {
return false;
}
async deleteVenueChannel (event) {
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget || event.target;
const channel = JSON.parse(target.getAttribute('data-channel'));
try {
await UIkit.modal.confirm(`Are you sure you want to remove channel "${channel.name}"?`);
} catch (error) {
// canceled
return false;
}
try {
const response = await fetch(`/admin/venue/channel/${channel._id}`, { method: 'DELETE' });
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to remove site link: ${error.message}`);
}
return false;
}
}
dtp.DtpSiteAdminHostStatsApp = DtpSiteAdminHostStatsApp;
Loading…
Cancel
Save