Compare commits

...
14 Commits
Author SHA1 Message Date
gisle 268f906ea4 show config. 2024-08-17 22:24:14 +02:00
gisle 44a98c8823 add support for env config. 2024-08-17 13:43:03 +02:00
gisle fe44300c0a fix date wonk
continuous-integration/drone/push Build is passing
2022-11-16 18:36:05 +01:00
gisle 2c7ba6df1d Merge branch 'master' of ssh+git://git.aiterp.net/rpdata/frontend
continuous-integration/drone/push Build is passing
2021-04-24 12:07:05 +02:00
gisle 283ffc64b2 Fix displaying of character hint posts. 2021-04-24 12:06:59 +02:00
gisle 2486c98da7 add support for trustedProxy GATEWAY to determine automatically from gateway. That's useful in docker where an nginx on the host is not always on a known ip.
continuous-integration/drone/push Build is passing
2021-04-02 15:04:28 +02:00
gisle 5955e6a71e remove Files page
continuous-integration/drone/push Build is passing
2021-03-30 19:58:27 +02:00
gisle 59a711f150 fix unnamed stories being unclickable.
continuous-integration/drone/push Build is passing
2020-11-29 01:28:56 +01:00
gisle b75c3461f7 add irccloud log import options
continuous-integration/drone/push Build is passing
2020-08-20 21:02:57 +02:00
gisle a10b29a0ea fix files page not working when logged out.
continuous-integration/drone/push Build is passing
2020-08-09 16:51:06 +02:00
gisle 79c3a76300 update drone ci
continuous-integration/drone/push Build is passing
2020-08-09 16:24:02 +02:00
gisle 6aef0412e8 the last few things. 2020-08-09 16:12:12 +02:00
gisle 5fe56eaae8 UI for file stuff.
the build was successful
2019-09-15 16:34:40 +02:00
gisle bb2ccf6f4b Fixed chapter moves crashing history page due to changes in change reporting in 2.0.
the build was successful
2019-08-25 16:01:05 +02:00
56 changed files with 8597 additions and 257 deletions
+29 -21
View File
@@ -1,25 +1,33 @@
pipeline:
docker-trunk:
name: rpdata-frontend
kind: pipeline
type: docker
steps:
- name: docker-release-tag
image: plugins/docker
repo: vmaple/rpdata-frontend
tags: trunk
secrets: [ docker_username, docker_password ]
settings:
auto_tag: true
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: r.vmaple.dev/rpdata/rpdata-frontend
registry: r.vmaple.dev
when:
event: push
branch: master
docker-release:
event:
- tag
- name: docker-release-latest
image: plugins/docker
repo: vmaple/rpdata-frontend
auto_tag: true
secrets: [ docker_username, docker_password ]
settings:
auto_tag: false
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: r.vmaple.dev/rpdata/rpdata-frontend
registry: r.vmaple.dev
when:
event: tag
ref: refs/tags/*
docker-release-latest:
image: plugins/docker
repo: vmaple/rpdata-frontend
tags: latest
secrets: [ docker_username, docker_password ]
when:
event: tag
ref: refs/tags/*
event:
- tag
+1
View File
@@ -12,6 +12,7 @@ prebuild.run({
"./marko/page/data/channels.marko",
"./marko/page/data/characters.marko",
"./marko/page/data/changes.marko",
"./marko/page/data/files.marko",
"./marko/page/story-content/view.marko",
"./marko/page/logs-content/view.marko",
]
+31 -1
View File
@@ -1,6 +1,6 @@
const fs = require("fs")
const config = {
let config = {
graphqlEndpoint: "http://127.0.0.1:17000/graphql",
playgroundEndpoint: "http://127.0.0.1:17000/playground",
port: 8080,
@@ -35,6 +35,31 @@ if (typeof(window) !== "undefined") {
delete config.redis
delete config.backend
} else {
config = {
graphqlEndpoint: process.env.RPDATA_API_GRAPHQL || "http://127.0.0.1:17000/graphql",
playgroundEndpoint: process.env.RPDATA_API_PLAYGROUND || "http://127.0.0.1:17000/playground",
port: Number(process.env.PORT || "8080"),
root: process.env.ROOT || "http://localhost:8080",
trustedProxy: process.env.TRUSTED_PROXY || null,
auth0: {
secret: process.env.AUTH0_SECRET || "INVALID_SECRET",
clientId: process.env.AUTH0_CLIENT_ID || "",
domain: process.env.AUTH0_DOMAIN || "aiterp.eu.auth0.com",
},
session: {
secret: process.env.SESSION_SECRET || "",
secure: process.env.SESSION_SECURE === "true",
},
redis: {
host: process.env.REDIS_HOST || "127.0.0.1",
port: process.env.REDIS_PORT || 6379,
},
backend: {
kid: process.env.RPDATA_KID || "",
secret: process.env.RPDATA_SECRET || "",
}
}
try {
const data = fs.readFileSync("/etc/aiterp/rpdata-frontend.json", "utf8")
const parsed = JSON.parse(data)
@@ -44,6 +69,11 @@ if (typeof(window) !== "undefined") {
} catch(err) {
console.error("Failed to load configuration: " + err.message || err.stack || err)
}
console.log("Config/Auth0", config.auth0.clientId, config.auth0.domain);
console.log("Config/API", config.graphqlEndpoint, config.backend.kid);
console.log("Config/Redis", config.redis.host, config.redis.port);
console.log("Config/Root", config.root);
}
module.exports = config
@@ -1,3 +1,3 @@
<tr class="compact-list-item color-highlight-dark">
<tr class=["compact-list-item", input.highlight ? "color-highlight-primary" : "color-highlight-dark"]>
<include(input.renderBody)/>
</tr>
@@ -3,3 +3,21 @@ tr.compact-list-item {
outline: 0.05px dotted;
vertical-align: middle;
}
tr.compact-list-item.color-highlight-primary {
a {
color: #FFF !important;
}
label {
color: #CCC !important;
}
div.label {
color: #AA8 !important;
}
div.value.color-text {
color: #FFF !important;
}
}
-1
View File
@@ -1,6 +1,5 @@
table.compact-list {
padding-top: 0.5em;
padding-bottom: 3em;
width: 90%;
max-width: 100ch;
@@ -2,7 +2,7 @@
<td class="icon color-highlight-primary">${input.icon}</td>
<td class="content color-primary color-highlight-dark">
<div class="title">
<a class="color-primary" href=input.link>${input.name}</a>
<a class="color-primary" href=input.link>${input.name || "(Untitled)"}</a>
</div>
<div if(input.description) class="description color-text">
<div class="content">${input.description}</div>
@@ -0,0 +1,61 @@
const moment = require("moment")
const {fileApi} = require("../../../../../rpdata/api/File")
module.exports = class {
onCreate(input) {
this.state = {
error: null,
loading: false,
values: {
file: null,
name: "",
public: false,
realFile: null,
}
}
}
change(key, ev) {
if (key === "file") {
this.state.values = Object.assign({}, this.state.values, {
[key]: ev.target.value,
realFile: ev.target.files[0],
name: ev.target.files[0].name,
})
} else {
this.state.values = Object.assign({}, this.state.values, {[key]: ev.target.value})
}
}
open() {
}
close() {
this.emit("close")
}
save() {
if (this.state.loading) {
return
}
const input = Object.assign({}, this.state.values)
input.file = input.realFile
delete input.realFile
this.state.loading = true
fileApi.uploadFile(input).then((res) => {
this.emit("added", res)
this.emit("close")
}).catch(errs => {
console.warn("Failed to add:", errs)
this.state.error = "Failed to add: " + JSON.stringify(errs)
}).then(() => {
this.state.loading = false
})
}
}
@@ -0,0 +1,19 @@
<modal class="modal color-text nolabel" key="modal" enabled=(input.enabled) closable on-close("close") on-open("open") >
<h1>Upload File</h1>
<p class="color-error">${state.error}</p>
<label>File</label>
<input key="eventName" type="file" placeholder="(None)" on-change("change", "file") value=state.values.file />
<label>Name</label>
<input key="name" autofocus placeholder="(Required)" class="big" on-change("change", "name") value=state.values.name />
<label>Public</label>
<toggle value=state.values.public on="On" off="Off"
onDesc="Other members may see this file on this page."
offDesc="Only you may see this file on this page."
on-change("change", "public") />
<button disabled=state.loading on-click("save")>Save</button>
</modal>
+16 -2
View File
@@ -65,8 +65,8 @@ module.exports = class {
this.state.operation += " chapter"
this.state.links = [{
text: chapter.title || `Untitled chapter (${chapterKey.id})`,
href: `/story/${storyKey.id}#${chapterKey.id}`,
text: (chapter||{}).title || `Untitled chapter (${(chapterKey||chapter).id})`,
href: `/story/${storyKey.id}#${(chapterKey||chapter).id}`,
}]
break
@@ -99,6 +99,19 @@ module.exports = class {
break
}
case "File": {
const key = data.keys.find(k => k.id !== "*")
const fileObj = data.objects.find(o => o.type === "File") || {name: key.id}
this.state.operation += " file"
this.state.links = [{
text: fileObj.name,
href: `/data/files#${key.id}`,
}]
break
}
case "Post": {
const logKey = data.keys.find(k => k.model === "Log")
const postKeys = data.keys.filter(k => k.model === "Post")
@@ -124,6 +137,7 @@ module.exports = class {
const opText = {
add: "added",
remove: "removed",
upload: "uploaded",
move: "moved",
edit: "edited",
import: "imported",
@@ -1,11 +1,27 @@
const { changesApi } = require("../../../../../rpdata/api/Change")
module.exports = class {
onCreate(input) {
this.state = {
characters: input.characters,
changes: input.changes,
shownAll: false,
modal: null,
}
}
clearLimit(e) {
// Disable mobile URL fallback.
if (e && e.preventDefault) {
e.preventDefault()
}
changesApi.list({limit: 0}).then(changes => {
this.state.changes = changes
this.state.shownAll = true
})
}
open(modal) {
this.state.modal = modal
}
@@ -3,10 +3,12 @@
<div class="changes-page">
<h1 class="color-primary">Changes</h1>
<p>All changes to <em>listed</em> resources is stored for 90 days for transparency reasons. The current state of an object after each change exists, but is only available through the GraphQL API at this time.</p>
<for (change in input.changes)>
<for (change in state.changes)>
<change data=change />
</for>
<a if(!state.shownAll) href="/data/changes?limit=0" class="show-more-button" on-click("clearLimit")>Show All</a>
</div>
</main>
<add-character-modal enabled=(state.modal === "character.add") user=input.user on-added("characterAdded") on-close("close") />
<add-channel-modal enabled=(state.modal === "channel.add") user=input.user on-close("close") />
<add-file-modal enabled=(state.modal === "file.add") user=input.user on-close("close") />
@@ -33,4 +33,21 @@
opacity: 1;
}
}
.show-more-button {
margin: 1em 0;
padding: 0.5em 2em;
font-size: 2em;
text-align: center;
user-select: none;
display: block;
color: #DA1;
}
.show-more-button:hover {
cursor: pointer;
text-decoration: underline;
}
}
@@ -4,3 +4,4 @@
</main>
<add-channel-modal enabled=(state.modal === "channel.add") user=input.user on-added("channelAdded") on-close("close") />
<add-character-modal enabled=(state.modal === "character.add") user=input.user on-close("close") />
<add-file-modal enabled=(state.modal === "file.add") user=input.user on-close("close") />
@@ -31,6 +31,15 @@ module.exports = class {
}
characterAdded(character) {
const index = this.state.characters.findIndex(c => c.id === character.id)
if (index !== -1) {
const characters = this.state.characters.slice()
characters[index] = character
this.state.characters = characters
return
}
this.state.characters = this.state.characters.concat(character)
}
@@ -4,3 +4,4 @@
</main>
<add-character-modal enabled=(state.modal === "character.add") user=input.user on-added("characterAdded") on-close("close") />
<add-channel-modal enabled=(state.modal === "channel.add") user=input.user on-close("close") />
<add-file-modal enabled=(state.modal === "file.add") user=input.user on-close("close") />
@@ -3,14 +3,14 @@
<menu-link key="characters" selected=input.selected.characters icon="C" href="/data/characters/">Characters</menu-link>
<menu-link key="channels" selected=input.selected.channels icon="#" href="/data/channels/">Channels</menu-link>
<menu-link key="history" selected=input.selected.changes icon="H" href="/data/changes/">History</menu-link>
<if-permitted user=input.user permission=["member", "character.add", "channel.add"]>
<if-permitted user=input.user permission=["member", "character.add", "channel.add", "file.add"]>
<menu-gap />
<menu-header>Add</menu-header>
<if-permitted user=input.user permission=["character.add", "member"]>
<menu-link dark key="characters_add" icon="+" on-click("emit", "open", "character.add")>Character</menu-link>
<if-permitted key="characters_add" user=input.user permission=["character.add", "member"]>
<menu-link dark icon="+" on-click("emit", "open", "character.add")>Character</menu-link>
</if-permitted>
<if-permitted user=input.user permission="channel.add">
<menu-link dark key="channels_add" icon="+" on-click("emit", "open", "channel.add")>Channel</menu-link>
<if-permitted key="channels_add" user=input.user permission="channel.add">
<menu-link dark icon="+" on-click("emit", "open", "channel.add")>Channel</menu-link>
</if-permitted>
</if-permitted>
</menu>
@@ -0,0 +1,59 @@
const moment = require("moment")
const {fileApi} = require("../../../../../rpdata/api/File")
module.exports = class {
onCreate(input) {
this.state = {
error: null,
loading: false,
values: {
id: "",
name: "",
public: false,
}
}
}
onInput(input) {
this.state.values = {
id: input.file.id,
name: input.file.name,
public: input.file.public,
}
}
change(key, ev) {
this.state.values = Object.assign({}, this.state.values, {[key]: ev.target.value})
}
open() {
}
close() {
this.emit("close")
}
save() {
if (this.state.loading) {
return
}
const input = Object.assign({}, this.state.values)
input.file = input.realFile
delete input.realFile
this.state.loading = true
fileApi.editFile(input).then((res) => {
this.emit("edited", res)
this.emit("close")
}).catch(errs => {
console.warn("Failed to add:", errs)
this.state.error = "Failed to add: " + JSON.stringify(errs)
}).then(() => {
this.state.loading = false
})
}
}
@@ -0,0 +1,16 @@
<modal class="modal color-text nolabel" key="modal" enabled=(input.enabled) closable on-close("close") on-open("open") >
<h1>Upload File</h1>
<p class="color-error">${state.error}</p>
<label>Name</label>
<input key="name" autofocus placeholder="(Required)" class="big" on-change("change", "name") value=state.values.name />
<label>Public</label>
<toggle value=state.values.public on="On" off="Off"
onDesc="Other members may see this file on this page."
offDesc="Only you may see this file on this page."
on-change("change", "public") />
<button disabled=state.loading on-click("save")>Save</button>
</modal>
@@ -0,0 +1,7 @@
module.exports = class {
isHighlighted(id) {
if (typeof(window) !== "undefined") {
return window.location.hash.endsWith(id)
}
}
}
@@ -0,0 +1,9 @@
<compact-list>
<compact-list-item for(file in input.files) key=file.id id=file.id highlight=component.isHighlighted(file.id)>
<file data=file
user=input.user
on-removed("emit", "removed", file)
on-edited("emit", "edited", file)
/>
</compact-list-item>
</compact-list>
@@ -0,0 +1,38 @@
const moment = require("moment")
module.exports = class {
onCreate(input) {
this.state = {
modal: null,
deleted: false,
date: moment(input.data.time).format("MMM D, YYYY"),
}
}
onInput(input) {
if (input && input.data) {
this.state.date = moment(input.data.time).format("MMM D, YYYY")
}
}
open(modal) {
this.state.modal = modal
}
close() {
this.state.modal = null
}
removed() {
this.state.deleted = true
this.emit("removed")
}
sizeString(bytes) {
const order = Math.floor(Math.log2(bytes) / 10)
const unit = ["B", "KB", "MB", "GB", "TB", "PB"][order]
const number = bytes / Math.pow(2, (order)*10)
return `${number.toFixed(2)} ${unit}`
}
}
@@ -0,0 +1,22 @@
<if (!state.deleted)>
<a class="anchor" id=input.data.id />
<compact-list-property primary long label="Name" value=input.data.name />
<compact-list-property short label="Date" value=state.date />
<compact-list-property short label="Type" value=input.data.mimeType />
<compact-list-property short label="Author" value=input.data.author />
<compact-list-property short label="Size" value=component.sizeString(input.data.size) />
<compact-list-options long>
<a key="link" class="color-menu" on-click("open", "link")>Link</a>
<if(input.data.kind === "upload")>
<if-permitted user=input.user author=input.data.author permission="file.edit">
<a key="edit" on-click("open", "edit", input.data)>Edit</a>
</if-permitted>
<if-permitted user=input.user author=input.data.author permission="file.remove">
<a key="remove" on-click("open", "remove", input.data)>Remove</a>
</if-permitted>
</if>
</compact-list-options>
<link-file-modal enabled=(state.modal === "link") file=input.data on-close("close") />
<edit-file-modal enabled=(state.modal === "edit") file=input.data on-edited("emit", "edited") on-close("close") />
<remove-file-modal enabled=(state.modal === "remove") file=input.data on-removed("removed") on-close("close") />
</if>
@@ -0,0 +1,47 @@
module.exports = class {
onCreate(input) {
this.state = {
publics: input.publics.sort(byDate),
privates: input.privates.sort(byDate),
modal: null,
}
}
onMount() {
}
open(modal) {
this.state.modal = modal
}
close() {
this.state.modal = null
}
fileRemoved({id}) {
this.state.publics = this.state.publics.filter(f => f.id !== id)
this.state.privates = this.state.privates.filter(f => f.id !== id)
}
fileEdited(file, patch) {
this.fileRemoved({id: file.id})
this.fileAdded(Object.assign({}, file, patch))
console.log(Object.assign({}, file, patch))
}
fileAdded(file) {
if (file.public) {
this.state.publics = [].concat(...this.state.publics).concat(file).sort(byDate)
} else {
this.state.privates = [].concat(...this.state.privates).concat(file).sort(byDate)
}
}
}
function byDate(a, b) {
const ad = new Date(a.time)
const bd = new Date(b.time)
return ad.getTime() - bd.getTime()
}
@@ -0,0 +1,11 @@
<data-menu categories=input.categories selected=(input.selected || {}) user=input.user on-open("open") />
<main class="data-file-page">
<h1 if(state.privates && state.privates.length > 0) class="file-list-headers color-primary">Your Private Files</h1>
<file-list user=input.user files=state.privates on-removed("fileRemoved") on-edited("fileEdited") on-nicks("fileNicksChanged") />
<h1 if(state.publics && state.publics.length > 0) class="file-list-headers color-primary">Public Files</h1>
<file-list user=input.user files=state.publics on-removed("fileRemoved") on-edited("fileEdited") on-nicks("fileNicksChanged") />
</main>
<add-channel-modal enabled=(state.modal === "channel.add") user=input.user on-close("close") />
<add-character-modal enabled=(state.modal === "character.add") user=input.user on-close("close") />
<add-file-modal enabled=(state.modal === "file.add") user=input.user on-added("fileAdded") on-close("close") />
@@ -0,0 +1,10 @@
h1.file-list-headers {
margin: 0;
font-size: 1.5em;
margin-top: 0.5em;
text-align: center;
}
main.data-file-page {
margin-bottom: 4em;
}
@@ -0,0 +1,19 @@
module.exports = class {
onCreate(input) {
this.state = {
isImage: (input.file && input.file.mimeType.startsWith("image/"))
}
}
onInput(input) {
this.state.isImage = (input.file && input.file.mimeType.startsWith("image/"))
}
open() {
}
close() {
this.emit("close")
}
}
@@ -0,0 +1,36 @@
<modal class="modal color-text nolabel link-file-modal" key="modal" enabled=(input.enabled) closable on-close("close") on-open("open") >
<h1>Links for ${input.file.name}</h1>
<p>
Below are links and copy-pastable syntax for sharing the file. They can be used for linking to it
in your stories and wiki pages.
</p>
<p class="color-danger">
This is the link to the actual file on the CDN, not a share of it. You must delete the file if you want
to un-share it.
</p>
<if(state.isImage)>
<p class="color-danger">
Images uploaded here cannot be inlined on the wiki. For that you have to upload it there.
</p>
</if>
<label>URL</label>
<input key="url" disabled placeholder="(None)" on-change("change", "eventName") value=input.file.url />
<label>Markdown link</label>
<input key="mdLink" disabled placeholder="(None)" on-change("change", "locationName") value=`[Link text](${input.file.url})` />
<label>Wiki link</label>
<input key="wikiLink" disabled placeholder="(None)" on-change("change", "locationName") value=`[${input.file.url} Link text]` />
<if(state.isImage)>
<label>Markdown image</label>
<input key="mdImage" disabled placeholder="(None)" on-change("change", "locationName") value=`![Alt. text](${input.file.url})` />
<label>Image Preview</label>
<img src=input.file.url alt="File preview"/>
</if>
</modal>
@@ -0,0 +1,19 @@
div.link-file-modal {
user-select: text;
p {
user-select: text;
cursor: text;
}
img {
margin: 0;
padding: 0;
margin-top: 0.25em;
width: 100%;
}
input {
user-select: all !important;
}
}
@@ -0,0 +1,51 @@
const {fileApi} = require("../../../../../rpdata/api/File")
module.exports = class {
onCreate(input) {
this.state = {
error: null,
loading: false,
values: {
id: "",
}
}
}
onInput(input) {
this.state.values = {
id: input.file.id,
}
}
change(key, ev) {
this.state.values = Object.assign({}, this.state.values, {[key]: ev.target.value})
}
open() {
}
close() {
this.emit("close")
}
doIt() {
if (this.state.loading) {
return
}
const input = Object.assign({}, this.state.values)
this.state.loading = true
fileApi.removeFile(input).then((res) => {
this.emit("removed", res)
this.emit("close")
}).catch(errs => {
console.warn("Failed to add:", errs)
this.state.error = "Failed to add: " + JSON.stringify(errs)
}).then(() => {
this.state.loading = false
})
}
}
@@ -0,0 +1,11 @@
<modal class="modal color-text nolabel" key="modal" enabled=(input.enabled) closable on-close("close") on-open("open") >
<h1>Delete File ${input.file.name}</h1>
<p class="color-error">${state.error}</p>
<p class="color-danger">
It might take up to an hour for it to be removed from CDN caches if it's been recently loaded.
</p>
<button disabled=state.loading on-click("doIt")>Remove</button>
</modal>
+6
View File
@@ -0,0 +1,6 @@
<include("../layout", {title: "Data", site: "data"})>
<@body>
<background src="/assets/images/bg.png" opacity=0.25 />
<files-page user=input.user privates=input.privates publics=input.publics selected=input.selected />
</@body>
</include>
@@ -14,6 +14,7 @@
<option value="annotation.info" selected=(state.values.kind === "annotation.info")>Annotation - Info</option>
<option value="annotation.warning" selected=(state.values.kind === "annotation.warning")>Annotation - Warning</option>
<option value="annotation.error" selected=(state.values.kind === "annotation.error")>Annotation - Error</option>
<option value="chars" selected=(state.values.kind === "chars")>Character Hints</option>
</select>
<label>Nick</label>
@@ -14,6 +14,7 @@
<option value="annotation.info" selected=(state.values.kind === "annotation.info")>Annotation - Info</option>
<option value="annotation.warning" selected=(state.values.kind === "annotation.warning")>Annotation - Warning</option>
<option value="annotation.error" selected=(state.values.kind === "annotation.error")>Annotation - Error</option>
<option value="chars" selected=(state.values.kind === "chars")>Character Hints</option>
</select>
<label>Nick</label>
@@ -1,96 +1,10 @@
const {logHeaderApi} = require("../../../../../rpdata/api/LogHeader")
module.exports = class {
onCreate(input) {
this.state = {
id: input.log.id,
channelName: input.log.channelName,
eventName: input.log.eventName,
nexts: [],
prevs: [],
loaded: false,
error: null,
}
}
onMount() {
this.timeout = setTimeout(() => {
logHeaderApi.list().then(logs => {
const index = logs.findIndex(l => l.id === this.state.id);
if (index === -1) {
return Promise.reject("This log not found in list.")
}
const log = logs[index]
const characterIds = log.characters.map(c => c.id);
const nexts = []
const prevs = []
let foundGroups = {}
let foundChannel = false
for (let i = index - 1; i >= 0; --i) {
const log2 = logs[i]
const hasEvent = (log.eventName ? log.eventName === log2.eventName : false)
const hasChannel = (log.channelName === log2.channelName)
const characters = log2.findCharacters(...characterIds)
const characterIdsKey = characters.map(c => c.id).sort().join(",")
const suggestion = {log: log2, characters, hasEvent, hasChannel}
if (hasChannel && !foundChannel) {
foundChannel = true
foundGroups[characterIdsKey] = true
nexts.push(suggestion)
} else if (hasEvent) {
foundGroups[characterIdsKey] = true
nexts.push(suggestion)
} else if (nexts.length < 8 && !hasEvent && characters.length > 1 && !foundGroups[characterIdsKey]) {
foundGroups[characterIdsKey] = true
nexts.push(suggestion)
}
}
foundGroups = {}
foundChannel = false
for (let i = index + 1; i < logs.length; ++i) {
const log2 = logs[i]
const hasEvent = (log.eventName ? log.eventName === log2.eventName : false)
const hasChannel = (log.channelName === log2.channelName)
const characters = log2.findCharacters(...characterIds)
const characterIdsKey = characters.map(c => c.id).sort().join(",")
const suggestion = {log: log2, characters, hasEvent, hasChannel}
if (hasChannel && !foundChannel) {
foundChannel = true
foundGroups[characterIdsKey] = true
prevs.push(suggestion)
} else if (hasEvent) {
foundGroups[characterIdsKey] = true
prevs.push(suggestion)
} else if (prevs.length < 8 && !hasEvent && characters.length > 1 && !foundGroups[characterIdsKey]) {
foundGroups[characterIdsKey] = true
prevs.push(suggestion)
}
}
this.state.nexts = nexts;
this.state.prevs = prevs;
this.state.loaded = true;
}).catch(err => {
console.warn("Suggestions load error:", err)
this.suggestionsError = "Failed to load suggestions: " + err.toString()
})
}, 1000)
}
onUnmount() {
clearTimeout(this.timeout)
}
}
@@ -1,41 +1,33 @@
import moment from "moment"
<div class="log-suggestions">
<if (state.loaded && state.error == null)>
<if (state.nexts.length > 0 || state.prevs.length > 0)>
<if (state.nexts.length > 0)>
<h2 class="color-primary">Next Logs</h2>
<div for(s in state.nexts) class="suggestion color-primary">
<div class="suggestion-header color-primary"><a href=`/logs/${s.log.id}`>${s.log.name || `${s.log.channelName} - ${moment(s.log.date).format("MMMM D, YYYY")}`}</a></div>
<div for(paragraph in (s.log.description||"").split("\n").filter(p => p)) class="suggestion-description color-text">${paragraph}</div>
<div class="suggestion-tags">
<div if(s.hasEvent) class="suggestion-tag color-tag-event">${s.log.eventName}</div>
<div if(s.hasChannel) class="suggestion-tag color-tag-location">${s.log.channelName}</div>
<div for(c in s.characters) class="suggestion-tag color-tag-character">${c.name}</div>
</div>
<if (input.log.nextLogs.length > 0 || input.log.prevLogs.length > 0)>
<if (input.log.nextLogs.length > 0)>
<h2 class="color-primary">Next Logs</h2>
<div for(s in input.log.nextLogs) class="suggestion color-primary">
<div class="suggestion-header color-primary"><a href=`/logs/${s.log.id}`>${s.log.title || `${s.log.channelName} - ${moment(s.log.date).format("MMMM D, YYYY")}`}</a></div>
<div for(paragraph in (s.log.description||"").split("\n").filter(p => p)) class="suggestion-description color-text">${paragraph}</div>
<div class="suggestion-tags">
<div if(s.hasEvent) class="suggestion-tag color-tag-event">${input.log.eventName}</div>
<div if(s.hasChannel) class="suggestion-tag color-tag-location">${s.log.channelName}</div>
<div for(c in s.characters) class="suggestion-tag color-tag-character">${c.name}</div>
</div>
</if>
<if (state.prevs.length > 0)>
<h2 class="color-primary">Previous Logs</h2>
<div for(s in state.prevs) class="suggestion color-primary">
<div class="suggestion-header color-primary"><a href=`/logs/${s.log.id}`>${s.log.name || `${s.log.channelName} - ${moment(s.log.date).format("MMMM D, YYYY")}`}</a></div>
<div for(paragraph in (s.log.description||"").split("\n").filter(p => p)) class="suggestion-description color-text">${paragraph}</div>
<div class="suggestion-tags">
<div if(s.hasEvent) class="suggestion-tag color-tag-event">${s.log.eventName}</div>
<div if(s.hasChannel) class="suggestion-tag color-tag-location">${s.log.channelName}</div>
<div for(c in s.characters) class="suggestion-tag color-tag-character">${c.name}</div>
</div>
</div>
</if>
<if (input.log.prevLogs.length > 0)>
<h2 class="color-primary">Previous Logs</h2>
<div for(s in input.log.prevLogs) class="suggestion color-primary">
<div class="suggestion-header color-primary"><a href=`/logs/${s.log.id}`>${s.log.title || `${s.log.channelName} - ${moment(s.log.date).format("MMMM D, YYYY")}`}</a></div>
<div for(paragraph in (s.log.description||"").split("\n").filter(p => p)) class="suggestion-description color-text">${paragraph}</div>
<div class="suggestion-tags">
<div if(s.hasEvent) class="suggestion-tag color-tag-event">${input.log.eventName}</div>
<div if(s.hasChannel) class="suggestion-tag color-tag-location">${s.log.channelName}</div>
<div for(c in s.characters) class="suggestion-tag color-tag-character">${c.name}</div>
</div>
</if>
</div>
</if>
<else>
<div class="empty color-menu">(No suggestions)</div>
</else>
</if>
<else-if(state.error != null)>
<div class="error color-danger">{state.error}</div>
</else-if>
<else>
<div class="loading color-menu">Loading suggestions...</div>
<div class="empty color-menu">(No suggestions)</div>
</else>
</div>
@@ -22,6 +22,9 @@
<irc-caret-notation class="post-content" source=state.text />
</annotation>
</div>
<div if(input.post.kind === "chars") class="post-body color-menu" >
<p><em>Character hint: ${input.post.text}</em></p>
</div>
<div if(input.post.kind === "scene") class="post-body color-text">
<irc-caret-notation class="post-content post-scene" source=state.text />
</div>
@@ -14,6 +14,7 @@ module.exports = class {
channelName: "",
date: "",
data: "",
sessionThresholdHours: 12,
},
timezones: tz.names(),
}
@@ -58,12 +59,24 @@ module.exports = class {
return
}
} else if (input.importer === "MircLike") {
this.state.error = "Date is required for mIRC-like logs"
this.state.error = "Date is required for mIRC-like logs."
return
} else {
input.date = null;
}
if (input.importer === "IRCCloudLog") {
const hours = parseFloat(input.sessionThresholdHours)
if (Number.isNaN(hours)) {
this.state.error = "The session change threshold must be a number."
return
}
input.sessionThresholdMs = Math.floor(hours * 3600000)
}
delete input.sessionThresholdHours
if (input.channelName.length < 2 || input.channelName.includes(" ") || input.channelName.includes(" ") || input.channelName.charAt(0) !== "#") {
this.state.error = "A valid channelName name is required"
return
@@ -10,6 +10,7 @@ import moment from "moment"
<select key="importer" autofocus class="big" placeholder="Kind" on-change("change", "importer") value=state.values.importer>
<option value="MircLike" selected=(state.values.importer === "MircLike")>mIRC-Like log</option>
<option value="ForumLog" selected=(state.values.importer === "ForumLog")>Forum Log</option>
<option value="IrcCloud" selected=(state.values.importer === "IrcCloud")>IRCCloud log export</option>
</select>
<label>Timezone</label>
@@ -25,6 +26,11 @@ import moment from "moment"
<input key="date" class="big" placeholder="(Required)" on-change("change", "date") value=state.values.date />
</if>
<if(state.values.importer === "IrcCloud")>
<label>Session Change Threshold (Hours)</label>
<input key="sessionThreshold" class="big" placeholder="(Required)" on-change("change", "sessionThresholdHours") value=state.values.sessionThresholdHours />
</if>
<label>Content</label>
<textarea key="data" placeholder="(Required)" class="tall" on-change("change", "data") value=state.values.data />
+6 -1
View File
@@ -79,9 +79,14 @@ module.exports = class {
}
}
clearLimit() {
clearLimit(e) {
this.state.filter = Object.assign({}, this.state.filter, {limit: 0})
// Disable mobile URL fallback.
if (e && e.preventDefault) {
e.preventDefault()
}
this.refresh()
this.updateQuery(this.state.filter)
}
+2 -2
View File
@@ -4,9 +4,9 @@
<main class="logs-page with-right">
<logs-table logs=state.logs on-addfilter("addFilter") />
<if(state.filter.limit > 0)>
<div class="show-more-button" on-click("clearLimit")>
<a href="/logs?limit=0" class="show-more-button" on-click("clearLimit")>
<span>Show All</span>
</div>
</a>
</if>
</main>
<add-log-modal enabled=(state.modal === "log.add") on-close("close") />
+3 -2
View File
@@ -1,15 +1,16 @@
main.logs-page {
div.show-more-button {
.show-more-button {
margin: 1em 0;
padding: 0.5em 2em;
font-size: 2em;
text-align: center;
user-select: none;
display: block;
color: #DA1;
}
div.show-more-button:hover {
.show-more-button:hover {
cursor: pointer;
text-decoration: underline;
@@ -31,7 +31,7 @@ module.exports = class {
let fictionalDate = new Date(comment.fictionalDate)
if (comment.fictionalDate && !Number.isNaN(fictionalDate.getTime()) && fictionalDate.getTime() !== 0) {
fictionalDate = moment(fictionalDate).format("MMM D, YYYY")
fictionalDate = moment.utc(fictionalDate).format("MMM D, YYYY")
} else {
fictionalDate = ""
}
+2 -1
View File
@@ -7,7 +7,8 @@ const strategy = new Auth0Strategy({
domain: config.auth0.domain,
clientID: config.auth0.clientId,
clientSecret: config.auth0.secret, // Replace this with the client secret for your app
callbackURL: config.root + "/auth/callback"
callbackURL: config.root + "/auth/callback",
state: true,
}, (accessToken, refreshToken, extraParams, profile, done) => {
// accessToken is the token to call Auth0 API (not needed in the most cases)
// extraParams.id_token has the JSON Web Token
+1 -1
View File
@@ -6,7 +6,7 @@ module.exports = session({
secret: config.session.secret,
store: new RedisStore(config.redis),
cookie: {
secure: config.session.secure
secure: config.session.secure,
},
resave: false,
saveUninitialized: true,
+7686 -62
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -30,15 +30,15 @@
"irc-caret-notation": "^1.0.0",
"isomorphic-fetch": "^2.2.1",
"jsonwebtoken": "^8.3.0",
"lasso": "^3.2.6",
"lasso": "^3.3.1",
"lasso-babel-transform": "^1.0.2",
"lasso-less": "^3.0.1",
"lasso-marko": "^2.4.4",
"lasso-marko": "^2.4.7",
"less": "^3.8.1",
"markdown-it": "^8.4.2",
"markdown-it-container": "^2.0.0",
"markdown-it-regexp": "^0.4.0",
"marko": "^4.13.8",
"marko": "^4.18.13",
"moment": "^2.22.2",
"moment-timezone": "^0.5.23",
"passport": "^0.4.0",
+9 -5
View File
@@ -17,12 +17,16 @@ router.get("/login", saveReferer, passport.authenticate("auth0", {scope: "openid
res.redirect("/")
})
router.get("/callback", passport.authenticate("auth0"), (req, res) => {
if (req.user == null) {
throw new Error("user null");
}
router.get("/callback", function (req, res, next) {
passport.authenticate("auth0", function (err, user, info) {
if (err) { return next(err) }
if (!user) { console.log(err, user, info); return res.redirect("/auth/login") }
req.logIn(user, function (err) {
if (err) { return next(err) }
res.redirect(req.session.loginReferrer || "/");
res.redirect(req.session.loginReferrer || "/");
})
})(req, res, next);
})
router.get("/user", (req, res) => {
+3 -1
View File
@@ -6,8 +6,10 @@ const {changesApi} = require("../../rpdata/api/Change")
const changesTemplate = require("../../marko/page/data/changes.marko")
router.get("/", async(req, res) => {
const limit = parseInt(req.query.limit) || 100
res.markoAsync(changesTemplate, {
changes: changesApi.list({limit: 2000}),
changes: changesApi.list({limit}),
selected: {changes: true},
})
})
+22
View File
@@ -0,0 +1,22 @@
const express = require("express")
const router = express.Router()
const {generateToken} = require("../graphql")
const {fileApi} = require("../../rpdata/api/File")
const filesTemplate = require("../../marko/page/data/files.marko")
router.get("/", async(req, res) => {
let privates = []
if (res.locals.user && res.locals.user.loggedIn) {
privates = fileApi.list({public: false}, {token: generateToken(res.locals.user.name)})
}
res.markoAsync(filesTemplate, {
privates: privates,
publics: fileApi.list({public: true}),
selected: {files: true},
})
})
module.exports = router
+6 -12
View File
@@ -3,16 +3,10 @@ const express = require("express")
const jwt = require("jsonwebtoken")
const config = require("../config")
const { query } = require("../rpdata/client")
const router = express.Router()
router.post("/", (req, res) => {
if (!req.header("Content-Type").startsWith("application/json")) {
res.status(400).json({errors: [{message: "Incorrect input type, expected application/json"}]})
return
}
const user = res.locals.user
const permissions = (req.header("X-Permissions") || "").split(",").filter(t => t != "" && t != "undefined" && t != "null")
let authorization = req.header("Authorization")
@@ -20,17 +14,17 @@ router.post("/", (req, res) => {
authorization = ""
}
if (!authorization && permissions.length > 0 && user.loggedIn) {
if (!authorization && user.loggedIn) {
authorization = `Bearer ${generateToken(user.name, permissions)}`
}
fetch(config.graphqlEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": req.header("Content-Type"),
"Authorization": authorization,
},
body: JSON.stringify(req.body),
body: req,
credentials: "include",
}).then(fetchRes => {
res.setHeader("Content-Type", fetchRes.headers.get("Content-Type"))
@@ -51,8 +45,8 @@ router.use("/", proxy(config.graphqlEndpoint, {ws: true}))
* @param {string} user
* @param {string[]} permissions
*/
function generateToken(user, permissions) {
return jwt.sign({user, permissions, exp: Math.floor((Date.now() / 1000) + 1200)}, config.backend.secret, {header: {kid: config.backend.kid}})
function generateToken(user) {
return jwt.sign({user, exp: Math.floor((Date.now() / 1000) + 1200)}, config.backend.secret, {header: {kid: config.backend.kid}})
}
module.exports = router
module.exports = {router, generateToken}
+5
View File
@@ -48,6 +48,11 @@ class ChangesAPI {
objects {
type: __typename
...on File {
id
name
}
...on Character {
id
name
+110
View File
@@ -0,0 +1,110 @@
const {query, queryForm} = require("../client")
class File {
/**
* @param {{id:string,kind:string,time:Date|string|number,public:boolean,name:string,mimeType:string,size:number,author:string,url:string}} data
*/
constructor(data) {
this.id = data.id
this.kind = data.kind
this.time = new Date(data.time)
this.public = data.public
this.name = data.name
this.mimeType = data.mimeType
this.size = data.size
this.author = data.author
this.url = data.url
}
}
class FileAPI {
/**
* Call `characters(filter)` query
*
* @param {{public:boolean,mimeTypes:string[]}} filter
* @returns {Promise<File[]>}
*/
list(filter = {public: true}, opts = {}) {
return query(`
query ListFiles($filter: FilesFilter!) {
files(filter:$filter) {
id
kind
time
public
name
mimeType
size
author
url
}
}
`, {filter}, opts).then(({files}) => {
return files.map(d => new File(d))
})
}
/**
* Upload a file
*
* @param {{public:boolean, file:File, name:string}} input
*/
uploadFile(input) {
return queryForm(`
mutation UploadFile($input: FileUploadInput!) {
uploadFile(input:$input) {
id
kind
time
public
name
mimeType
size
author
url
}
}
`, {input}).then(({uploadFile}) => {
return new File(uploadFile)
})
}
/**
* Edit a file
*
* @param {{id: string, public:boolean, name:string}} input
*/
editFile(input) {
return queryForm(`
mutation EditFile($input: FileEditInput!) {
editFile(input:$input) {
id
public
name
}
}
`, {input}).then(({editFile}) => {
return editFile
})
}
/**
* Edit a file
*
* @param {{id: string, public:boolean, name:string}} input
*/
removeFile(input) {
return queryForm(`
mutation RemoveFIle($input: FileRemoveInput!) {
removeFile(input:$input) {
id
}
}
`, {input}).then(({removeFile}) => {
return removeFile
})
}
}
module.exports = { File, fileApi: new FileAPI() }
+19 -3
View File
@@ -31,7 +31,11 @@ class Log {
}
static fromData(data) {
return new Log(data.id, data.shortId, data.date, data.title, data.eventName, data.description, data.open, data.channel, data.characters, data.posts)
return new Log(
data.id, data.shortId, data.date,
data.title, data.eventName, data.description,
data.open, data.channel, data.characters, data.posts
)
}
}
@@ -70,10 +74,22 @@ class LogAPI {
nick
text
}
nextLogs {
log {id title date channelName}
hasEvent
hasChannel
characters {name}
}
prevLogs {
log {id title date channelName}
hasEvent
hasChannel
characters {name}
}
}
}
`, {id}).then(({log}) => {
return Log.fromData(log)
return log
})
}
@@ -219,7 +235,7 @@ class LogAPI {
subscribeChanges(logId) {
return new Subscription(`
subscription Changes($keys: [ChangeKeyInput!]!) {
changes(keys: $keys) {
changes(filter: {keys: $keys}) {
id
model
op
+63 -2
View File
@@ -16,7 +16,7 @@ function query(query, variables = {}, options = {}) {
headers: {
"Content-Type": "application/json",
"Authorization": options.token ? `Bearer ${options.token}` : null,
"X-Permissions": options.permissions ? options.permissions.join(",") : null,
//"X-Permissions": options.permissions ? options.permissions.join(",") : null,
},
body: JSON.stringify({query: query.length > 256 ? compressQuery(query || "") : query, variables, operationName: options.operationName}),
credentials: "include",
@@ -31,6 +31,67 @@ function query(query, variables = {}, options = {}) {
})
}
/**
*
*
* @param {string} query GQL Query
* @param {Object} variables Variables
* @param {{token?:string,permissions?:string[]}} options
*/
function queryForm(query, variables = {}, options = {}) {
const map = {}
const files = []
function findFiles(prefix, data) {
for (const key in data) {
if (!data.hasOwnProperty(key)) {
continue
}
const value = data[key]
if (value instanceof File) {
map[files.length] = [`${prefix}.${key}`]
files.push(value)
} else if (typeof(value) === 'object') {
data[key] = findFiles(`${prefix}.${key}`, Object.assign({}, value))
}
}
return data
}
const operations = {
query: query,
variables: findFiles("variables", Object.assign({}, variables)),
}
const form = new FormData()
form.append("operations", JSON.stringify(operations))
form.append("map", JSON.stringify(map))
for (let i = 0; i < files.length; ++i) {
form.append(i.toString(), files[i])
}
console.log(form)
return fetch(config.graphqlEndpoint, {
method: "POST",
headers: {
"Authorization": options.token ? `Bearer ${options.token}` : null,
"X-Permissions": options.permissions ? options.permissions.join(",") : null,
},
body: form,
credentials: "include",
}).then(res => {
return res.json()
}).then(json => {
if (json.errors != null && json.errors.length > 0) {
return Promise.reject(json.errors)
}
return json.data
})
}
class Subscription extends EventEmitter {
constructor(query, variables) {
super()
@@ -115,4 +176,4 @@ class Subscription extends EventEmitter {
}
}
module.exports = { query, Subscription }
module.exports = { query, queryForm, Subscription }
+13 -5
View File
@@ -28,7 +28,12 @@ lasso.configure(require("./marko-config"))
// Set trusted proxy
if (config.trustedProxy != null) {
if (ip.isV4Format(config.trustedProxy) || ip.isV6Format(config.trustedProxy)) {
if (config.trustedProxy === "GATEWAY") {
const ip = require("child_process").execSync("/sbin/ip route|awk '/default/ { print $3 }'").toString().trim()
app.set('trust proxy', ip)
console.log("Trusting proxy from default gateway", ip);
} else if (ip.isV4Format(config.trustedProxy) || ip.isV6Format(config.trustedProxy)) {
app.set('trust proxy', config.trustedProxy)
console.log("Trusting proxy", config.trustedProxy);
@@ -69,6 +74,12 @@ app.use(session)
app.use(passport.initialize())
app.use(passport.session())
app.use(require("./middleware/locals"))
// Proxy (does not want all middleware)
app.use("/graphql", require("./routes/graphql").router)
app.use("/playground", proxy(config.playgroundEndpoint))
// The rest of the middleware
app.use(bodyParser.json({limit: "1mb"}))
app.use(bodyParser.text({limit: "256kb"}))
@@ -79,10 +90,6 @@ app.use(lassoMiddleware.serveStatic())
// Authentication
app.use("/auth", require("./routes/auth"))
// API Proxy
app.use("/graphql", require("./routes/graphql"))
app.use("/playground", proxy(config.playgroundEndpoint))
// Global css doesn't work with prebuild.
if (process.env.NODE_ENV === "production") {
app.use("/hax/global.css", require("./routes/globalcss"))
@@ -108,6 +115,7 @@ app.use("/data/", require("./routes/data"))
app.use("/data/characters/", require("./routes/data/characters"))
app.use("/data/channels/", require("./routes/data/channels"))
app.use("/data/changes/", require("./routes/data/changes"))
app.use("/data/files/", require("./routes/data/files"))
// Entry point
app.get("/", function(req, res) {