Gek/external actions (#827)
* WIP External actions modal frontend * Add external action links * Allow modal to show/hide and use a dynamic url * Use external link object instead of just url for state * add style and placement to external action buttons * reformat and simplify tag list style as not to conflict with action buttons and make them look less actionable since they're not * fix bug to open modal * have Esc key close modal * fix style on modal * make modal bg darker * close modal when you click outside of it * fix zindex * Add support for external action icons and colors * Some external action modal sizing + loading spinner Co-authored-by: Ginger Wong <omqmail@gmail.com>
This commit is contained in:
BIN
webroot/img/loading.gif
Normal file
BIN
webroot/img/loading.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 465 B After Width: | Height: | Size: 558 B |
@@ -13,6 +13,9 @@ import {
|
||||
hasTouchScreen,
|
||||
getOrientation,
|
||||
} from './utils/helpers.js';
|
||||
import ExternalActionModal, {
|
||||
ExternalActionButton,
|
||||
} from './components/external-action-modal.js';
|
||||
|
||||
import {
|
||||
addNewlines,
|
||||
@@ -73,6 +76,8 @@ export default class App extends Component {
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
orientation: getOrientation(this.hasTouchScreen),
|
||||
|
||||
externalAction: null,
|
||||
};
|
||||
|
||||
// timers
|
||||
@@ -96,7 +101,10 @@ export default class App extends Component {
|
||||
this.disableChatInput = this.disableChatInput.bind(this);
|
||||
this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this);
|
||||
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
this.handleKeyPressed = this.handleKeyPressed.bind(this);
|
||||
this.displayExternalAction = this.displayExternalAction.bind(this);
|
||||
this.closeExternalActionModal = this.closeExternalActionModal.bind(this);
|
||||
|
||||
// player events
|
||||
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||
@@ -119,6 +127,7 @@ export default class App extends Component {
|
||||
if (this.hasTouchScreen) {
|
||||
window.addEventListener('orientationchange', this.handleWindowResize);
|
||||
}
|
||||
window.addEventListener('keydown', this.handleKeyDown);
|
||||
window.addEventListener('keypress', this.handleKeyPressed);
|
||||
this.player = new OwncastPlayer();
|
||||
this.player.setupPlayerCallbacks({
|
||||
@@ -140,6 +149,7 @@ export default class App extends Component {
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
window.removeEventListener('blur', this.handleWindowBlur);
|
||||
window.removeEventListener('focus', this.handleWindowFocus);
|
||||
window.removeEventListener('keydown', this.handleKeyDown);
|
||||
window.removeEventListener('keypress', this.handleKeyPressed);
|
||||
if (this.hasTouchScreen) {
|
||||
window.removeEventListener('orientationchange', this.handleWindowResize);
|
||||
@@ -386,6 +396,12 @@ export default class App extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (e.code === 'Escape' && this.state.externalAction !== null) {
|
||||
this.closeExternalActionModal();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyPressed(e) {
|
||||
if (
|
||||
e.code === 'Space' &&
|
||||
@@ -396,6 +412,35 @@ export default class App extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
displayExternalAction(index) {
|
||||
const { configData, username } = this.state;
|
||||
const action = configData.externalActions[index];
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const { url: actionUrl, openExternally } = action || {};
|
||||
let url = new URL(actionUrl);
|
||||
// Append url and username to params so the link knows where we came from and who we are.
|
||||
url.searchParams.append('username', username);
|
||||
url.searchParams.append('instance', window.location);
|
||||
|
||||
if (openExternally) {
|
||||
var win = window.open(url.toString(), '_blank');
|
||||
win.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
externalAction: action,
|
||||
});
|
||||
}
|
||||
|
||||
closeExternalActionModal() {
|
||||
this.setState({
|
||||
externalAction: null,
|
||||
});
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const {
|
||||
chatInputEnabled,
|
||||
@@ -413,6 +458,7 @@ export default class App extends Component {
|
||||
websocket,
|
||||
windowHeight,
|
||||
windowWidth,
|
||||
externalAction,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
@@ -424,23 +470,12 @@ export default class App extends Component {
|
||||
name,
|
||||
extraPageContent,
|
||||
chatDisabled,
|
||||
externalActions,
|
||||
} = configData;
|
||||
|
||||
const bgUserLogo = { backgroundImage: `url(${logo})` };
|
||||
|
||||
const tagList =
|
||||
tags !== null && tags.length > 0
|
||||
? tags.map(
|
||||
(tag, index) => html`
|
||||
<li
|
||||
key="tag${index}"
|
||||
class="tag rounded-sm text-gray-100 bg-gray-700 text-xs uppercase mr-3 mb-2 p-2 whitespace-no-wrap"
|
||||
>
|
||||
${tag}
|
||||
</li>
|
||||
`
|
||||
)
|
||||
: null;
|
||||
const tagList = tags !== null && tags.length > 0 && tags.join(' #');
|
||||
|
||||
const viewerCountMessage =
|
||||
streamOnline && viewerCount > 0
|
||||
@@ -470,6 +505,32 @@ export default class App extends Component {
|
||||
? null
|
||||
: html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `;
|
||||
|
||||
const externalActionButtons =
|
||||
externalActions &&
|
||||
html`<div
|
||||
id="external-actions-container"
|
||||
class="flex flex-row align-center"
|
||||
>
|
||||
${externalActions.map(
|
||||
function (action, index) {
|
||||
return html`<${ExternalActionButton}
|
||||
onClick=${this.displayExternalAction}
|
||||
action=${action}
|
||||
index=${index}
|
||||
/>`;
|
||||
}.bind(this)
|
||||
)}
|
||||
</div>`;
|
||||
|
||||
const externalActionModal = externalAction
|
||||
? html`<${ExternalActionModal}
|
||||
title=${this.state.externalAction.description ||
|
||||
this.state.externalAction.title}
|
||||
url=${this.state.externalAction.url}
|
||||
onClose=${this.closeExternalActionModal}
|
||||
/>`
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="app-container"
|
||||
@@ -555,6 +616,7 @@ export default class App extends Component {
|
||||
<div
|
||||
class="user-content-header border-b border-gray-500 border-solid"
|
||||
>
|
||||
${externalActionButtons}
|
||||
<h2 class="font-semibold text-5xl">
|
||||
<span class="streamer-name text-indigo-600">${name}</span>
|
||||
</h2>
|
||||
@@ -567,9 +629,9 @@ export default class App extends Component {
|
||||
class="stream-summary my-4"
|
||||
dangerouslySetInnerHTML=${{ __html: summary }}
|
||||
></div>
|
||||
<ul id="tag-list" class="tag-list flex flex-row flex-wrap my-4">
|
||||
${tagList}
|
||||
</ul>
|
||||
<div id="tag-list" class="tag-list text-gray-600 mb-3">
|
||||
${tagList && `#${tagList}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -592,6 +654,7 @@ export default class App extends Component {
|
||||
chatInputEnabled=${chatInputEnabled && !chatDisabled}
|
||||
instanceTitle=${name}
|
||||
/>
|
||||
${externalActionModal}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
86
webroot/js/components/external-action-modal.js
Normal file
86
webroot/js/components/external-action-modal.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { h } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
export default function ExternalActionModal({ url, title, onClose }) {
|
||||
return html`
|
||||
<div class="fixed inset-0 overflow-y-auto" style="z-index: 9999">
|
||||
<div
|
||||
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
|
||||
>
|
||||
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||
<div
|
||||
onClick=${() => onClose()}
|
||||
class="absolute inset-0 bg-gray-900 bg-opacity-75"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span
|
||||
class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>​</span
|
||||
>
|
||||
<div
|
||||
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:align-middle w-screen md:max-w-2xl lg:max-w-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
>
|
||||
<div class="bg-white ">
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex justify-between items-center"
|
||||
>
|
||||
<h3 class="font-bold hidden md:block">${title}</h3>
|
||||
<span class="" onclick=${onClose}>
|
||||
<svg
|
||||
class="h-12 w-12 fill-current text-grey hover:text-grey-darkest"
|
||||
role="button"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<title>Close</title>
|
||||
<path
|
||||
d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Show a loading spinner while the iframe loads -->
|
||||
<iframe
|
||||
style="height: 70vh"
|
||||
width="100%"
|
||||
allowpaymentrequest="true"
|
||||
allowfullscreen="false"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
src=${url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ExternalActionButton({ index, action, onClick }) {
|
||||
const { title, icon, color = undefined } = action;
|
||||
const logo =
|
||||
icon &&
|
||||
html`
|
||||
<span class="external-action-icon"><img src=${icon} alt="" /></span>
|
||||
`;
|
||||
const bgcolor = color && { backgroundColor: `${color}` };
|
||||
const handleClick = () => onClick(index);
|
||||
return html`
|
||||
<button
|
||||
class="external-action-button rounded-sm flex flex-row justify-center items-center overflow-hidden bg-gray-800"
|
||||
data-index=${index}
|
||||
onClick=${handleClick}
|
||||
style=${bgcolor}
|
||||
>
|
||||
${logo}
|
||||
<span class="external-action-label">${title}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -82,6 +82,37 @@ header {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#external-actions-container {
|
||||
margin: 1em 0;
|
||||
|
||||
}
|
||||
.external-action-button {
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: .88em;
|
||||
padding: .25em .75em;
|
||||
margin: .35em;
|
||||
margin-left: 0;
|
||||
display: flex;
|
||||
max-width: 250px;
|
||||
padding-top: .3em;
|
||||
}
|
||||
.external-action-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.external-action-icon {
|
||||
margin: .25em .5em .25em 0;
|
||||
}
|
||||
.external-action-icon img {
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
}
|
||||
.external-action-label {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ************************************************ */
|
||||
|
||||
@@ -245,3 +276,7 @@ header {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
iframe{
|
||||
background:url(/img/loading.gif) center center no-repeat; height: 100%;
|
||||
}
|
||||
Reference in New Issue
Block a user