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:
parent
84f74f0353
commit
3fb80554ef
@ -452,6 +452,26 @@ func SetChatDisabled(w http.ResponseWriter, r *http.Request) {
|
||||
controllers.WriteSimpleResponse(w, true, "chat disabled status updated")
|
||||
}
|
||||
|
||||
// SetExternalActions will set the 3rd party actions for the web interface.
|
||||
func SetExternalActions(w http.ResponseWriter, r *http.Request) {
|
||||
type externalActionsRequest struct {
|
||||
Value []models.ExternalAction `json:"value"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var actions externalActionsRequest
|
||||
if err := decoder.Decode(&actions); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update external actions with provided values")
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetExternalActions(actions.Value); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update external actions with provided values")
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "external actions update")
|
||||
}
|
||||
|
||||
func requirePOST(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Method != controllers.POST {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
|
@ -53,6 +53,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||
InstanceURL: data.GetServerURL(),
|
||||
},
|
||||
S3: data.GetS3Config(),
|
||||
ExternalActions: data.GetExternalActions(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -73,6 +74,7 @@ type serverConfigAdminResponse struct {
|
||||
LatencyLevel int `json:"latencyLevel"`
|
||||
YP yp `json:"yp"`
|
||||
ChatDisabled bool `json:"chatDisabled"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
}
|
||||
|
||||
type videoSettings struct {
|
||||
|
@ -22,6 +22,7 @@ type webConfigResponse struct {
|
||||
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
|
||||
SocialHandles []models.SocialHandle `json:"socialHandles"`
|
||||
ChatDisabled bool `json:"chatDisabled"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
}
|
||||
|
||||
// GetWebConfig gets the status of the server.
|
||||
@ -50,6 +51,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
|
||||
StreamTitle: data.GetStreamTitle(),
|
||||
SocialHandles: socialHandles,
|
||||
ChatDisabled: data.GetChatDisabled(),
|
||||
ExternalActions: data.GetExternalActions(),
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(configuration); err != nil {
|
||||
|
@ -34,6 +34,7 @@ const s3StorageConfigKey = "s3_storage_config"
|
||||
const videoLatencyLevel = "video_latency_level"
|
||||
const videoStreamOutputVariantsKey = "video_stream_output_variants"
|
||||
const chatDisabledKey = "chat_disabled"
|
||||
const externalActionsKey = "external_actions"
|
||||
|
||||
// GetExtraPageBodyContent will return the user-supplied body content.
|
||||
func GetExtraPageBodyContent() string {
|
||||
@ -424,6 +425,27 @@ func GetChatDisabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetExternalActions will return the registered external actions.
|
||||
func GetExternalActions() []models.ExternalAction {
|
||||
configEntry, err := _datastore.Get(externalActionsKey)
|
||||
if err != nil {
|
||||
return []models.ExternalAction{}
|
||||
}
|
||||
|
||||
var externalActions []models.ExternalAction
|
||||
if err := configEntry.getObject(&externalActions); err != nil {
|
||||
return []models.ExternalAction{}
|
||||
}
|
||||
|
||||
return externalActions
|
||||
}
|
||||
|
||||
// SetExternalActions will save external actions.
|
||||
func SetExternalActions(actions []models.ExternalAction) error {
|
||||
var configEntry = ConfigEntry{Key: externalActionsKey, Value: actions}
|
||||
return _datastore.Save(configEntry)
|
||||
}
|
||||
|
||||
// VerifySettings will perform a sanity check for specific settings values.
|
||||
func VerifySettings() error {
|
||||
if GetStreamKey() == "" {
|
||||
|
17
models/externalAction.go
Normal file
17
models/externalAction.go
Normal file
@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
// ExternalAction is a link that will open as a 3rd party action.
|
||||
type ExternalAction struct {
|
||||
// URL is the URL to load.
|
||||
URL string `json:"url"`
|
||||
// Title is the name of this action, displayed in the modal.
|
||||
Title string `json:"title"`
|
||||
// Description is the description of this action.
|
||||
Description string `json:"description"`
|
||||
// Icon is the optional icon for the button associated with this action.
|
||||
Icon string `json:"icon"`
|
||||
// Color is the optional color for the button associated with this action.
|
||||
Color string `json:"color"`
|
||||
// OpenExternally states if the action should open a new tab/window instead of an internal modal.
|
||||
OpenExternally bool `json:"openExternally"`
|
||||
}
|
@ -150,6 +150,7 @@ func Start() error {
|
||||
|
||||
// Connected clients
|
||||
http.HandleFunc("/api/integrations/clients", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetConnectedClients))
|
||||
|
||||
// Logo path
|
||||
http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogoPath))
|
||||
|
||||
@ -189,6 +190,9 @@ func Start() error {
|
||||
// reset the YP registration
|
||||
http.HandleFunc("/api/admin/yp/reset", middleware.RequireAdminAuth(admin.ResetYPRegistration))
|
||||
|
||||
// set external action links
|
||||
http.HandleFunc("/api/admin/config/externalactions", middleware.RequireAdminAuth(admin.SetExternalActions))
|
||||
|
||||
port := config.WebServerPort
|
||||
|
||||
log.Infof("Web server is listening on port %d.", port)
|
||||
|
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%;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user