Compare commits

...
38 Commits
Author SHA1 Message Date
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
gisle 1583eb412e That's why I should always test two-line fixes...
the build failed
2019-08-02 20:42:04 +02:00
gisle 5c094a2471 Fixed prev logs not allowing overlaps with next logs.
the build was successful
2019-08-02 20:36:30 +02:00
gisle 99aec4d2cb Added log suggestions to the bottom of logs. 2019-08-02 17:34:12 +02:00
gisle 3f944ff029 Fixed logs page not remembering show all button.
the build failed
2019-08-02 11:14:30 +02:00
gisle 3cbe42e1ba Fixed ordinal checker crashing edit story form.
the build was successful
2019-08-02 11:10:25 +02:00
gisle 01b501f9e5 Fixed /logs/<lig-id> route not supporting dash in channel name.
the build was successful
2019-07-20 10:18:05 +02:00
gisle 9b7d182955 Removed limit argument in logs query if it's not needed.
the build was successful
2019-05-21 20:45:27 +02:00
gisle 7c55fc03aa Fixed locals middleware doing an auth check for assets, scripts and other static content.
the build was successful
2019-05-19 21:37:01 +02:00
gisle 6fca626ea2 logs-content: Added exception for annotations in multipart post detection.
the build was successful
2019-04-25 21:00:54 +02:00
gisle d92e6bfeb0 Merge branch 'master' of ssh+git://git.aiterp.net/rpdata/frontend
the build was successful
2019-04-25 20:54:10 +02:00
gisle 2d89096430 logs-content: Fixed post being added twice on add due to race condition between websocket and mutation return. 2019-04-25 20:53:59 +02:00
gisle 3988028256 story-content: Fixed comment fictional dates being affected by browser timezone.
the build was successful
2019-04-19 16:21:59 +02:00
gisle c51733ef3b logs-content: Fixed event link filtering by channel. 2019-04-19 16:17:05 +02:00
gisle f81f05d274 logs-content: Added character highlighter feature. 2019-04-19 16:15:07 +02:00
gisle 25d4aa73d1 logs-content: Replaced old post rendering with actual irc caret notation parsing. 2019-04-19 14:57:05 +02:00
gisle e1a5968341 package: npm audit fix 2019-04-18 12:14:09 +02:00
gisle 5c4446f161 Fixed story chapter comment permission check not allowing other users to comment.
the build was successful
2019-03-20 21:02:28 +01:00
gisle 3a057f6f24 Fixed story chapter fictionalDate field weirdness, especially when it's not provided.
the build was successful
2019-03-17 21:59:50 +01:00
gisle 853076fd5e Fixed story chapters being unresponsive if there are no comments.
the build was successful
2019-03-17 21:44:07 +01:00
gisle 06bf2b67a2 Added correction of Ctrl+Bs in log posts showing as ** due to markdown being particular about where the bolds should be.
the build was successful
2019-03-17 21:10:58 +01:00
gisle 40259b8839 Fixed validation of fictionalDate input in add/edit story.
the build was successful
2019-03-17 20:29:26 +01:00
gisle 3fc9d5de7f Fixed validation of fictionalDate input in create/edit chapter modals. 2019-03-17 20:25:03 +01:00
gisle c69e528105 Added UI for Commit entries to change history
the build was successful
2019-03-17 20:15:35 +01:00
gisle 6ea9632623 Comments are pretty much done!
the build was successful
2019-03-17 20:00:49 +01:00
gisle 490725ecd2 Long time no see. A bunch of stuff I cba to summarize.
the build was successful
2019-03-07 23:13:46 +01:00
gisle 494d0404ed Merge branch '1.2' 2019-01-19 11:19:41 +01:00
gisle 8bfc0dad14 api: Fixed fictionalDate being Jan 1, 1970 instead of null if none is set.
the build was successful
2019-01-17 21:12:05 +01:00
gisle 3ed3e50486 api: Fixed fatal error if Comment.character is null.
the build was successful
2019-01-17 21:06:43 +01:00
gisle 5260d96f37 api: Added Comment and hooked it up to the Story.find response.
the build was successful
2019-01-17 20:51:26 +01:00
95 changed files with 10030 additions and 287 deletions
+28 -20
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 ]
when:
event: push
branch: master
docker-release:
image: plugins/docker
repo: vmaple/rpdata-frontend
settings:
auto_tag: true
secrets: [ docker_username, docker_password ]
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:
event:
- tag
- name: docker-release-latest
image: plugins/docker
repo: vmaple/rpdata-frontend
tags: latest
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/*
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",
]
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import moment from "moment";
<if(input.value != null)>
<span class=["date", input.class]>${(input.utc ? moment.utc(input.value) : moment(input.value)).format(input.format || "MMM D, YYYY")}</span>
</if>
@@ -0,0 +1,23 @@
const {parse, BOLD} = require("irc-caret-notation")
module.exports = class {
onCreate() {
this.state = {
paragraphs: [],
prev: "",
}
}
onInput(input) {
if (this.state == null) {
return
}
if (input.source != this.state.prev) {
this.state.prev = input.source
this.state.paragraphs = input.source.split("\n").filter(l => l.length > 0).map(l => parse(l, {
"*": (!(input.source.startsWith("(")) ? BOLD : null),
}))
}
}
}
@@ -0,0 +1,5 @@
<div class=["irc-caret-notation", input.class]>
<p for(p in state.paragraphs)>
<span for(chunk in p) class=chunk.cssClasses("icn-")>${chunk.text}</span>
</p>
</div>
+1
View File
@@ -1,3 +1,4 @@
<div class=["markdown-content", input.class]>
<include(input.renderBody) />
<include(state.render) />
</div>
+2 -2
View File
@@ -1,6 +1,6 @@
<a on-click("onClick") href=input.href>
<div class=state.classes>
<div class="icon color-primary">${input.icon}</div>
<div class=[state.classes, input.weak ? "dark" : null]>
<div class=["icon", "color-primary"]>${input.icon}</div>
<div class=["text", input.selected ? "color-primary" : input.textClass]>
<include(input.renderBody) />
</div>
+3
View File
@@ -47,6 +47,9 @@ module.exports = class {
if (this.input.enabled) {
window.removeEventListener("keydown", this.onPress)
}
this.mounted = false
this.opened = false
}
/**
+68
View File
@@ -51,3 +51,71 @@ body {
background-color: #555;
color: #000;
}
/*
* Tooltip magic.
*/
.tooltip {
position: relative;
border-bottom: 0.5px dotted; /* If you want dots under the hoverable text */
span {
cursor: default;
}
> .tooltip-content {
visibility: hidden;
width: 400px;
text-align: left;
padding: 0.25em 1ch;
position: absolute;
z-index: 11000;
pointer-events: none;
top: 0;
left: 105%;
border: 1px solid;
> h1 {
font-size: 1.5em;
margin: 0;
text-align: center;
border: none;
}
> h2 {
font-size: 1em;
margin: 0;
margin-bottom: 0.15em;
margin-top: -0.25em;
font-weight: 200;
opacity: 0.5;
text-align: center;
border: none;
}
p:last-of-type {
padding-bottom: 0;
}
}
}
.no-tooltip {
position: relative;
}
.tooltip:hover {
> .tooltip-content {
visibility: visible;
}
}
@media screen and (max-width: 1200px) {
.tooltip {
> .tooltip-content {
visibility: hidden !important;
}
}
}
+3 -3
View File
@@ -92,7 +92,7 @@ body.theme-data {
}
div.markdown-content .orange, body.theme-logs div.markdown-content blockquote, body.theme-logs div.markdown-content table, body.theme-logs div.markdown-content pre {
div.markdown-content .orange, body.theme-logs .color-blockquote, body.theme-logs div.markdown-content blockquote, body.theme-logs div.markdown-content table, body.theme-logs div.markdown-content pre {
background-color: rgba(221, 170, 17, 0.125);
color: #DA1;
@@ -100,7 +100,7 @@ div.markdown-content .orange, body.theme-logs div.markdown-content blockquote, b
color: #DA1;
}
}
div.markdown-content .blue, body.theme-story div.markdown-content blockquote, body.theme-story div.markdown-content table, body.theme-story div.markdown-content pre {
div.markdown-content .blue, body.theme-story .color-blockquote, body.theme-story div.markdown-content blockquote, body.theme-story div.markdown-content table, body.theme-story div.markdown-content pre {
background-color: rgba(34, 187, 255, 0.125);
color: #4BF;
@@ -108,7 +108,7 @@ div.markdown-content .blue, body.theme-story div.markdown-content blockquote, bo
color: #4BF; //
}
}
div.markdown-content .green, body.theme-mapp div.markdown-content blockquote, body.theme-mapp div.markdown-content table, body.theme-mapp div.markdown-content pre {
div.markdown-content .green, body.theme-mapp .color-blockquote, body.theme-mapp div.markdown-content blockquote, body.theme-mapp div.markdown-content table, body.theme-mapp div.markdown-content pre {
background-color: rgba(17,255,136, 0.125);
color: #1F8;
@@ -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>
+30 -1
View File
@@ -65,7 +65,22 @@ module.exports = class {
this.state.operation += " chapter"
this.state.links = [{
text: chapter.title || `Untitled chapter (${chapterKey.id})`,
text: (chapter||{}).title || `Untitled chapter (${(chapterKey||chapter).id})`,
href: `/story/${storyKey.id}#${(chapterKey||chapter).id}`,
}]
break
}
case "Comment": {
const storyKey = data.keys.find(k => k.model === "Story") || {}
const chapterKey = data.keys.find(k => k.model === "Chapter")
const chapter = data.objects.find(o => o.type === "Chapter") || {}
const comment = data.objects.find(o => o.type === "Comment") || {}
this.state.operation += ' ' + comment.characterName + " comment in chapter"
this.state.links = [{
text: chapter.title || `ID (${chapterKey.id})`,
href: `/story/${storyKey.id}#${chapterKey.id}`,
}]
@@ -84,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")
@@ -109,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>
@@ -0,0 +1,10 @@
module.exports = class {
onCreate(input) {
this.state = {
id: input.log.id,
channelName: input.log.channelName,
eventName: input.log.eventName,
error: null,
}
}
}
@@ -0,0 +1,33 @@
import moment from "moment"
<div class="log-suggestions">
<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>
</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>
</div>
</if>
</if>
<else>
<div class="empty color-menu">(No suggestions)</div>
</else>
</div>
@@ -0,0 +1,46 @@
div.log-suggestions {
padding: 0 0.5ch;
margin-top: 1em;
margin-bottom: 2em;
h2 {
text-align: center;
margin: 0;
margin-bottom: 0.25em;
}
div.empty, div.loading {
text-align: center;
}
div.suggestion {
margin-bottom: 0.5em;
padding: 0.5em;
div.suggestion-header {
margin: 0.125em 0.5ch;
a {
color: inherit;
}
a:hover {
color: white;
}
}
div.suggestion-description {
margin: 0.125em 0.5ch;
}
div.suggestion-tags {
margin-top: 0.25em;
div.suggestion-tag {
display: inline-block;
padding: 0 0.5ch;
margin: 0.125em 0.5ch;
border: 0.25px solid;
}
}
}
}
@@ -1,5 +1,21 @@
module.exports = class {
onCreate() {
this.state = {
highlights: [],
}
}
select(value) {
this.emit("select", value)
}
toggleHighlight(id) {
if (this.state.highlights.includes(id)) {
this.state.highlights = this.state.highlights.filter(id2 => id2 !== id)
} else {
this.state.highlights = this.state.highlights.concat(id)
}
this.emit("hightlights", this.state.highlights);
}
}
@@ -4,10 +4,23 @@
<menu-link dark key="_create" on-click("select", "post.add", null) icon="+">Add Post</menu-link>
<menu-gap />
</if-permitted>
<menu-header>Links</menu-header>
<menu-header>Channel</menu-header>
<menu-link href=("/logs/?channels="+encodeURIComponent(input.log.channel.name)) icon="#">${input.log.channel.name}</menu-link>
<menu-link if(input.log.channel.eventName) href=("/logs/?channels="+input.log.channel.eventName) icon="E">${input.log.channel.eventName}</menu-link>
<menu-gap />
<if(input.log.eventName)>
<menu-header>Event</menu-header>
<menu-link href=("/logs/?events="+input.log.eventName) icon="E">${input.log.eventName}</menu-link>
<menu-gap />
</if>
<if(input.log.characters.length > 0)>
<menu-header>Characters</menu-header>
<for(character in input.log.characters)>
<menu-link href=("/logs/?characters="+character.id) icon="C">${character.name} <span class="weak">(${character.author})</span></menu-link>
<menu-link weak=(state.highlights.length > 0 && !state.highlights.includes(character.id))
icon="C"
on-click("toggleHighlight", character.id)>
${character.name} <span class="weak">(${character.author})</span>
</menu-link>
</for>
<menu-gap />
</if>
</menu>
@@ -9,6 +9,7 @@ module.exports = class {
log: input.log,
modal: null,
removed: false,
highlights: [],
}
}
@@ -111,6 +112,11 @@ module.exports = class {
}
postAdded(post) {
// Stop duplicates in case of race condition.
if (this.state.log.posts.find(p => p.id === post.id)) {
return
}
this.state.log.posts = this.state.log.posts.concat([post])
this.state.log = Object.assign({}, this.state.log)
}
@@ -161,6 +167,10 @@ module.exports = class {
this.state.removed = true
}
highlightsChanged(highlights) {
this.state.highlights = highlights.slice();
}
get title() {
if (this.state.log.title) {
return this.state.log.title
@@ -1,8 +1,9 @@
<logs-content-menu user=input.user log=state.log on-select("open") />
<logs-content-menu on-hightlights("highlightsChanged") user=input.user log=state.log on-select("open") />
<main>
<div class="logs-content">
<div class="header">
<h1 class="color-primary">${component.title}</h1>
<p for(paragraph in state.log.description.split("\n").filter(p => p)) class="color-text">${paragraph}</p>
<if-permitted user=input.user permission="log.edit">
<a on-click("open", "log.edit") class="color-menu">Edit</a>
</if-permitted>
@@ -25,8 +26,10 @@
prev=(state.log.posts[loop.getIndex() - 1] || {})
post=post
characters=state.log.characters
highlights=state.highlights
last=(post.position === state.log.posts.length)
/>
<log-suggestions log=state.log />
</div>
<add-post-modal enabled=(state.modal === "post.add") logId=state.log.id on-close("close") on-added("postAdded") />
@@ -15,6 +15,10 @@ div.logs-content {
margin-bottom: 0;
}
p {
margin: 0.25em 0 0 0;
}
a {
vertical-align: middle;
display: inline-block;
@@ -14,6 +14,7 @@ module.exports = class {
multipart: false,
anchored: false,
prevWasText: false,
dark: false,
}
this.mounted = false;
@@ -48,7 +49,7 @@ module.exports = class {
}
const timediff = Date.parse(input.post.time) - Date.parse(input.prev.time)
this.state.multipart = (input.post.nick == input.prev.nick && input.prev.multipart !== false) && (Math.abs(timediff) < 60000);
this.state.multipart = (!input.post.kind.startsWith("annotation.") && input.post.nick == input.prev.nick && input.prev.multipart !== false) && (Math.abs(timediff) < 60000);
this.state.prevWasText = (input.prev.kind === "text");
this.updatePost(input)
@@ -81,24 +82,12 @@ module.exports = class {
this.state.name = input.post.nick
this.state.nameSuffix = ""
this.state.text = input.post.text.replace(/\x02/g, "**").replace(/\x1D/g, "*").replace(/\`/g, "\\`").trim();
this.state.text = input.post.text
if (this.state.text.startsWith("|")) {
this.state.name = ""
this.state.text = this.state.text.slice(1).trim();
}
if (input.post.kind === "text" && !this.state.text.includes("\"") && !this.state.text.includes("\''") && !this.state.text.includes("|")) {
const colonIndex = this.state.text.indexOf(": ");
if (colonIndex != -1 && colonIndex < this.state.text.indexOf(" ")) {
this.state.text = this.state.text.replace(": ", ": \"") + "\"";
if (input.highlights.length > 0 && (input.post.kind === "action" || input.post.kind === "text")) {
this.state.dark = true
} else {
this.state.text = '"' + this.state.text + '"'
}
}
if (this.state.text.charAt(0) == this.state.text.charAt(0).toUpperCase()) {
this.state.name = ""
this.state.dark = false
}
if (!input.post.nick.startsWith("=")) {
@@ -106,13 +95,19 @@ module.exports = class {
for (const character of input.characters) {
for (const nick of character.nicks) {
if (nick === postNick) {
this.state.name = character.name
this.state.name = character.name + character.id
this.state.shortName = character.shortName
if (input.post.nick.endsWith("'s") || input.post.nick.endsWith("'")) {
this.state.nameSuffix = (character.shortName.endsWith("s") || character.shortName.endsWith("z")) ? "'" : "'s"
}
if (input.highlights.length > 0) {
this.state.dark = !(input.highlights.includes(character.id))
} else {
this.state.dark = false
}
return
}
}
@@ -1,4 +1,4 @@
<div if(!state.removed) class=["post", component.kindClass("pk-"), state.multipart ? "multipart" : null, state.anchored ? "anchored" : null]>
<div if(!state.removed) class=["post", component.kindClass("pk-"), state.multipart ? "multipart" : null, state.anchored ? "anchored" : null, state.dark ? "dark" : null]>
<a class="anchor" id=input.post.id />
<div class="post-meta-row">
<div class="options color-menu">
@@ -19,18 +19,18 @@
</div>
<div if(input.post.kind.startsWith("annotation.")) class="post-body" >
<annotation level=(input.post.kind.substring(11))>
<markdown class="post-content" source=state.text />
<irc-caret-notation class="post-content" source=state.text />
</annotation>
</div>
<div if(input.post.kind === "scene") class="post-body color-text">
<markdown class="post-content post-scene" source=state.text />
<irc-caret-notation class="post-content post-scene" source=state.text />
</div>
<div if(input.post.kind === "action") class="post-body color-text">
$ const prefix = ((!state.multipart || state.prevWasText) ? state.shortName + state.nameSuffix + " " : "");
<markdown class="post-content post-action" source=(prefix + state.text) />
<irc-caret-notation class="post-content post-action" source=(prefix + state.text) />
</div>
<div if(input.post.kind === "text") class="post-body color-text">
<markdown class="post-content post-text" source=state.text />
<irc-caret-notation class="post-content post-text" source=state.text />
</div>
<remove-post-modal enabled=(state.modal === "remove") post=input.post on-close("close") on-remove("remove") />
<edit-post-modal enabled=(state.modal === "edit") post=input.post on-close("close") on-edited("edited") />
@@ -1,6 +1,6 @@
div.post {
margin-top: 1em;
margin-bottom: 1ch;
margin-bottom: 1.25em;
margin-left: -12px;
padding-left: 12px;
padding-right: 0.5ch;
@@ -65,12 +65,54 @@ div.post {
margin-top: 0.33em;
}
p {
line-height: 1.33em;
margin-bottom: -0.25em;
span.icn-bold { font-weight: 800; }
span.icn-underline { text-decoration: underline; }
span.icn-italic { font-style: italic; }
span.icn-fg-0 { color: #fff; }
span.icn-fg-1 { color: #111; }
span.icn-fg-2 { color: #13d; }
span.icn-fg-3 { color: #173; }
span.icn-fg-4 { color: #d21; }
span.icn-fg-5 { color: #721; }
span.icn-fg-6 { color: #727; }
span.icn-fg-7 { color: #d71; }
span.icn-fg-8 { color: #aa1; }
span.icn-fg-9 { color: #1d3; }
span.icn-fg-10 { color: #177; }
span.icn-fg-11 { color: #1DD; }
span.icn-fg-12 { color: #127; }
span.icn-fg-13 { color: #c1a; }
span.icn-fg-14 { color: #444; }
span.icn-fg-15 { color: #aaa; }
span.icn-bg-0 { background-color: #fff; }
span.icn-bg-1 { background-color: #111; }
span.icn-bg-2 { background-color: #13d; }
span.icn-bg-3 { background-color: #173; }
span.icn-bg-4 { background-color: #d21; }
span.icn-bg-5 { background-color: #721; }
span.icn-bg-6 { background-color: #727; }
span.icn-bg-7 { background-color: #d71; }
span.icn-bg-8 { background-color: #aa1; }
span.icn-bg-9 { background-color: #1d3; }
span.icn-bg-10 { background-color: #177; }
span.icn-bg-11 { background-color: #1DD; }
span.icn-bg-12 { background-color: #127; }
span.icn-bg-13 { background-color: #c1a; }
span.icn-bg-14 { background-color: #444; }
span.icn-bg-15 { background-color: #aaa; }
}
p:first-of-type {
margin-top: 0;
padding-top: 0;
}
p:last-of-type {
padding-bottom: 0.1em;
margin-bottom: 0em;
}
}
}
@@ -91,7 +133,7 @@ div.post.pk-text.anchored {
}
div.post.multipart {
margin-top: -0.9em;
margin-top: -1.5em;
div.post-meta {
span {
@@ -99,3 +141,7 @@ div.post.multipart {
}
}
}
div.post.dark {
opacity: 0.25;
}
@@ -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 />
+7 -2
View File
@@ -68,7 +68,7 @@ module.exports = class {
if (filter.search) {
queries.push("search=" + encodeURIComponent(filter.search))
}
if (filter.limit) {
if (filter.limit != null) {
queries.push("limit=" + encodeURIComponent(filter.limit.toString()))
}
@@ -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;
@@ -51,6 +51,13 @@ module.exports = class {
break
}
case "title": {
this.state.color = "color-primary"
this.state.text = value
break
}
default: {
this.state.color = "color-menu"
this.state.text = value
@@ -1,6 +1,18 @@
<if(state.text != null && state.text != "")>
<div class=["chapter-meta", input.kind, state.color, input.weak ? "weak" : null] title=state.tooltip>
<a if(state.href != null) href=state.href>${state.text}</a>
<span else>${state.text}</span>
<span else class="tooltip">
<span>${state.text}</span>
<div if(input.character != null) class="tooltip-content color-highlight-dark color-primary">
<h1>${input.character.name}</h1>
<h2>${input.character.author}</h2>
<markdown source=input.character.description />
</div>
<div else-if(input.ukAuthor != null) class="tooltip-content color-highlight-dark color-primary">
<h1>(Unknown Character)</h1>
<h2>${input.ukAuthor}</h2>
</div>
</span>
</div>
</if>
@@ -13,6 +13,10 @@ div.chapter-meta {
a:hover {
border-bottom: 0.5px solid;
}
.tooltip {
border-bottom: 0.25px dotted;
}
}
div.chapter-meta.weak {
@@ -3,9 +3,42 @@ module.exports = class {
this.state = {
modal: null,
removed: false,
commentLabels: {
Article: "Comment",
Message: "Message",
Chat: "Chat Message",
},
selectedComment: null,
}
}
onOpenComment(comment, modal) {
this.state.selectedComment = comment
this.state.modal = modal
}
addComment(comment) {
this.updateChapter({
comments: this.input.chapter.comments.concat(comment),
})
}
editComment(comment) {
const comments = this.input.chapter.comments.slice()
const index = comments.findIndex(c => c.id === comment.id)
if (index != -1) {
comments[index] = comment
} else {
comments.push(comment)
}
this.updateChapter({comments})
}
removeComment(comment) {
this.updateChapter({comments: this.input.chapter.comments.filter(c => c.id !== comment.id)})
}
updateChapter(data) {
this.emit("edit", data)
}
@@ -16,10 +16,26 @@
<chapter-meta kind="date" utc=true value=input.chapter.fictionalDate />
<chapter-meta weak kind="date" value=input.chapter.createdDate />
<chapter-meta weak kind="author" value=input.chapter.author />
<chapter-meta weak kind="message" value=input.chapter.commentMode />
</div>
<markdown class="chapter-content color-text" source=input.chapter.source />
<div class=["comment-section", "type-" + (input.chapter.commentMode.toLowerCase() || "disabled")]>
<comment for(comment in input.chapter.comments) key=comment.id
comment=comment
locked=input.chapter.commentsLocked
mode=input.chapter.commentMode
user=input.user
on-open("onOpenComment", comment)
/>
<if-permitted if(input.chapter.canComment) user=input.user permission=["member", "chapter.edit"]>
<div class="add-comment color-menu" on-click("open", "addComment") >Add ${state.commentLabels[input.chapter.commentMode] || "Comment"}</div>
</if-permitted>
</div>
<edit-chapter-modal enabled=(state.modal === "edit") chapter=input.chapter on-close("close") on-edit("updateChapter") />
<create-comment-modal enabled=(state.modal === "addComment") chapter=input.chapter user=input.user on-close("close") on-add("addComment") />
<edit-comment-modal enabled=(state.modal === "editComment") chapter=input.chapter comment=state.selectedComment user=input.user on-close("close") on-edit("editComment") />
<remove-comment-modal enabled=(state.modal === "removeComment") comment=state.selectedComment user=input.user on-close("close") on-remove("removeComment", state.selectedComment) />
<move-chapter-modal enabled=(state.modal === "move") chapter=input.chapter user=input.user on-close("close") on-move("moveChapter") />
<remove-chapter-modal enabled=(state.modal === "remove") chapter=input.chapter on-close("close") on-remove("removeChapter") />
</div>
@@ -49,4 +49,30 @@
div.chapter-content {
font-size: 1.25em;
}
div.comment-section {
margin: 1em 4ch 2em 4ch;
div.add-comment {
text-align: center;
margin-top: 1em;
padding: 1em 0;
font-size: 1.5em;
border: 1px solid;
opacity: 0.5;
cursor: pointer;
}
div.add-comment:hover {
opacity: 1;
}
div.add-comment:first-child {
margin-top: 0;
}
}
@media screen and (max-width: 1200px) {
div.comment-section {
margin: 1em 2ch 2em 2ch;
}
}
}
@@ -0,0 +1,18 @@
module.exports = class {
onCreate(input) {
this.state = {
showCharacter: false,
paragraphs: input.comment.source.split('\n').filter(l => l.length > 1),
}
}
onInput(input) {
if (this.state) {
this.state.paragraphs = input.comment.source.split('\n').filter(l => l.length > 1)
}
}
toggle(key) {
this.state[key] = !this.state[key]
}
}
@@ -0,0 +1,121 @@
import moment from "moment"
<div class=["comment", "mode-" + input.mode.toLowerCase()]>
<if (input.mode === "Message")>
<div class="metadata">
<div class="options color-menu">
<if-permitted user=input.user author=input.comment.author permission="story.edit">
<a on-click("emit", "open", "editComment", input.comment) >Edit</a>
</if-permitted>
<if-permitted user=input.user author=input.comment.author permission="story.edit">
<a on-click("emit", "open", "removeComment", input.comment) >Remove</a>
</if-permitted>
</div>
<chapter-meta weak kind="date" value=input.comment.createdDate />
<chapter-meta weak kind="author" value=input.comment.author />
</div>
<div class="content color-blockquote">
<table class="header">
<tbody>
<tr if(input.comment.subject != "")>
<th>Subject:</th>
<td>${input.comment.subject}</td>
</tr>
<tr>
<th>From:</th>
<td>
<div if(input.comment.character != null) class="tooltip" style="display: inline-block">
<span>${input.comment.characterName}</span>
<div class="tooltip-content color-highlight-dark">
<h1>${input.comment.character.name}</h1>
<h2>${input.comment.character.author}</h2>
<markdown source=input.comment.character.description />
</div>
</div>
<div else>
<span class="tooltip color-primary">
<span>${input.comment.characterName}</span>
<div class="tooltip-content color-highlight-dark color-primary">
<h1>(Unknown Character)</h1>
<h2>${input.comment.author}</h2>
</div>
</span>
</div>
</td>
</tr>
<tr if(input.comment.fictionalDate != null)>
<th>Date:</th>
<td>
<date utc value=input.comment.fictionalDate></date>
</td>
</tr>
</tbody>
</table>
<markdown class="body" source=input.comment.source />
</div>
</if>
<if(input.mode === "Chat")>
<div class="content">
<div class="options color-menu">
<if-permitted user=input.user author=input.comment.author permission="story.edit">
<a on-click("emit", "open", "editComment", input.comment) >Edit</a>
</if-permitted>
<if-permitted user=input.user author=input.comment.author permission="story.edit">
<a on-click("emit", "open", "removeComment", input.comment) >Remove</a>
</if-permitted>
</div>
<for (paragraph in state.paragraphs)>
<div class="message-text color-text">
<markdown class="body" source=paragraph>
<if (input.comment.fictionalDate)>
<span class="decoration color-menu">[</span>
<date utc class="color-menu" value=input.comment.fictionalDate></date>
<span class="decoration color-menu">]&nbsp;</span>
</if>
<span class="decoration color-menu">&lt;</span>
<span if(input.comment.character != null) class="tooltip color-primary">
<span>${input.comment.characterName}</span>
<div class="tooltip-content color-highlight-dark color-primary">
<h1>${input.comment.character.name}</h1>
<h2>${input.comment.character.author}</h2>
<markdown source=input.comment.character.description />
</div>
</span>
<span class="tooltip color-primary" else>
<span>${input.comment.characterName}</span>
<div class="tooltip-content color-highlight-dark color-primary">
<h1>(Unknown Character)</h1>
<h2>${input.comment.author}</h2>
</div>
</span>
<span class="decoration color-menu">&gt;&nbsp;</span>
</markdown>
</div>
</for>
</div>
</if>
<if(input.mode === "Article")>
<div class="metadata">
<div class="options color-menu">
<if-permitted user=input.user author=input.comment.author permission="story.edit">
<a on-click("emit", "open", "editComment", input.comment) >Edit</a>
</if-permitted>
<if-permitted user=input.user author=input.comment.author permission="story.edit">
<a on-click("emit", "open", "removeComment", input.comment) >Remove</a>
</if-permitted>
</div>
<chapter-meta kind="title" value=input.comment.subject />
<chapter-meta kind="name" value=input.comment.characterName character=(input.comment.character) ukAuthor=input.comment.author />
<chapter-meta utc kind="date" value=input.comment.fictionalDate />
<chapter-meta weak kind="date" value=input.comment.createdDate />
<chapter-meta weak kind="author" value=input.comment.author />
</div>
<div class="content color-text">
<markdown class="body" source=input.comment.source />
</div>
</if>
</div>
@@ -0,0 +1,113 @@
div.comment {
position: relative;
div.options {
width: 100%;
max-width: 95ch;
}
}
div.comment.mode-message {
margin-bottom: 1.5em;
font-size: 1em;
.metadata {
padding-bottom: 0.5em;
}
.content {
outline: 1px solid;
padding: 0.25em 1ch;
font-size: 1.25em;
table.header {
border-bottom: 0.5px solid;
margin: 0;
width: 100%;
tbody {
tr {
text-align: left;
th {
width: 12ch;
padding-right: 1ch;
}
td {
width: 100%;
}
}
}
}
}
div.body {
padding-top: 0.5em;
p:first-of-type {
margin-top: 0;
padding-top: 0;
}
}
}
div.comment.mode-chat {
div.options {
display: none;
bottom: -0.25em;
a {
background: black;
opacity: 0.666;
}
a:hover {
opacity: 1;
}
}
div.content {
line-height: 2em;
font-size: 1.125em;
div.tooltip-content {
text-indent: 0;
}
div.message-text {
vertical-align: top;
padding-left: 2ch;
text-indent: -2ch;
div.body {
p:first-of-type {
display: inline;
}
p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
p {
line-height: 1.5em;
}
}
}
}
}
div.comment.mode-article {
margin-bottom: 1em;
.content {
font-size: 1.25em;
}
}
div.comment.mode-chat:hover {
div.options {
display: block;
}
}
@@ -11,6 +11,7 @@ module.exports = class {
title: "",
source: "",
fictionalDate: "",
commentMode: "Disabled",
},
}
@@ -19,18 +20,18 @@ module.exports = class {
onInput(input) {
if (input.chapter && !this.first) {
let {fictionalDate, title, source} = input.chapter
let {fictionalDate, title, source, commentMode, commentsLocked} = input.chapter
if (fictionalDate != null) {
fictionalDate = moment.utc(fictionalDate).format("MMM D, YYYY")
}
this.state.values = {fictionalDate, title, source}
this.state.values = {fictionalDate, title, source, commentMode, commentsLocked}
}
}
change(key, ev) {
this.state.values[key] = ev.target.value
this.state.values = Object.assign({}, this.state.values, {[key]: ev.target.value})
}
open() {
@@ -52,15 +53,19 @@ module.exports = class {
let fictionalDate = new Date(values.fictionalDate + " UTC")
if (values.fictionalDate != "") {
if (Number.isNaN(fictionalDate)) {
this.state.error = `Could not parse ${values.fictionalDate} as date!`
if (Number.isNaN(fictionalDate.getTime())) {
this.state.error = `Could not parse ${values.fictionalDate} as date.`
if (values.fictionalDate.includes("th")) {
this.state.error += " Try to remove the 'th'."
}
return
}
} else {
fictionalDate = null
}
const input = {storyId: this.input.storyId, title: values.title, source: values.source, fictionalDate}
const input = {storyId: this.input.storyId, title: values.title, source: values.source, fictionalDate, commentMode: values.commentMode}
chapterApi.addChapter(input).then(chapter => {
this.emit("add", chapter)
@@ -70,11 +75,12 @@ module.exports = class {
title: "",
source: "",
fictionalDate: "",
commentMode: "Disabled",
}
}).catch(errs => {
console.warn("Failed to edit:", errs)
this.state.error = "Failed to edit: " + errs[0].message
this.state.error = "Failed to edit: " + (errs[0]||errs||{}).message || errs
}).then(() => {
this.state.loading = false
})
@@ -12,5 +12,13 @@
<label>Content</label>
<textarea key="source" placeholder="" class="tall" on-change("change", "source") value=state.values.source />
<label>Comments</label>
<select key="kind" class="big" placeholder="Kind" on-change("change", "commentMode") value=state.values.commentMode>
<option value="Disabled" selected=(state.values.commentMode === "Disabled")>Disabled – Hide all comments</option>
<option value="Article" selected=(state.values.commentMode === "Article")>Article – Looks like story chapters</option>
<option value="Chat" selected=(state.values.commentMode === "Chat")>Chat – One-line chat messages</option>
<option value="Message" selected=(state.values.commentMode === "Message")>Message – Extranet Mail</option>
</select>
<button disabled=state.loading on-click("save")>Save</button>
</modal>
@@ -0,0 +1,139 @@
const moment = require("moment")
const {commentApi} = require("../../../../../rpdata/api/Comment")
const {charactersApi} = require("../../../../../rpdata/api/Character")
module.exports = class {
onCreate(input) {
this.state = {
error: null,
loading: false,
values: {
subject: "",
source: "",
characterName: "",
characterId: "",
fictionalDate: "",
},
characters: [],
opened: false,
placeholder: "(Required)",
}
this.first = false
}
onMount() {
charactersApi.list({author: (this.input.user||{}).name}).then((characters) => {
this.state.characters = characters.sort((a,b) => a.name.localeCompare(b.name))
})
const comments = this.input.chapter.comments || []
const lastComment = comments[comments.length - 1]
if (lastComment != null) {
let date = lastComment.fictionalDate || this.input.chapter.fictionalDate
if (date != null) {
this.change("fictionalDate", {target: {value: moment(date).format("MMM D, YYYY")}})
}
}
}
change(key, ev) {
this.state.values = Object.assign({}, this.state.values, {[key]: ev.target.value})
if (key === "characterId") {
if (ev.target.value !== "") {
const character = this.state.characters.find(c => c.id === this.state.values.characterId)
if (character != null) {
this.state.placeholder = character.name
} else {
this.state.placeholder = "(Required)"
}
} else {
this.state.placeholder = "(Required)"
}
}
}
open() {
if (!this.state.opened) {
charactersApi.list({author: (this.input.user||{}).name}).then((characters) => {
this.state.characters = characters.sort((a,b) => a.name.localeCompare(b.name))
})
}
}
close() {
this.first = false
this.state.opened = false
this.emit("close")
}
save() {
const values = Object.assign({}, this.state.values)
if (values.source.length == 0) {
this.state.error = "The comment body is empty."
console.warn(values)
return
}
if (this.input.chapter.commentMode !== "Chat" && values.subject.trim().length == 0) {
this.state.error = "The subject is empty."
return
}
if (values.characterName.trim().length < 1) {
const character = this.state.characters.find(c => c.id === this.state.values.characterId)
if (character == null) {
this.state.error = "You need to give a display name for an anonymous character."
return
}
values.characterName = character.name
}
let fictionalDate = new Date(values.fictionalDate + " UTC")
if (values.fictionalDate != "") {
if (Number.isNaN(fictionalDate)) {
this.state.error = `Could not parse ${values.fictionalDate} as date!`
return
}
} else {
fictionalDate = null
}
const input = {
chapterId: this.input.chapter.id,
subject: values.subject,
source: values.source,
characterName: values.characterName,
characterId: values.characterId,
fictionalDate: fictionalDate,
}
if (input.characterId == "") {
input.characterId = null
}
commentApi.addComment(input).then(comment => {
this.emit("add", comment)
this.emit("close")
this.state.values = {
subject: "",
source: "",
characterName: "",
characterId: "",
fictionalDate: "",
}
this.state.opened = false
}).catch(errs => {
console.warn("Failed to add comemnt:", errs)
this.state.error = "Failed to add comemnt: " + (errs[0]||errs||{}).message || errs
}).then(() => {
this.state.loading = false
})
}
}
@@ -0,0 +1,27 @@
<modal class="modal color-text nolabel" key="modal" enabled=(input.enabled) closable on-close("close") on-open("open") >
<h1>Add Comment</h1>
<p key="error" class="color-error">${state.error}</p>
<if(input.chapter.commentMode !== "Chat")>
<label>Subject</label>
<input key="subject" autofocus placeholder="" class="big" on-change("change", "subject") value=state.values.subject />
</if>
<label>Character ${state.characterId}</label>
<select on-change("change", "characterId")>
<option value="" selected=state.values.characterId=="">Anonymous</option>
<option for(character in state.characters) value=character.id selected=(state.values.characterId==character.id)>${character.name}</option>
</select>
<label>Display Name</label>
<input key="characterName" autofocus placeholder=state.placeholder on-change("change", "characterName") value=state.values.characterName />
<label>IC Date</label>
<input key="icdate" placeholder="(Optional)" on-change("change", "fictionalDate") value=state.values.fictionalDate />
<label>Content</label>
<textarea key="source" placeholder="" class="tall" on-change("change", "source") value=state.values.source />
<button disabled=state.loading on-click("save")>Save</button>
</modal>
@@ -11,6 +11,8 @@ module.exports = class {
title: "",
source: "",
fictionalDate: "",
commentMode: "Article",
commentsLocked: false,
},
}
@@ -19,18 +21,20 @@ module.exports = class {
onInput(input) {
if (input.chapter && !this.first) {
let {fictionalDate, title, source} = input.chapter
let {fictionalDate, title, source, commentMode, commentsLocked} = input.chapter
if (fictionalDate != null) {
fictionalDate = moment.utc(fictionalDate).format("MMM D, YYYY")
} else {
fictionalDate = ""
}
this.state.values = {fictionalDate, title, source}
this.state.values = {fictionalDate, title, source, commentMode, commentsLocked}
}
}
change(key, ev) {
this.state.values[key] = ev.target.value
this.state.values = Object.assign({}, this.state.values, {[key]: ev.target.value})
}
open() {
@@ -47,15 +51,19 @@ module.exports = class {
let fictionalDate = new Date(values.fictionalDate + " UTC")
if (values.fictionalDate != "") {
if (Number.isNaN(fictionalDate)) {
this.state.error = `Could not parse ${values.fictionalDate} as date`
if (Number.isNaN(fictionalDate.getTime())) {
this.state.error = `Could not parse ${values.fictionalDate} as date.`
if (values.fictionalDate.includes("th")) {
this.state.error += " Try to remove the 'th'."
}
return
}
} else {
fictionalDate = null
}
const input = {id: this.input.chapter.id, title: values.title, source: values.source}
const input = {id: this.input.chapter.id, title: values.title, source: values.source, commentsLocked: values.commentsLocked, commentMode: values.commentMode}
if (fictionalDate != null) {
input.fictionalDate = fictionalDate
@@ -12,5 +12,17 @@
<label>Content</label>
<textarea key="source" placeholder="Content" class="tall" on-change("change", "source") value=state.values.source />
<label>Comments</label>
<select key="kind" class="big" placeholder="Kind" on-change("change", "commentMode") value=state.values.commentMode>
<option value="Disabled" selected=(state.values.commentMode === "Disabled")>Disabled – Hide all comments</option>
<option value="Article" selected=(state.values.commentMode === "Article")>Article – Looks like story chapters</option>
<option value="Chat" selected=(state.values.commentMode === "Chat")>Chat – One-line chat messages</option>
<option value="Message" selected=(state.values.commentMode === "Message")>Message – Extranet Mail</option>
</select>
<toggle value=state.values.commentsLocked on="Locked" off="Unlocked"
onDesc="Logged-in users can post comments."
offDesc="Nobody can post comments to this chapter."
on-change("change", "commentsLocked") />
<button disabled=state.loading on-click("save")>Save</button>
</modal>
@@ -0,0 +1,139 @@
const moment = require("moment")
const {commentApi} = require("../../../../../rpdata/api/Comment")
const {charactersApi} = require("../../../../../rpdata/api/Character")
module.exports = class {
onCreate(input) {
this.state = {
error: null,
loading: false,
values: {
subject: "",
source: "",
characterName: "",
characterId: "",
fictionalDate: "",
},
characters: [],
opened: false,
placeholder: "(Required)",
}
}
onInput(input) {
if (input.comment) {
charactersApi.list({author: (input.user||{}).name}).then((characters) => {
this.state.characters = characters.sort((a,b) => a.name.localeCompare(b.name))
})
const comment = input.comment
let fictionalDate = new Date(comment.fictionalDate)
if (comment.fictionalDate && !Number.isNaN(fictionalDate.getTime()) && fictionalDate.getTime() !== 0) {
fictionalDate = moment(fictionalDate).format("MMM D, YYYY")
} else {
fictionalDate = ""
}
this.state.values = {
subject: comment.subject,
characterId: comment.characterId,
characterName: comment.characterName,
fictionalDate: fictionalDate,
source: comment.source,
}
}
}
change(key, ev) {
this.state.values = Object.assign({}, this.state.values, {[key]: ev.target.value})
if (key === "characterId") {
if (ev.target.value !== "") {
const character = this.state.characters.find(c => c.id === this.state.values.characterId)
if (character != null) {
this.state.placeholder = character.name
} else {
this.state.placeholder = "(Required)"
}
} else {
this.state.placeholder = "(Required)"
}
}
}
close() {
this.emit("close")
}
save() {
const values = Object.assign({}, this.state.values)
let clearFictionalDate = false
if (values.source.length == 0) {
this.state.error = "The comment body is empty."
console.warn(values)
return
}
if (this.input.chapter.commentMode !== "Chat" && values.subject.trim().length == 0) {
this.state.error = "The subject is empty."
return
}
if (values.characterName.trim().length < 1) {
const character = this.state.characters.find(c => c.id === this.state.values.characterId)
if (character == null) {
this.state.error = "You need to give a display name for an anonymous character."
return
}
values.characterName = character.name
}
let fictionalDate = new Date(values.fictionalDate + " UTC")
if (values.fictionalDate != "") {
if (Number.isNaN(fictionalDate)) {
this.state.error = `Could not parse ${values.fictionalDate} as date!`
return
}
} else {
fictionalDate = null
clearFictionalDate = true
}
const input = {
commentId: this.input.comment.id,
subject: values.subject,
source: values.source,
characterName: values.characterName,
characterId: values.characterId,
fictionalDate: fictionalDate,
clearFictionalDate: clearFictionalDate,
}
if (input.characterId == "") {
input.characterId = null
}
commentApi.editComment(input).then(comment => {
this.emit("edit", comment)
this.close()
this.state.values = {
subject: "",
source: "",
characterName: "",
characterId: "",
fictionalDate: "",
}
}).catch(errs => {
console.warn("Failed to add comemnt:", errs)
this.state.error = "Failed to add comemnt: " + (errs[0]||errs||{}).message || errs
}).then(() => {
this.state.loading = false
})
}
}
@@ -0,0 +1,27 @@
<modal class="modal color-text nolabel" key="modal" enabled=(input.enabled) closable on-close("close") >
<h1>Edit Comment</h1>
<p key="error" class="color-error">${state.error}</p>
<if(input.chapter.commentMode !== "Chat")>
<label>Subject</label>
<input key="subject" autofocus placeholder="" class="big" on-change("change", "subject") value=state.values.subject />
</if>
<label>Character ${state.characterId}</label>
<select on-change("change", "characterId")>
<option value="" selected=state.values.characterId=="">Anonymous</option>
<option for(character in state.characters) value=character.id selected=(state.values.characterId==character.id)>${character.name}</option>
</select>
<label>Display Name</label>
<input key="characterName" autofocus placeholder=state.placeholder on-change("change", "characterName") value=state.values.characterName />
<label>IC Date</label>
<input key="icdate" placeholder="(Optional)" on-change("change", "fictionalDate") value=state.values.fictionalDate />
<label>Content</label>
<textarea key="source" placeholder="" class="tall" on-change("change", "source") value=state.values.source />
<button disabled=state.loading on-click("save")>Save</button>
</modal>
@@ -2,6 +2,8 @@ const moment = require("moment")
const {storyApi} = require("../../../../../rpdata/api/Story")
const ORDINAL_REGEX = /[0-9]{1,2}(st|nd|rd|th)/g
module.exports = class {
onCreate() {
this.state = {
@@ -45,9 +47,15 @@ module.exports = class {
const values = this.state.values
let fictionalDate = new Date(values.fictionalDate + " UTC")
if (values.fictionalDate != "") {
if (Number.isNaN(fictionalDate)) {
this.state.error = `Could not parse ${values.fictionalDate} as date`
if (values.fictionalDate) {
if (Number.isNaN(fictionalDate.getTime())) {
this.state.error = `Could not parse ${values.fictionalDate} as date.`
const ordinal = values.fictionalDate.match(ORDINAL_REGEX)[0]
if (ordinal != null) {
this.state.error += " Try to remove the '"+ordinal.slice(-2)+"'."
}
return
}
} else {
@@ -36,13 +36,7 @@ module.exports = class {
return
}
for (const key in data) {
if (!data.hasOwnProperty(key)) {
continue
}
this.state.story.chapters[index][key] = data[key]
}
this.state.story.chapters[index] = Object.assign({}, this.state.story.chapters[index], data);
this.refreshStory()
}
@@ -0,0 +1,36 @@
const {commentApi} = require("../../../../../rpdata/api/Comment")
module.exports = class {
onCreate(input) {
this.state = {
error: null,
loading: false,
values: {
title: "",
source: "",
fictionalDate: "",
},
}
}
change(key, ev) {
this.state.values[key] = ev.target.value
}
close() {
this.emit("close")
}
doIt() {
commentApi.removeComment({commentId: this.input.comment.id}).then(() => {
this.emit("remove")
this.emit("close")
}).catch(errs => {
console.warn("Failed to delete:", errs)
this.state.error = "Failed to delete: " + errs.message || errs[0].message
}).then(() => {
this.state.loading = false
})
}
}
@@ -0,0 +1,9 @@
<modal class="modal color-text nolabel" key="modal" enabled=(input.enabled) notification closable on-close("close") >
<h1>Delete Comment</h1>
<p class="color-error">${state.error}</p>
<p class="color-danger">This is irreversible!</p>
<button disabled=state.loading on-click("doIt")>${Math.random() > 0.99 ? "Deleet" : "Delete"}</button>
</modal>
@@ -42,8 +42,12 @@ module.exports = class {
let fictionalDate = new Date(values.fictionalDate + " UTC")
if (values.fictionalDate != "") {
if (Number.isNaN(fictionalDate)) {
this.state.error = `Could not parse ${values.fictionalDate} as date`
if (Number.isNaN(fictionalDate.getTime())) {
this.state.error = `Could not parse ${values.fictionalDate} as date.`
if (values.fictionalDate.includes("th")) {
this.state.error += " Try to remove the 'th'."
}
return
}
} else {
+2
View File
@@ -28,6 +28,7 @@ module.exports = (req, res, next) => {
}
}
if (["/static/", "/hax/", "/assets/"].find(p => req.path.startsWith(p)) == null) {
if (req.user) {
res.locals.user = {
loggedIn: true,
@@ -39,6 +40,7 @@ module.exports = (req, res, next) => {
loggedIn: false,
}
}
}
next()
}
+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,
+7766 -118
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -27,17 +27,18 @@
"graphql-query-compress": "^1.1.0",
"http-proxy-middleware": "^0.19.1",
"ip": "^1.1.5",
"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",
+8 -4
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 || "/");
})
})(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}
+10
View File
@@ -48,6 +48,11 @@ class ChangesAPI {
objects {
type: __typename
...on File {
id
name
}
...on Character {
id
name
@@ -68,6 +73,11 @@ class ChangesAPI {
time
nick
}
...on Comment {
chapterId
characterName
}
}
}
}
+50 -3
View File
@@ -1,4 +1,5 @@
const {query} = require("../client")
const {Comment} = require("./Comment")
class Chapter {
/**
@@ -9,8 +10,12 @@ class Chapter {
* @param {string | Date} createdDate
* @param {string | Date} fictionalDate
* @param {string | Date} editedDate
* @param {boolean} canComment
* @param {"Disabled"|"Article"|"Chat"|"Message"} commentMode
* @param {boolean} commentsLocked
* @param {any[]} comments
*/
constructor(id, title, author, source, createdDate, fictionalDate, editedDate) {
constructor(id, title, author, source, createdDate, fictionalDate, editedDate, canComment, commentMode, commentsLocked, comments) {
this.id = id
this.title = title
this.author = author
@@ -18,6 +23,10 @@ class Chapter {
this.createdDate = new Date(createdDate)
this.fictionalDate = fictionalDate != null ? new Date(fictionalDate) : null
this.editedDate = new Date(editedDate)
this.canComment = canComment
this.commentMode = commentMode
this.commentsLocked = commentsLocked
this.comments = (comments||[]).map(c => Comment.fromData(c))
}
}
@@ -39,12 +48,31 @@ class ChapterAPI {
createdDate
fictionalDate
editedDate
canComment
commentMode
commentsLocked
comments {
id
subject
author
characterName
character {
id
name
author
description
}
fictionalDate
createdDate
editedDate
source
}
}
}
`, {input}, {permissions: ["member", "story.add"]}).then((data) => {
const ac = data.addChapter
return new Chapter(ac.id, ac.title, ac.author, ac.source, ac.createdDate, ac.fictionalDate, ac.editedDate)
return new Chapter(ac.id, ac.title, ac.author, ac.source, ac.createdDate, ac.fictionalDate, ac.editedDate, ac.canComment, ac.commentMode, ac.commentsLocked, ac.comments)
})
}
@@ -61,6 +89,25 @@ class ChapterAPI {
title
fictionalDate
source
canComment
commentMode
commentsLocked
comments {
id
subject
author
characterName
character {
id
name
author
description
}
fictionalDate
createdDate
editedDate
source
}
}
}
`, {input}, {permissions: ["member", "story.edit"]}).then(({editChapter}) => {
@@ -68,7 +115,7 @@ class ChapterAPI {
editChapter.fictionalDate = new Date(editChapter.fictionalDate)
}
return editChapter
return Object.assign({comments:[]}, editChapter)
})
}
+169
View File
@@ -0,0 +1,169 @@
const {query} = require("../client")
class Comment {
/**
* @param {string} id
* @param {string} subject
* @param {string} author
* @param {string} characterName
* @param {CommentCharacter} character
* @param {string | number | Date} fictionalDate
* @param {string | number | Date} createdDate
* @param {string | number | Date} editedDate
* @param {string} source
*/
constructor(id, subject, author, characterName, character, fictionalDate, createdDate, editedDate, source) {
this.id = id
this.subject = subject
this.author = author
this.characterName = characterName
this.character = character != null ? CommentCharacter.fromData(character) : null
this.fictionalDate = fictionalDate != null ? new Date(fictionalDate) : null
this.createdDate = new Date(createdDate)
this.editedDate = new Date(editedDate)
this.source = source
}
static fromData(data) {
return new Comment(data.id, data.subject, data.author, data.characterName, data.character, data.fictionalDate, data.createdDate, data.editedDate, data.source)
}
}
class CommentCharacter {
/**
* @param {string} id
* @param {string} name
* @param {string} author
* @param {string} description
*/
constructor(id, name, author, description) {
this.id = id
this.name = name
this.author = author
this.description = description
}
static fromData(data) {
return new CommentCharacter(data.id, data.name, data.author, data.description)
}
}
class CommentAPI {
/**
* @param {string} id
* @returns {Promise<Comment>}
*/
find(id) {
return query(`
query FindComment($id: String!) {
comment(id: $id) {
id
subject
author
characterName
character {
id
name
author
description
}
fictionalDate
createdDate
editedDate
source
}
}
`, {id}).then(({comment}) => {
return new Comment(
comment.id, comment.subject, comment.author,
comment.characterName, comment.character,
comment.fictionalDate, comment.createdDate,
comment.editedDate, comment.source,
)
})
}
/**
* @param {any} input
* @returns {Promise<Comment>}
*/
addComment(input) {
return query(`
mutation AddComment($input: CommentAddInput!) {
addComment(input: $input) {
id
subject
author
characterName
character {
id
name
author
description
}
fictionalDate
createdDate
editedDate
source
}
}
`, {input}, {permissions:["member", "story.edit"]}).then(({addComment: comment}) => {
return new Comment(
comment.id, comment.subject, comment.author,
comment.characterName, comment.character,
comment.fictionalDate, comment.createdDate,
comment.editedDate, comment.source,
)
})
}
/**
* @param {any} input
* @returns {Promise<Comment>}
*/
editComment(input) {
return query(`
mutation EditComment($input: CommentEditInput!) {
editComment(input: $input) {
id
subject
author
characterName
character {
id
name
author
description
}
fictionalDate
createdDate
editedDate
source
}
}
`, {input}, {permissions:["member", "story.edit"]}).then(({editComment: comment}) => {
return new Comment(
comment.id, comment.subject, comment.author,
comment.characterName, comment.character,
comment.fictionalDate, comment.createdDate,
comment.editedDate, comment.source,
)
})
}
/**
* @param {{commentId:string}} input
* @returns {Promise<{commentId:string}>}
*/
removeComment(input) {
return query(`
mutation RemoveComment($input: CommentRemoveInput!) {
removeComment(input: $input) {
id
}
}
`, {input}, {permissions:["member", "story.edit"]})
}
}
module.exports = {Comment, CommentCharacter, commentApi: new CommentAPI}
+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
+14
View File
@@ -27,6 +27,15 @@ class LogHeader {
this.open = open
this.characters = characters.map(ch => new LogHeaderCharacter(ch.id, ch.name, ch.shortName, ch.author))
}
/**
* Return all characters in that log.
*
* @param {...string} ids Character IDs
*/
findCharacters(...ids) {
return this.characters.filter(c => ids.includes(c.id))
}
}
class LogHeaderCharacter {
@@ -89,6 +98,11 @@ const logHeaderApi = {
* @returns {Promise<LogHeader[]>}
*/
list(filter = {}) {
filter = Object.assign({}, filter)
if (filter.limit != null && filter.limit <= 0) {
delete filter.limit
}
return query(`
query LogHeaders($filter: LogsFilter) {
headers: logs(filter:$filter) {
+20 -1
View File
@@ -29,7 +29,7 @@ class Story {
this.updatedDate = new Date(updatedDate)
this.fictionalDate = fictionalDate != null ? new Date(fictionalDate) : null
this.tags = tags.map(dt => new Tag(dt.kind, dt.name))
this.chapters = chapters != null ? chapters.map(c => new Chapter(c.id, c.title, c.author, c.source, c.createdDate, c.fictionalDate, c.editedDate)) : null
this.chapters = chapters != null ? chapters.map(c => new Chapter(c.id, c.title, c.author, c.source, c.createdDate, c.fictionalDate, c.editedDate, c.canComment, c.commentMode, c.commentsLocked, c.comments)) : null
}
}
@@ -114,6 +114,25 @@ const storyApi = {
createdDate
fictionalDate
editedDate
canComment
commentMode
commentsLocked
comments {
id
subject
author
characterName
character {
id
name
author
description
}
fictionalDate
createdDate
editedDate
source
}
}
}
}
+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 }
+14 -6
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"))
@@ -102,12 +109,13 @@ app.use("/story/by-author/", require("./routes/story/by-author"))
app.use("/story/tag-list/", require("./routes/story/tag-list"))
app.use("/story/:id(S[0-9a-z]{15})/", require("./routes/story-content"))
app.use("/logs/", require("./routes/logs"))
app.use("/logs/:id([0-9]{4}-[0-1][0-9]-[0-3][0-9]_[0-9]{9}_[A-Za-z0-9\,\']{2,})/", require("./routes/logs-content"))
app.use("/logs/:id([0-9]{4}-[0-1][0-9]-[0-3][0-9]_[0-9]{9}_[A-Za-z0-9\,\'\-]{2,})/", require("./routes/logs-content"))
app.use("/logs/:id(L[0-9]{0,7})/", require("./routes/logs-content"))
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) {