Compare commits

...
40 Commits
Author SHA1 Message Date
gisle 624f7046af server: Added resilience to trust proxy resolution to handle IP changes and bad start order. 2019-01-19 11:18:44 +01:00
gisle 8b3d642104 logs: Added default limit that's cleared by adding a filter or clicking show more at the bottom.
the build was successful
2019-01-12 15:35:21 +01:00
gisle 46369f84bd data: Fixed incosnsitensies and undefines in history.
the build was successful
2019-01-12 14:34:59 +01:00
gisle 68f3effa8d story-content: Added new checks for story tag management so any logged-in user can view the form if the story is open.
the build was successful
2019-01-12 12:18:55 +01:00
gisle 4657ae2aec logs: Added prioritization of filter names starting with the search query, for example bringing 'Li' higher up when the search query is 'li'.
the build was successful
2019-01-05 11:46:46 +01:00
gisle f134d57b79 data: Fixed centiseconds being used instead of seconds in change history entry timestamps.
the build was successful
2019-01-05 11:31:25 +01:00
gisle 9b4da98ac4 logs-content: Added escaping of back-ticks (`) to avoid monospace in log posts where a nick containing a back-tick has been copied into the text.
the build was successful
2019-01-05 11:28:22 +01:00
gisle 599ca83f0f logs-content: Fixed quote detection in text post not detecting double-apostrpohes (KVIrc has anti-RP sentiments among their dev team)
the build was successful
2019-01-05 11:19:45 +01:00
gisle b952007f3b logs-content: Fixed character names being omitted on multipart posts where an action post follows a text post.
the build was successful
2019-01-05 11:16:29 +01:00
gisle c0e74f4756 server: Added DNS resolution when setting trust proxy since IPs are temporary in Docker.
the build failed
2019-01-05 11:02:38 +01:00
gisle 68a8e7ef97 story-content: Added hiding of first chapter's title if it matches story name to conserve vertical space.
the build was successful
2019-01-05 10:46:16 +01:00
gisle 5b81f7ff0d data: Fixed unknown nick list sometimes disappearing in character nicks modal.
the build was successful
2018-12-09 12:21:47 +01:00
gisle 78a14ae86b data: Increased limit on change list to 2000. 2018-12-09 12:21:15 +01:00
gisle c28f17abdd data: Added op text for move-in, move-out and tag, added fallback to ID for untitled chapters.
the build was successful
2018-12-08 16:30:54 +01:00
gisle de3bae3353 data: Added change history.
the build was successful
2018-12-08 15:36:54 +01:00
gisle fcf2361fbf logs-content: Added anchoring on posts, link posts and background on linked-to post. 2018-12-08 15:36:23 +01:00
gisle 7c1623493a data: Added unknown nick list to character add and nicks modal.
the build was successful
2018-12-04 22:12:54 +01:00
gisle 013c50d4f4 client, logs-content: Added change subscription setup and hooked it up to the logs-content page.
the build was successful
2018-12-03 20:12:15 +01:00
gisle f236d35974 logs-content, story-content: Fixed options being too far left in chapter/post on mobile.
the build was successful
2018-12-02 14:39:23 +01:00
gisle 55379fcfe3 logs-content: Added styles for posts to differentiate post types more easily and highlight scenes. 2018-12-02 14:35:46 +01:00
gisle c4636cf843 logs: Added log import form.
the build was successful
2018-12-02 14:18:56 +01:00
gisle 8fd9b19bd0 package: Fixed browser-refresh infinite loop on site changed. 2018-12-02 14:18:42 +01:00
gisle 5a5dfea774 package: Fixed browser-refresh restarting server on git commands. 2018-12-02 12:25:35 +01:00
gisle c157f5f592 package: npm audit fix 2018-12-02 12:21:56 +01:00
gisle 47f71c733b package: Updated version number
the build was successful
2018-11-03 15:05:31 +01:00
gisle 326e4478c0 logs: Added filter menu.
the build was successful
2018-11-03 15:05:02 +01:00
gisle 68a1a64fe3 server: Integrated @marko/prebuild, and added workaround for global css issue.
the build was successful
2018-11-02 20:18:15 +01:00
gisle cdd165ab03 logs-content: Added time difference to multipart post detection.
the build was successful
2018-11-01 21:04:07 +01:00
gisle a8f4d68f1e *: Added media queries to make it less awful on mobile.
the build was successful
2018-11-01 20:56:24 +01:00
gisle b57873091b story-content: Improved visibility of chapter title.
the build was successful
2018-11-01 19:51:14 +01:00
gisle 7a1884d1cf *: Added autofocus on all forms' first input or select.
the build was successful
2018-10-31 23:31:40 +01:00
gisle 5be6b1ecec logs-content: Added italics to irc post rendering.
the build was successful
2018-10-31 23:25:35 +01:00
gisle ea89975b14 logs: Added logs-table that looks like old logs site.
the build was successful
2018-10-28 14:21:17 +01:00
gisle 850114af2d global: Made dark overlay darker. 2018-10-28 14:21:04 +01:00
gisle 39d8254b9b global: Fixed <main> being a little too wide. 2018-10-28 14:20:37 +01:00
gisle adf73a4623 story-content: Added chapter move option.
the build was successful
2018-10-27 14:57:25 +02:00
gisle 1e022d6116 components/if-permitted: Added not option. 2018-10-27 14:56:59 +02:00
gisle d832e66402 components/modal: Fixed open event firing repeatedly. 2018-10-27 14:56:37 +02:00
gisle 94d0a83f46 story: Added a 2-hour grace period for updated tooltip, removed *
the build was successful
2018-10-27 09:51:42 +02:00
gisle 824a8dae23 story: Added view of own unlisted stories.
the build was successful
2018-10-22 19:00:27 +02:00
97 changed files with 3012 additions and 333 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
**/*.marko.js
.cache
.static
+1
View File
@@ -1,4 +1,5 @@
*.marko.js
*.prebuild.json
/node_modules
npm-debug.log
/.cache
+2 -2
View File
@@ -26,8 +26,8 @@ RUN rm -rf .static
RUN rm -rf .cache
RUN rm -rf .git
# Build templates (when that works)
# RUN node build.js
# Build templates
RUN node build.js
## 2. Regroup
FROM node:10-alpine
+22 -47
View File
@@ -1,53 +1,28 @@
const build = require("./builder")
const prebuild = require("@marko/prebuild");
// Setup global environment
require("marko/node-require").install()
require("es6-promise").polyfill()
const config = require("./marko-config")
config.loadPrebuild = false
// Configure lasso
require("lasso").configure({
plugins: [
"lasso-marko",
"lasso-less",
],
outputDir: "./.static",
bundlingEnabled: true,
minify: true,
fingerprintsEnabled: true,
prebuild.run({
config: config,
pages: [ // List of pages to prebuild.
"./marko/page/story/list.marko",
"./marko/page/story/tag-list.marko",
"./marko/page/logs/list.marko",
"./marko/page/data/channels.marko",
"./marko/page/data/characters.marko",
"./marko/page/data/changes.marko",
"./marko/page/story-content/view.marko",
"./marko/page/logs-content/view.marko",
]
}).then(lassoPrebuildResult => {
const builds = lassoPrebuildResult._buildsByPath
require: {
builtins: {
fs: require.resolve("empty-module"),
},
for (const key in builds) {
if (!builds.hasOwnProperty(key)) {
continue
}
transforms: [
{
transform: "lasso-babel-transform",
config: {
extensions: [".js", ".es6"], // Enabled file extensions. Default: [".js", ".es6"]
babelOptions: {
presets: [ "es2015" ]
}
}
}
]
console.log("Built", key)
}
})
// Render templates
async function buildAll() {
await build("./marko/page/story/list.marko")
await build("./marko/page/story/tag-list.marko", {tags: []})
await build("./marko/page/logs/list.marko")
await build("./marko/page/data/channels.marko", {channels: [], user: {}})
await build("./marko/page/data/characters.marko", {characters: [], user: {}})
await build("./marko/page/story-content/view.marko", {story: {chapters: []}})
await build("./marko/page/logs-content/view.marko", {log: {posts: [], channel: {}}})
}
buildAll().then(() => {
console.log("Done!")
}).catch(err => {
console.error(err)
process.exit(1)
})
-15
View File
@@ -1,15 +0,0 @@
module.exports = function build(path, data) {
console.log("Building", path)
return new Promise((resolve, reject) => {
const render = require(path).render(data)
render.once("error", err => reject)
const interval = setInterval(() => {
if (render._remaining === 0) {
clearInterval(interval)
resolve()
}
}, 500)
})
}
+31
View File
@@ -0,0 +1,31 @@
const isProduction = process.env.NODE_ENV === "production"
module.exports = {
plugins: [
"lasso-marko",
"lasso-less",
],
outputDir: "./.static", // Place all generated JS/CSS/etc. files into the "static" dir
bundlingEnabled: isProduction, // Only enable bundling in production
minify: isProduction, // Only minify JS and CSS code in production
fingerprintsEnabled: isProduction, // Only add fingerprints to URLs in production
loadPrebuild: isProduction, // Only load prebuilt stuff in prod.
require: {
builtins: {
fs: require.resolve("empty-module"),
},
transforms: isProduction ? [
{
transform: "lasso-babel-transform",
config: {
extensions: [".js", ".es6"], // Enabled file extensions. Default: [".js", ".es6"]
babelOptions: {
presets: [ "es2015" ]
}
}
}
] : null
}
}
@@ -19,10 +19,10 @@ module.exports = class {
this.state.text = dateStr
if (updated != null && Date.parse(updated) > Date.parse(value)) {
if (updated != null && Date.parse(updated) > (Date.parse(value) + 7200000)) {
const m2 = moment(updated)
this.state.tooltip = "Originally posted: " + dateStr
this.state.text = m2.format("MMM D, YYYY") + " *"
this.state.text = m2.format("MMM D, YYYY")
}
break
@@ -7,6 +7,11 @@ tr.content-list-item {
text-align: center;
width: 2.5ch;
outline: 0.05px solid;
@media screen and (max-width: 700px) {
width: 1.25ch;
font-size: 1.5em;
}
}
> td.content {
+6 -1
View File
@@ -7,7 +7,7 @@ module.exports = class {
onInput(input) {
if (!input.user || !input.user.loggedIn) {
this.state.permitted = false
this.state.permitted = input.not
return
}
@@ -18,5 +18,10 @@ module.exports = class {
} else {
this.state.permitted = input.user.permissions.includes(input.permission)
}
// Flip the above permission
if (input.not) {
this.state.permitted = !this.state.permitted
}
}
}
+1 -1
View File
@@ -1 +1 @@
<div class="menu-gap"></div>
<div class=["menu-gap", input.class]></div>
+1 -1
View File
@@ -6,7 +6,7 @@ module.exports = class {
}
onInput(input) {
this.state.classes = ["menu-link", "color-menu"]
this.state.classes = ["menu-link", "color-menu"].concat(input.class)
if (input.dark) {
this.state.classes.push(input)
+5
View File
@@ -3,9 +3,14 @@ module.exports = class {
this.state = {
loggingIn: false,
loggingOut: false,
enabled: false,
}
}
toggleEnabled() {
this.state.enabled = !this.state.enabled
}
loginClicked() {
this.state.loggingIn = true
}
+3 -1
View File
@@ -1,5 +1,7 @@
<div class="menu">
<div class=["menu", state.enabled ? "enabled" : "disabled"]>
<div class="menu-body">
<menu-link icon=(state.enabled ? "<" : ">") on-click("toggleEnabled") class="showhide">${state.enabled ? "Hide Menu" : "Show Menu"}</menu-link>
<menu-gap class="showhide" />
<include(input.renderBody) />
</div>
<if(input.user != null)>
+24
View File
@@ -12,4 +12,28 @@
user-select: none;
overflow-x: hidden;
overflow-y: auto;
.showhide {
display: none;
}
}
@media screen and (max-width: 700px) {
.menu.disabled {
.menu-link, .menu-header, .menu-blurb, .menu-gap {
display: none;
}
}
.showhide {
display: block !important;
}
.menu {
position: static;
width: 100%;
box-sizing: border-box;
margin-left: -0.25ch;
font-size: 1.25em;
}
}
+4 -1
View File
@@ -1,10 +1,12 @@
module.exports = class {
onCreate() {
this.mounted = false
this.opened = false
}
closeModal() {
this.emit("close")
this.opened = false
if (this.mounted) {
window.removeEventListener("keydown", this.onPress)
@@ -17,8 +19,9 @@ module.exports = class {
}
if (input) {
if (input.enabled && (!this.input || !this.input.enabled)) {
if (input.enabled && !this.opened) {
this.emit("open")
this.opened = true
}
if (input.closable) {
+5
View File
@@ -0,0 +1,5 @@
<div class=["right-menu", input.class]>
<div class="menu-body">
<include(input.renderBody) />
</div>
</div>
+26
View File
@@ -0,0 +1,26 @@
.right-menu {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 28ch;
padding: 0.333em;
padding-bottom: 1em;
text-align: left;
user-select: none;
overflow-x: hidden;
overflow-y: auto;
.showhide {
display: none;
}
}
@media screen and (max-width: 800px) {
.right-menu {
display: none;
}
}
@@ -1,7 +1,7 @@
<div class="tag-options">
<div class="field">
<label>Tag Name</label>
<input key="name" placeholder="Name" on-change("change", "name") value=state.values.name />
<input key="name" autofocus placeholder="Name" on-change("change", "name") value=state.values.name />
</div>
<div class="field">
<label>Tag Kind</label>
@@ -0,0 +1,17 @@
const {charactersApi} = require("../../../rpdata/api/Character")
module.exports = class {
onCreate() {
this.state = {
nicks: [],
}
}
onMount() {
charactersApi.unknownNicks(100).then(unknowns => {
this.state.nicks = unknowns.map(u => u.nick)
}).catch(err => {
console.warn("Failed to get unknown nicks:", err)
})
}
}
@@ -0,0 +1,3 @@
<div class="unknown-nick-list">
<div for(nick in state.nicks) on-click("emit", "select", nick) key=nick class="nick">${nick}</div>
</div>
@@ -0,0 +1,16 @@
div.unknown-nick-list {
padding: 0.5em 0;
div.nick {
display: inline-block;
padding: 0.25em 0.5ch;
margin: 2px;
background-color: #111;
color: #881;
}
div.nick:hover {
cursor: pointer;
background-color: #222;
color: #CC2;
}
}
+16 -2
View File
@@ -7,19 +7,33 @@ body {
main {
margin-left: 30ch; // See side-menu
width: calc(100% - 30ch);
width: calc(100% - 32ch);
margin-right: 0;
}
@media screen and (min-width: 700px) {
main.with-right {
width: calc(100% - 60ch)
}
}
a {
text-decoration: none;
}
}
@media screen and (max-width: 700px) {
body {
main {
margin-left: 0.25ch;
width: 100%;
}
}
}
/*
* Less glaring chrome/ium scrollbars.
*/
::-webkit-scrollbar {
::-webkit-scrollbar {
width: 2px;
/* for vertical scrollbars */
height: 2px;
+3 -3
View File
@@ -19,7 +19,7 @@ body.theme-story {
color: #4BF;
}
.color-highlight-dark {
background-color: rgba(0, 9, 18, 0.50);
background-color: rgba(0, 4, 9, 0.75);
}
}
@@ -40,7 +40,7 @@ body.theme-logs {
color: #BBB;
}
.color-highlight-dark {
background-color: rgba(18, 9, 0, 0.50);
background-color: rgba(9, 4, 0, 0.75);
}
.color-highlight-primary {
background-color: rgba(255, 187, 17, 0.25);
@@ -83,7 +83,7 @@ body.theme-data {
color: #BBB;
}
.color-highlight-dark {
background-color: rgba(18, 8, 0, 0.50);
background-color: rgba(8, 4, 0, 0.75);
}
.color-highlight-primary {
background-color: rgba(204, 204, 34, 0.25);
+11 -1
View File
@@ -30,4 +30,14 @@ nav.layout-navbar {
color: white;
}
}
}
}
@media screen and (max-width: 700px) {
nav.layout-navbar {
position: static;
margin-left: 0;
width: 100%;
font-size: 1.25em;
}
}
+6
View File
@@ -0,0 +1,6 @@
<include("../layout", {title: "Data", site: "data"})>
<@body>
<background src="/assets/images/bg.png" opacity=0.25 />
<changes-page user=input.user changes=input.changes selected=input.selected />
</@body>
</include>
@@ -4,7 +4,7 @@
<p class="color-error">${state.error}</p>
<label>Name</label>
<input key="name" placeholder="(Required)" class="big" on-change("change", "name") value=state.values.name />
<input key="name" autofocus placeholder="(Required)" class="big" on-change("change", "name") value=state.values.name />
<label>Event Name</label>
<input key="eventName" placeholder="(None)" on-change("change", "eventName") value=state.values.eventName />
@@ -17,8 +17,6 @@
onDesc="Log new posts in this channel."
offDesc="The logbot will not monitor this channel."
on-change("change", "logged") />
<p class="color-danger" if(state.values.logged)>The logbot is not yet available, so this does nothing right now.</p>
<button disabled=state.loading on-click("save")>Save</button>
</modal>
@@ -33,6 +33,14 @@ module.exports = class {
this.emit("close")
}
selectUnknown(nick) {
this.state.values = Object.assign({}, this.state.values, {
nick: nick,
name: nick.replace(/_/g, " ").replace(/`/g, "'"),
shortName: (!nick.includes("_") && nick.includes("`")) ? nick.split("`")[0] : "",
})
}
save() {
if (this.state.loading) {
return
@@ -4,7 +4,7 @@
<p class="color-error">${state.error}</p>
<label>Full Name</label>
<input key="name" placeholder="" class="big" on-change("change", "name") value=state.values.name />
<input key="name" autofocus placeholder="" class="big" on-change("change", "name") value=state.values.name />
<label>IRC Nick</label>
<input key="nick" placeholder="" on-change("change", "nick") value=state.values.nick />
@@ -21,4 +21,12 @@
<textarea key="description" placeholder="Description" on-change("change", "description") value=state.values.description />
<button disabled=state.loading on-click("save")>Save</button>
<h2>Unknown Nicks</h2>
<p>After you're done adding the one you're here for, why not take a look at the unknown nicks discovered in the last log character updates?</p>
<p>Clicking one will change the nick in the form above, and add a guess for what the full name is.</p>
<if-permitted not user=input.user permission="character.add">
<p class="color-danger">You don't have permission to set a different author on characters, so only add your own.</p>
</if-permitted>
<unknown-nick-list on-select("selectUnknown") />
</modal>
@@ -0,0 +1,119 @@
const moment = require("moment")
module.exports = class {
onCreate() {
this.state = {
operation: "",
links: [],
parentLink: null,
}
}
onInput(input) {
if (input.data) {
this.refresh(input.data)
}
}
refresh(data) {
this.state.links = []
this.state.operation = opText[data.op]
this.state.parentLink = null
switch (data.model) {
case "Channel": {
const key = data.keys.find(k => k.id !== "*")
this.state.operation += " channel"
this.state.links = [{
text: key.id,
href: `/data/channels/`,
}]
break
}
case "Character": {
const character = data.objects.find(o => o.type === "Character")
this.state.operation += " character"
this.state.links = [{
text: `${character.name} (${character.id})`,
href: `/data/characters/`,
}]
break
}
case "Story": {
const key = data.keys.find(k => k.model === "Story" && k.id !== "*")
const story = data.objects.find(o => o.type === "Story")
this.state.operation += " story"
this.state.links = [{
text: story.name,
href: `/story/${key.id}`,
}]
break
}
case "Chapter": {
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")
this.state.operation += " chapter"
this.state.links = [{
text: chapter.title || `Untitled chapter (${chapterKey.id})`,
href: `/story/${storyKey.id}#${chapterKey.id}`,
}]
break
}
case "Log": {
const key = data.keys.find(k => k.id !== "*")
this.state.operation += " log"
this.state.links = [{
text: key.id,
href: `/logs/${key.id}`,
}]
break
}
case "Post": {
const logKey = data.keys.find(k => k.model === "Log")
const postKeys = data.keys.filter(k => k.model === "Post")
const postObjs = data.objects.filter(o => o.type === "Post")
this.state.operation += (postKeys.length > 1) ? " posts" : " post"
this.state.links = postObjs.map(po => ({
text: `[${moment(po.time).format("HH:mm")}] ${po.nick}`,
href: `/logs/${logKey.id}#${po.id}`,
}))
this.state.parentLink = {
prefix: "in log",
text: `${logKey.id}`,
href: `/logs/${logKey.id}`,
}
break
}
}
}
}
const opText = {
add: "added",
remove: "removed",
move: "moved",
edit: "edited",
import: "imported",
tag: "tagged",
untag: "untagged",
"move-in": "moved in",
"move-out": "moved out",
}
@@ -0,0 +1,15 @@
import moment from "moment";
<div class="change">
<span class="change-id color-menu">[${moment(input.data.date).format("MMM D HH:mm:ss")}] * </span>
<span class="author color-text">${input.data.author} </span>
<span class="text color-text">${state.operation} </span>
<span for(link in state.links | status-var=loop) key=link.href class="link">
<a class="color-primary" href=link.href>${link.text}</a>
<span if(loop.getIndex() < state.links.length - 1) class="comma">, </span>
</span>
<if (state.parentLink != null)>
<span class="text"> ${state.parentLink.prefix} </span>
<a class="color-primary" href=state.parentLink.href>${state.parentLink.text}</a>
</if>
</div>
@@ -0,0 +1,8 @@
div.change {
text-indent: -2ch;
margin-left: 2ch;
a:hover {
text-decoration: underline;
}
}
@@ -0,0 +1,16 @@
module.exports = class {
onCreate(input) {
this.state = {
characters: input.characters,
modal: null,
}
}
open(modal) {
this.state.modal = modal
}
close() {
this.state.modal = null
}
}
@@ -0,0 +1,12 @@
<data-menu categories=input.categories selected=(input.selected || {}) user=input.user on-open("open") />
<main>
<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)>
<change data=change />
</for>
</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") />
@@ -0,0 +1,36 @@
.changes-page {
width: 90%;
max-width: 95ch;
margin: auto;
> p {
text-align: center;
}
h1 {
font-weight: 200;
text-align: center;
}
.header {
text-align: center;
vertical-align: middle;
margin-bottom: 1em;
h1 {
display: block;
margin-bottom: 0;
}
a {
vertical-align: middle;
display: inline-block;
padding: 0.5em 0.5ch 0.5em 0.5ch;
opacity: 0.5;
}
a:hover {
cursor: pointer;
opacity: 1;
}
}
}
@@ -39,7 +39,24 @@ module.exports = class {
}).catch(errs => {
console.warn("Failed to add nick:", errs)
this.state.error = "Failed to add nick: " + errs[0].message
this.state.error = errs[0].message
}).then(() => {
this.state.loading = false
})
}
selectUnknown(nick) {
if (this.state.loading) {
return
}
const input = {id: this.input.character.id, nick}
this.state.loading = true
charactersApi.addNick(input).then((res) => {
this.emit("nicks", res.nicks)
}).catch(errs => {
this.state.error = errs[0].message
}).then(() => {
this.state.loading = false
})
@@ -10,9 +10,12 @@
</div>
<label>Current Nicks</label>
<div for(nick in input.character.nicks) class="existing-nick-row" >
<div for(nick in input.character.nicks) class="existing-nick-row" key=nick >
<div class="content color-text">${nick}</div>
<button disabled=state.loading on-click("removeNick", nick)>Remove</button>
</div>
<h2>Unknown Nicks</h2>
<p>These are the 100 most common nicks in the logs, maybe one of these belongs to <strong>${input.character.name}</strong>?</p>
<unknown-nick-list key="un-list" on-select("selectUnknown") />
</modal>
@@ -2,8 +2,9 @@
<menu-header>List</menu-header>
<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-gap />
<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"]>
<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>
@@ -4,7 +4,7 @@
<p class="color-error">${state.error}</p>
<label>Event Name</label>
<input key="eventName" placeholder="(None)" on-change("change", "eventName") value=state.values.eventName />
<input key="eventName" autofocus placeholder="(None)" on-change("change", "eventName") value=state.values.eventName />
<label>Location Name</label>
<input key="locationName" placeholder="(None)" on-change("change", "locationName") value=state.values.locationName />
@@ -15,7 +15,5 @@
offDesc="The logbot will not monitor this channel."
on-change("change", "logged") />
<p class="color-danger" if(state.values.logged)>The logbot is not yet available, so this does nothing right now.</p>
<button disabled=state.loading on-click("save")>Save</button>
</modal>
@@ -4,7 +4,7 @@
<p class="color-error">${state.error}</p>
<label>Full Name</label>
<input key="name" placeholder="" class="big" on-change("change", "name") value=state.values.name />
<input key="name" autofocus placeholder="" class="big" on-change("change", "name") value=state.values.name />
<label>Short Name</label>
<input key="shortName" placeholder="E.g. first/given name" on-change("change", "shortName") value=state.values.shortName />
+9
View File
@@ -4,6 +4,15 @@
<html lang="en">
<head>
<meta charset="UTF-8"/>
<head>
<meta content="AiteStory is a timeline of aiterp IC content that doesn't fit the wiki format" name="description" />
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes" name="viewport" />
<meta content="#111111" name="theme-color" />
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
<link rel="stylesheet" href="/hax/global.css">
</head>
<title>${input.title ? `${input.title} - Aite RP` : "Aite RP"}</title>
<lasso-head/>
</head>
@@ -4,7 +4,7 @@
<p key="error" class="color-error">${state.error}</p>
<label>Timestamp</label>
<input key="time" placeholder="Title" class="big" on-change("change", "time") value=state.values.time />
<input key="time" autofocus placeholder="Title" class="big" on-change("change", "time") value=state.values.time />
<label>Kind</label>
<select key="kind" class="big" placeholder="Kind" on-change("change", "kind") value=state.values.kind>
@@ -19,15 +19,11 @@ module.exports = class {
}
onInput(input) {
if (input.log && !this.first) {
this.state.values = {
title: input.log.title,
event: input.log.eventName,
description: input.log.description,
open: input.log.open,
}
this.first = true
this.state.values = {
title: input.log.title,
event: input.log.eventName,
description: input.log.description,
open: input.log.open,
}
}
@@ -6,7 +6,7 @@ import moment from "moment"
<p key="error" class="color-error">${state.error}</p>
<label>Title</label>
<input key="title" placeholder=(input.log.channel.name + " – " + moment(input.log.date).format("MMMM D, YYYY")) class="big" on-change("change", "title") value=state.values.title />
<input key="title" autofocus placeholder=(input.log.channel.name + " – " + moment(input.log.date).format("MMMM D, YYYY")) class="big" on-change("change", "title") value=state.values.title />
<label>Event Name</label>
<input key="event" placeholder="(Optional)" on-change("change", "event") value=state.values.event />
@@ -4,7 +4,7 @@
<p key="error" class="color-error">${state.error}</p>
<label>Timestamp</label>
<input key="time" placeholder="Title" class="big" on-change("change", "time") value=state.values.time />
<input key="time" autofocus placeholder="Title" class="big" on-change("change", "time") value=state.values.time />
<label>Kind</label>
<select key="kind" class="big" placeholder="Kind" on-change("change", "kind") value=state.values.kind>
@@ -1,6 +1,7 @@
const moment = require("moment")
const {postApi} = require("../../../../../rpdata/api/Post")
const {logsApi} = require("../../../../../rpdata/api/Log")
module.exports = class {
onCreate(input) {
@@ -9,6 +10,22 @@ module.exports = class {
modal: null,
removed: false,
}
}
onMount() {
this.sub = logsApi.subscribeChanges(this.state.log.id)
this.sub.on("data", data => {
this.handleChange(data.changes)
})
this.sub.connect()
}
onUnmount() {
if (this.sub != null) {
this.sub.disconnect()
}
}
open(modal) {
@@ -30,8 +47,6 @@ module.exports = class {
for (const patch of patches) {
const index = posts.findIndex(p => p.id === patch.id)
if (index == -1) {
console.warn("Post not found for patch:", patch)
// TODO: Force full refresh
continue
}
@@ -42,8 +57,7 @@ module.exports = class {
posts.sort((a, b) => a.position - b.position)
for (let i = 1; i < posts.length; ++i) {
if (posts[i].position !== posts[i - 1].position + 1) {
console.warn("Discontinuity detected!")
// TODO: Force full refresh
console.warn("Discontinuity detected:", i, posts)
}
}
@@ -65,15 +79,7 @@ module.exports = class {
removePost(post) {
postApi.remove({id: post.id}).then(() => {
this.state.log.posts = this.state.log.posts.filter(p => p.id !== post.id)
for (const p of this.state.log.posts) {
if (p.position > post.position) {
p.position--
}
}
this.state.log = Object.assign({}, this.state.log)
this.postRemoved(post)
}).catch(errs => {
console.warn("Failed to delete:", errs)
this.state.error = "Failed to delete: " + (errs.message || errs[0].message)
@@ -88,11 +94,69 @@ module.exports = class {
this.state.log = Object.assign(Object.assign({}, this.state.log), patch)
}
postRemoved(post) {
if (this.state.log.posts.find(p => p.id === post.id) == null) {
return
}
const posts = this.state.log.posts.filter(p => p.id !== post.id).map(p => {
if (p.position > post.position) {
return Object.assign({}, p, {position: p.position - 1})
}
return p
})
this.state.log = Object.assign({}, this.state.log, {posts: posts})
}
postAdded(post) {
this.state.log.posts = this.state.log.posts.concat([post])
this.state.log = Object.assign({}, this.state.log)
}
handleChange(change) {
switch (`${change.model}.${change.op}`) {
case "Post.add": {
for (const post of change.objects.filter(o => o.type === "Post")) {
this.postAdded(post)
}
break
}
case "Post.move":
case "Post.edit": {
this.patch(change.objects.filter(o => o.type === "Post"))
break
}
case "Post.remove": {
for (const post of change.objects.filter(o => o.type === "Post")) {
this.postRemoved(post)
}
}
case "Log.edit": {
const patch = change.objects.find(o => o.type === "Log")
if (patch == null) {
break
}
this.logEdited(patch)
break
}
case "Log.remove": {
this.logRemoved()
break
}
}
}
logRemoved() {
this.state.removed = true
}
@@ -1,5 +1,5 @@
<if(state.text != null && state.text != "")>
<div class=["post-meta", input.kind, state.color, input.weak ? "weak" : null] title=state.tooltip>
<div class=["post-meta", input.kind, state.color, input.weak ? "weak" : null, input.class] title=state.tooltip>
<a if(state.href != null) href=state.href>${state.text}</a>
<span else>${state.text}</span>
</div>
@@ -17,4 +17,14 @@ div.post-meta {
div.post-meta.weak {
opacity: 0.5;
}
div.post-meta.nick.pk-text {
color: #AAA;
}
div.post-meta.nick.pk-action {
color: #A64;
}
div.post-meta.nick.pk-scene {
color: #68A;
}
@@ -1,3 +1,7 @@
const EventEmitter = require("events");
let linkSignal = null;
module.exports = class {
onCreate(input) {
this.state = {
@@ -7,8 +11,13 @@ module.exports = class {
nameSuffix: "",
modal: null,
removed: false,
multipart: false,
anchored: false,
prevWasText: false,
}
this.mounted = false;
this.updatePost(input)
}
@@ -38,18 +47,58 @@ module.exports = class {
return
}
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.prevWasText = (input.prev.kind === "text");
this.updatePost(input)
}
onMount() {
this.linkSignalCallback = () => {
this.state.anchored = false
}
if (linkSignal == null) {
linkSignal = new EventEmitter()
linkSignal.setMaxListeners(10000)
}
linkSignal.on("signal", this.linkSignalCallback)
this.state.anchored = window.location.hash.slice(1) === this.input.post.id;
}
onUnmount() {
linkSignal.removeListener("signal", this.linkSignalCallback)
}
link() {
linkSignal.emit("signal")
this.state.anchored = true
}
updatePost(input) {
this.state.shortName = input.post.nick.split("_").shift()
this.state.name = input.post.nick
this.state.nameSuffix = ""
this.state.text = input.post.text.replace(/\x02/g, "**")
this.state.text = input.post.text.replace(/\x02/g, "**").replace(/\x1D/g, "*").replace(/\`/g, "\\`").trim();
if (input.post.kind === "text" && !this.state.text.includes("\"")) {
this.state.text = '"' + this.state.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(": ", ": \"") + "\"";
} else {
this.state.text = '"' + this.state.text + '"'
}
}
if (this.state.text.charAt(0) == this.state.text.charAt(0).toUpperCase()) {
this.state.name = ""
}
if (!input.post.nick.startsWith("=")) {
@@ -1,8 +1,8 @@
$ const multipart = (input.post.nick == input.prev.nick && input.prev.multipart !== false);
<div if(!state.removed) class=["post", component.kindClass("post-type"), multipart ? "multipart" : null]>
<div if(!state.removed) class=["post", component.kindClass("pk-"), state.multipart ? "multipart" : null, state.anchored ? "anchored" : null]>
<a class="anchor" id=input.post.id />
<div class="post-meta-row">
<div class="options color-menu">
<a on-click("link") href=("#" + input.post.id) class="color-menu">Link</a>
<if-permitted user=input.user permission="post.move">
<a on-click("move", -1, true)>Up</a>
<a on-click("move", +1, true)>Down</a>
@@ -15,7 +15,7 @@ $ const multipart = (input.post.nick == input.prev.nick && input.prev.multipart
</if-permitted>
</div>
<post-meta kind="timestamp" value=input.post.time />
<post-meta kind="nick" value=input.post.nick />
<post-meta kind="nick" class=("pk-"+input.post.kind) value=input.post.nick />
</div>
<div if(input.post.kind.startsWith("annotation.")) class="post-body" >
<annotation level=(input.post.kind.substring(11))>
@@ -26,7 +26,7 @@ $ const multipart = (input.post.nick == input.prev.nick && input.prev.multipart
<markdown class="post-content post-scene" source=state.text />
</div>
<div if(input.post.kind === "action") class="post-body color-text">
$ const prefix = (!multipart ? state.shortName + state.nameSuffix + " " : "");
$ const prefix = ((!state.multipart || state.prevWasText) ? state.shortName + state.nameSuffix + " " : "");
<markdown class="post-content post-action" source=(prefix + state.text) />
</div>
<div if(input.post.kind === "text") class="post-body color-text">
@@ -1,15 +1,27 @@
div.post {
margin-top: 1em;
margin-bottom: 1ch;
margin-left: -12px;
padding-left: 12px;
padding-right: 0.5ch;
transition: background-color 0.333s linear;
div.post-meta-row {
padding: 0;
margin: 0;
}
a.anchor {
display: block;
position: relative;
top: -4em;
}
div.options {
position: absolute;
text-align: right;
width: calc(100% - 35.5ch);
width: calc(100% - 38.75ch);
max-width: 95ch;
a {
@@ -24,6 +36,10 @@ div.post {
a:hover {
opacity: 1;
}
@media screen and (max-width: 700px) {
width: calc(100% - 6.5ch);
}
}
div.post-body {
@@ -48,15 +64,34 @@ div.post {
div.annotation {
margin-top: 0.33em;
}
}
div.post-body p:first-of-type {
margin-top: 0;
padding-top: 0;
}
p:first-of-type {
margin-top: 0;
padding-top: 0;
}
p:last-of-type {
padding-bottom: 0.1em;
}
}
}
div.post.pk-scene {
margin-left: -14px;
border-left: 2px solid #68A;
}
div.post.pk-scene.anchored {
background-color: rgba(102, 136, 170, 0.25);
}
div.post.pk-action.anchored {
background-color: rgba(170, 102, 68, 0.25);
}
div.post.pk-text.anchored {
background-color: rgba(170, 170, 170, 0.25);
}
div.post.multipart {
margin-top: -1.25em;
margin-top: -0.9em;
div.post-meta {
span {
@@ -1,5 +1,5 @@
<label>Search</label>
<input key="search" placeholder="" class="big" value=state.search />
<input key="search" autofocus placeholder="" class="big" value=state.search />
<label>Filters</label>
<div if(state.search.length > 0) class="filter-row" >
@@ -6,7 +6,7 @@ import moment from "moment"
<p key="error" class="color-error">${state.error}</p>
<label>Title</label>
<input key="title" placeholder="(Optional)" class="big" on-change("change", "title") value=state.values.title />
<input key="title" autofocus placeholder="(Optional)" class="big" on-change("change", "title") value=state.values.title />
<label>Date</label>
<input key="date" placeholder="(Required)" on-change("change", "date") value=state.values.date />
@@ -0,0 +1,154 @@
module.exports = class {
onCreate(input) {
this.state = {
characters: [],
channels: [],
eventNames: [],
filters: [],
search: "",
activeMap: {
events: {},
characters: {},
channels: {},
},
}
}
onInput(input) {
this.update(input)
}
onMount(input) {
this.getEl("search").addEventListener("keydown", ev => {
if (this.waiting) {
return
}
if (this.timeout != null) {
clearTimeout(this.timeout)
}
if (ev.key === "Enter") {
this.emit("add", "search", ev.target.value)
this.getEl("search").value = ""
}
this.timeout = setTimeout(() => {
this.timeout = null
this.state.search = ev.target.value
this.update(this.input)
}, 200)
})
}
update(input) {
if (input.filter) {
this.updateActive(input)
}
const search = this.state.search.toLocaleLowerCase()
this.state.characters = input.characters.filter(c => !this.state.activeMap.characters[c.id]).filter(c => this.matches(c.name, search)).sort((a,b) => compareFilters(a.name, b.name, search))
this.state.channels = input.channels.filter(c => !this.state.activeMap.channels[c.name]).filter(c => this.matches(c.name, search)).sort((a,b) => compareFilters(a.name, b.name, search))
this.state.eventNames = input.eventNames.filter(e => !this.state.activeMap.events[e]).filter(e => this.matches(e, search)).sort((a,b) => compareFilters(a, b, search))
}
updateActive({filter, characters, channels, eventNames}) {
const filters = []
const activeMap = {
events: {},
characters: {},
channels: {},
}
if (filter.search != null) {
filters.push({
type: "search",
key: "search",
icon: "T",
color: "color-text",
text: filter.search,
})
}
for (const character of (filter.characters || [])) {
activeMap.characters[character] = true
filters.push({
type: "characters",
key: character,
icon: "C",
color: "color-tag-character",
id: character,
text: (characters.find(c => c.id === character) || {name: "Unknown ("+character+")"}).name,
})
}
for (const channel of (filter.channels || [])) {
activeMap.channels[channel] = true
filters.push({
type: "channels",
key: channel.replace(/'/g, "__"),
icon: "#",
color: "color-tag-location",
id: channel,
text: channel,
})
}
for (const event of (filter.events || [])) {
activeMap.events[event] = true
filters.push({
type: "events",
key: event.replace(/['\s\.]/g, "__"),
icon: "E",
color: "color-tag-event",
id: event,
text: event,
})
}
this.state.filters = filters
this.state.activeMap = activeMap
}
matches(str, search) {
if (search == "") {
return true
}
if (search.length > str.length) {
return false
}
return str.toLowerCase().indexOf(search) !== -1
}
}
/**
*
* @param {string} a
* @param {string} b
* @param {string} search
*/
function compareFilters(a, b, search) {
if (search != "") {
if (search.charAt(0) != "#" && a.charAt(0) == "#" && b.charAt(0) == "#") {
search = '#' + search
}
const aStarts = a.toLocaleLowerCase().startsWith(search)
const bStarts = b.toLocaleLowerCase().startsWith(search)
if (aStarts && !bStarts) {
return -1
} else if (bStarts && !aStarts) {
return 1
}
}
return a.localeCompare(b)
}
@@ -0,0 +1,36 @@
<right-menu class="filter-menu">
<input key="search" />
<menu-gap />
<menu-header if(state.filters.length > 0)>Active Filters</menu-header>
<div class="filter-section">
<menu-link for(filter in state.filters)
on-click("emit", "remove", filter.type, filter.id)
textClass=filter.color
unselectable
icon=filter.icon >${filter.text}</menu-link>
</div>
<menu-gap if(state.filters.length > 0) />
<menu-header>Characters</menu-header>
<div class="filter-section">
<menu-link for(character in state.characters)
on-click("emit", "add", "characters", character.id)
icon=character.name.charAt(0).toUpperCase()
textClass="color-tag-character">${character.name}</menu-link>
</div>
<menu-gap />
<menu-header>Channels</menu-header>
<div class="filter-section">
<menu-link for(channel in state.channels)
on-click("emit", "add", "channels", channel.name)
icon=channel.name.charAt(1).toUpperCase()
textClass="color-tag-location">${channel.name}</menu-link>
</div>
<menu-gap />
<menu-header>Events</menu-header>
<div class="filter-section">
<menu-link for(eventName in state.eventNames)
on-click("emit", "add", "events", eventName)
icon=eventName.charAt(0).toUpperCase()
textClass="color-tag-event">${eventName}</menu-link>
</div>
</right-menu>
@@ -0,0 +1,24 @@
div.right-menu.filter-menu {
input {
background: none;
border: none;
outline: none;
width: 95%;
margin-top: 1em;
margin-left: 2.5%;
border: 1px solid rgba(119, 119, 119, 0.25);
font-size: 1.1em;
box-sizing: border-box;
color: #999;
}
input:focus {
color: #FC1;
}
div.filter-section {
max-height: calc(25vh - 3em);
overflow-y: auto;
overflow-x: hidden;
}
}
@@ -0,0 +1,83 @@
const {tz} = require("moment-timezone")
const {logsApi} = require("../../../../../rpdata/api/Log")
module.exports = class {
onCreate(input) {
this.state = {
error: null,
loading: false,
imported: null,
values: {
importer: "MircLike",
timezone: tz.guess() || "UTC",
channelName: "",
date: "",
data: "",
},
timezones: tz.names(),
}
this.first = false
}
change(key, ev) {
this.state.values[key] = ev.target.value
this.state.values = Object.assign({}, this.state.values)
}
open() {
this.state.loading = false
}
close() {
this.emit("close")
this.state.values = {
importer: "MircLike",
timezone: tz.guess() || "UTC",
channelName: "",
date: "",
data: "",
}
this.state.imported = null
this.state.loading = false
}
save() {
if (this.state.loading) {
return
}
this.state.error = null;
const input = Object.assign({}, this.state.values)
if (input.date != "" && input.importer !== "ForumLog") {
input.date = new Date(input.date)
if (Number.isNaN(input.date)) {
this.state.error = "Invalid date"
return
}
} else if (input.importer === "MircLike") {
this.state.error = "Date is required for mIRC-like logs"
return
} else {
input.date = null;
}
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
}
this.state.loading = true
logsApi.import(input).then(logs => {
this.state.imported = logs
}).catch(errs => {
console.warn("Import failed:", errs)
this.state.error = "Import failed: " + errs[0].message
}).then(() => {
this.state.loading = false
})
}
}
@@ -0,0 +1,44 @@
import moment from "moment"
<modal class="modal color-text nolabel" key="modal" enabled=(input.enabled) closable on-close("close") on-open("open") >
<if(state.imported == null)>
<h1>Import Log</h1>
<p key="error" class="color-error">${state.error}</p>
<label>Type</label>
<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>
</select>
<label>Timezone</label>
<select key="timezone" class="big" placeholder="Kind" on-change("change", "timezone") value=state.values.timezone>
<option for(tz in state.timezones) value=tz selected=(state.values.timezone === tz)>${tz}</option>
</select>
<label>Channel</label>
<input key="channelName" placeholder="(Required)" class="big" on-change("change", "channelName") value=state.values.channelName />
<if(state.values.importer === "MircLike")>
<label>Date</label>
<input key="date" class="big" placeholder="(Required)" on-change("change", "date") value=state.values.date />
</if>
<label>Content</label>
<textarea key="data" placeholder="(Required)" class="tall" on-change("change", "data") value=state.values.data />
<button disabled=state.loading on-click("save")>Import</button>
</if>
<else>
<h1>Import Successful</h1>
<p>The following logs were imported.</p>
<ol class="imported-log-list">
<for (log in state.imported)>
<li><a class="color-primary" href=("/logs/" + log.id)>${log.id} (${moment(log.date).format("MMMM D, YYYY")})</a></li>
</for>
</ol>
</else>
</modal>
@@ -0,0 +1,9 @@
ol.imported-log-list {
color: #777;
li {
a:hover {
text-decoration: underline;
}
}
}
@@ -13,54 +13,7 @@ module.exports = class {
return
}
const filters = []
const {characters, channels, events, search} = input.filter
if (search != null) {
filters.push({
type: "search",
key: "search",
icon: "T",
color: "color-text",
text: search,
})
}
for (const character of (characters || [])) {
filters.push({
type: "characters",
key: character,
icon: "C",
color: "color-tag-character",
id: character,
text: (input.characters.find(c => c.id === character) || {name: "Unknown ("+character+")"}).name,
})
}
for (const channel of (channels || [])) {
filters.push({
type: "channels",
key: channel.replace(/'/g, "__"),
icon: "#",
color: "color-tag-location",
id: channel,
text: channel,
})
}
for (const event of (events || [])) {
filters.push({
type: "events",
key: event.replace(/['\s\.]/g, "__"),
icon: "E",
color: "color-tag-event",
id: event,
text: event,
})
}
this.state.filters = filters
this.state.filtered = (filters.length > 0)
this.state.filtered = input.filter.search || input.filter.channels || input.filter.characters || input.filter.events
}
select(value) {
@@ -3,10 +3,6 @@
<menu-link key="logs" selected=(input.selected.index && !state.filtered) icon="L" href="/logs/">All</menu-link>
<if-permitted key="if-permitted" user=input.user permission="log.add">
<menu-link key="add_log" dark on-click("select", "log.add") icon="+">Add Log</menu-link>
<menu-link key="import_log" dark on-click("select", "log.import") icon="+">Import Log</menu-link>
</if-permitted>
<menu-gap />
<menu-header key="filters">Filters</menu-header>
<menu-link for(filter in state.filters) textClass=filter.color unselectable icon=filter.icon on-click("emit", "removefilter", filter.type, filter.id)>${filter.text}</menu-link>
<menu-link key="add_filter" dark on-click("select", "filter.add") icon="+">Add Filter</menu-link>
<menu-gap />
</menu>
@@ -0,0 +1,10 @@
const moment = require("moment")
module.exports = class {
onCreate(input) {
this.state = {
expand: false,
dateStr: moment(input.log.date).format("MMM D, YYYY"),
}
}
}
@@ -0,0 +1,22 @@
$ const fullNames = (input.log.characters.length < 4);
<tr key=input.log.shortId class="logs-table-row color-text">
<td class="date color-primary">
<a href=("/logs/"+input.log.id)>${state.dateStr}</a>
</td>
<td class="channel color-menu">
<a on-click("emit", "addfilter", "channels", input.log.channelName)>${input.log.channelName}</a>
</td>
<td class="event color-tag-event">
<a on-click("emit", "addfilter", "events", input.log.eventName)>${(input.log.eventName || "")}</a>
</td>
<td class="characters color-menu">
<span for(character in input.log.characters | status-var=loop)>
<a on-click("emit", "addfilter", "characters", character.id) class="name">${fullNames ? character.name : character.shortName}</a>
<span class="comma" if(loop.getIndex() < (loop.getLength() - 1))>, </span>
</span>
</td>
</tr>
<tr if(state.expand) key=(input.log.shortId + "_expand") class="logs-table-row-expanded">
</tr>
@@ -0,0 +1,31 @@
tr.logs-table-row {
td {
padding: 0.25em 1ch;
text-align: center;
border-bottom: 1px solid rgba(51, 51, 51, 0.5);
a {
color: inherit;
}
}
td.expand {
width: 0%;
}
td.date {
width: 16%;
}
td.channel {
width: 14%;
}
td.event {
width: 18%;
}
td.character {
a.name {
white-space: nowrap;
}
}
}
@@ -0,0 +1,11 @@
import moment from "moment"
<table class="logs-table color-highlight-dark color-primary">
<tr key="_header">
<th>Date</th>
<th>Channel</th>
<th class="event">Event</th>
<th>Characters</th>
</tr>
<logs-table-row for(log in input.logs) key=log.shortId log=log on-addfilter("emit", "addfilter") />
</table>
@@ -0,0 +1,25 @@
table.logs-table {
outline: 1px solid;
margin: 1em auto;
width: 90%;
max-width: 140ch;
a:hover {
cursor: pointer;
text-decoration: underline;
}
@media screen and (max-width: 1200px) {
td.event, th.event {
display: none;
}
}
@media screen and (max-width: 700px) {
td.date {
white-space: normal !important;
}
font-size: 0.66em;
}
}
+46 -4
View File
@@ -1,4 +1,4 @@
const {logHeaderApi} = require("../../../../../rpdata/api/LogHeader")
const { logHeaderApi } = require("../../../../../rpdata/api/LogHeader")
module.exports = class {
onCreate(input) {
@@ -19,16 +19,23 @@ module.exports = class {
addFilter(type, filter) {
if (type === "search") {
this.state.filter = Object.assign({}, this.state.filter, {search: filter})
if (filter.trim().length > 0) {
this.state.filter = Object.assign({}, this.state.filter, {search: filter})
} else {
this.state.filter = Object.assign({}, this.state.filter, {search: null})
}
} else {
this.state.filter = Object.assign({}, this.state.filter, {[type]: (this.state.filter[type] || []).concat(filter)})
this.state.filter = Object.assign({}, this.state.filter, {[type]: (this.state.filter[type] || []).filter(f => f !== filter).concat(filter)})
}
this.state.filter = Object.assign({}, this.state.filter, {limit: 0})
this.updateQuery(this.state.filter)
this.refresh()
}
removeFilter(type, filter) {
if (type === "search") {
this.state.filter = Object.assign({}, this.state.filter, {search: null})
this.refresh()
@@ -42,6 +49,41 @@ module.exports = class {
this.refresh()
}
}
this.updateQuery(this.state.filter)
}
updateQuery(filter) {
const queries = []
if (filter.characters) {
queries.push("characters=" + filter.characters.join(","))
}
if (filter.channels) {
queries.push("channels=" + filter.channels.map(c => encodeURIComponent(c)).join(","))
}
if (filter.events) {
queries.push("events=" + filter.events.map(e => encodeURIComponent(e)).join(","))
}
if (filter.search) {
queries.push("search=" + encodeURIComponent(filter.search))
}
if (filter.limit) {
queries.push("limit=" + encodeURIComponent(filter.limit.toString()))
}
if (queries.length > 0) {
history.replaceState("", "", `/logs/?${queries.join("&")}`)
} else {
history.replaceState("", "", "/logs/")
}
}
clearLimit() {
this.state.filter = Object.assign({}, this.state.filter, {limit: 0})
this.refresh()
this.updateQuery(this.state.filter)
}
refresh() {
+10 -4
View File
@@ -1,7 +1,13 @@
<background src="/assets/images/bg.png" opacity=0.25 />
<logs-menu key="menu" on-select("open") on-removefilter("removeFilter") selected=(input.selected || {}) user=input.user filter=state.filter characters=input.characters />
<main>
<logs-list logs=state.logs />
<logs-menu key="menu" on-select("open") selected=(input.selected || {}) user=input.user filter=state.filter characters=input.characters />
<filter-menu on-add("addFilter") on-remove("removeFilter") filter=state.filter characters=input.characters channels=input.channels eventNames=input.eventNames />
<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")>
<span>Show All</span>
</div>
</if>
</main>
<add-log-modal enabled=(state.modal === "log.add") on-close("close") />
<add-filter-modal enabled=(state.modal === "filter.add") characters=input.characters filter=state.filter on-add("addFilter") on-close("close") />
<import-log-modal enabled=(state.modal === "log.import") on-close("close") />
@@ -0,0 +1,17 @@
main.logs-page {
div.show-more-button {
margin: 1em 0;
padding: 0.5em 2em;
font-size: 2em;
text-align: center;
user-select: none;
color: #DA1;
}
div.show-more-button:hover {
cursor: pointer;
text-decoration: underline;
}
}
+7 -2
View File
@@ -1,6 +1,11 @@
<include("../layout", {title: "Logs", site: "logs"})>
<@body>
<!-- Page needed to get component.js functionality -->
<page logs=input.logs filter=input.filter characters=input.characters user=input.user selected=input.selected />
<page logs=input.logs
filter=input.filter
characters=input.characters
channels=input.channels
eventNames=input.eventNames
user=input.user
selected=input.selected />
</@body>
</include>
@@ -10,6 +10,10 @@ module.exports = class {
this.emit("edit", data)
}
moveChapter(data) {
this.emit("move", data)
}
removeChapter() {
this.state.removed = true
this.emit("remove")
@@ -1,17 +1,18 @@
import moment from "moment"
<div class="story-chapter">
<a class="anchor" id=input.chapter.id />
<h1 if(input.chapter.title && !input.hideTitle) class="title color-primary">${input.chapter.title}</h1>
<div class="metadata">
<div class="options color-menu">
<if-permitted user=input.user author=input.chapter.author permission="chapter.edit">
<a on-click("open", "edit") >Edit</a>
</if-permitted>
<if-permitted user=input.user author=input.chapter.author permission="chapter.move">
<a on-click("open", "move") >Move</a>
</if-permitted>
<if-permitted user=input.user author=input.chapter.author permission="chapter.remove">
<a on-click("open", "remove") >Remove</a>
</if-permitted>
</div>
<chapter-meta kind="title" value=input.chapter.title />
<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 />
@@ -19,5 +20,6 @@ import moment from "moment"
<markdown class="chapter-content color-text" source=input.chapter.source />
<edit-chapter-modal enabled=(state.modal === "edit") chapter=input.chapter on-close("close") on-edit("updateChapter") />
<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>
@@ -1,5 +1,12 @@
.story-chapter {
padding-bottom: 1em;
padding-bottom: 2em;
h1.title {
border-bottom: 1px solid rgba(34, 187, 255, 0.25);
font-size: 1.75em;
margin: 0;
padding-bottom: 0.125em;
}
.metadata {
padding: 0;
@@ -15,7 +22,7 @@
.options {
position: absolute;
text-align: right;
width: calc(100% - 35.5ch);
width: calc(100% - 38.75ch);
max-width: 95ch;
pointer-events: none;
@@ -33,6 +40,10 @@
a:hover {
opacity: 1;
}
@media screen and (max-width: 700px) {
width: calc(100% - 6.5ch);
}
}
div.chapter-content {
@@ -4,7 +4,7 @@
<p key="error" class="color-error">${state.error}</p>
<label>Title</label>
<input key="title" placeholder="(Optional)" class="big" on-change("change", "title") value=state.values.title />
<input key="title" autofocus placeholder="(Optional)" class="big" on-change("change", "title") value=state.values.title />
<label>IC Date</label>
<input key="icdate" placeholder="(Optional)" on-change("change", "fictionalDate") value=state.values.fictionalDate />
@@ -4,7 +4,7 @@
<p key="error" class="color-error">${state.error}</p>
<label>Title</label>
<input key="title" placeholder="Title" class="big" on-change("change", "title") value=state.values.title />
<input key="title" autofocus placeholder="Title" class="big" on-change("change", "title") value=state.values.title />
<label>IC Date</label>
<input key="icdate" placeholder="IC Date" on-change("change", "fictionalDate") value=state.values.fictionalDate />
@@ -4,7 +4,7 @@
<p key="error" class="color-error">${state.error}</p>
<label>Title</label>
<input key="name" placeholder="Name" class="big" on-change("change", "name") value=state.values.name />
<input key="name" autofocus placeholder="Name" class="big" on-change("change", "name") value=state.values.name />
<label>IC Date</label>
<input key="icdate" placeholder="IC Date" on-change("change", "fictionalDate") value=state.values.fictionalDate />
@@ -0,0 +1,89 @@
const {storyApi} = require("../../../../../rpdata/api/Story")
const {chapterApi} = require("../../../../../rpdata/api/Chapter")
module.exports = class {
onCreate(input) {
this.state = {
error: null,
loading: false,
loadingStories: true,
stories: [],
selected: null,
}
this.first = false
}
change(key, ev) {
const value = ev.target.value
switch (key) {
case "selected": {
this.state.selected = Object.assign({}, this.state.stories.find(s => s.id === value))
break
}
}
}
open() {
const promises = []
if ((this.input.user.permissions || []).includes("chapter.move")) {
promises.push(storyApi.list())
} else {
promises.push(storyApi.list({author: this.input.user.name}))
promises.push(storyApi.list({open: true}))
}
this.state.loadingStories = true
Promise.all(promises).then(lists => {
this.state.stories = unique(Array.prototype.concat.call(...lists))
this.state.selected = this.state.stories[0]
}).catch(err => {
console.warn(err)
}).then(() => {
this.state.loadingStories = false
})
}
close() {
this.first = false
this.emit("close")
}
save() {
if (this.state.loading) {
return
}
const input = {id: this.input.chapter.id, storyId: this.state.selected.id}
this.state.loading = true
chapterApi.moveChapter(input).then(data => {
this.emit("move", input)
this.emit("close")
}).catch(errs => {
console.warn("Failed to edit:", errs)
this.state.error = "Failed to edit: " + errs[0].message
}).then(() => {
this.state.loading = false
})
}
}
function unique(stories) {
const ids = {}
const results = []
for (const story of stories) {
if (ids[story.id]) {
continue
}
ids[story.id] = true
results.push(story)
}
return results
}
@@ -0,0 +1,18 @@
$ const selected = (state.selected||{id:null});
<modal class="modal color-text nolabel" key="modal" enabled=(input.enabled) closable on-close("close") on-open("open") >
<h1>Move Chapter</h1>
<p key="error" class="color-error">${state.error}</p>
<label>Target story</label>
<select key="story" on-change("change", "selected") autofocus value=selected.id>
<option for(story in state.stories) value=story.id selected=(selected.id === story.id)>${story.name}</option>
</select>
<if-permitted not permission="story.move">
<p>Only stories you are allowed to move the chapter to appear in the above list.</p>
</if-permitted>
<button disabled=state.loading on-click("save")>Move</button>
</modal>
@@ -24,6 +24,12 @@ module.exports = class {
this.refreshStory()
}
moveChapter(chapter, data) {
if (data.storyId !== this.state.story.id) {
this.state.story = Object.assign({}, this.state.story, {chapters: this.state.story.chapters.filter(ch => ch.id !== chapter.id)})
}
}
updateChapter(id, data) {
const index = this.state.story.chapters.findIndex(ch => ch.id === id)
if (index === -1) {
@@ -8,18 +8,22 @@
<a on-click("open", "story.edit") class="color-menu">Edit</a>
</if-permitted>
<if-permitted user=input.user author=state.story.author permission="story.remove">
<a on-click("open", "story.remove") class="color-menu">Remove</a>
<a on-click("open", "story.remove") class="color-menu">Remove</a>
</if-permitted>
</if>
</div>
<story-tags tags=state.story.tags author=state.story.author user=(!state.removed ? input.user : null) on-select("open", "story.tags")/>
<story-tags tags=state.story.tags open=state.story.open author=state.story.author user=(!state.removed ? input.user : null) on-select("open", "story.tags")/>
<annotation if(state.removed) level="error">
<p>
This story has been removed. Your browser has not refreshed the page yet,
so you can still read it (or back it up) before leaving the page.
</p>
</annotation>
<chapter for(chapter in state.story.chapters) key=chapter.id chapter=chapter user=(!state.removed ? input.user : null) on-edit("updateChapter", chapter.id) on-remove("removeChapter", chapter.id)/>
<chapter for(chapter in state.story.chapters) key=chapter.id
chapter=chapter
user=(!state.removed ? input.user : null)
hideTitle=(state.story.chapters[0] === chapter && chapter.title === state.story.name)
on-edit("updateChapter", chapter.id) on-remove("removeChapter", chapter.id) on-move("moveChapter", chapter) />
</div>
<create-chapter-modal storyId=state.story.id enabled=(state.modal === "chapter.add") chapter=input.chapter on-close("close") on-add("addChapter") />
@@ -1,6 +1,14 @@
<div class="story-tags">
<a for(tag in input.tags) href=("/story/by-tag/" + tag.kind + "/" + encodeURIComponent(tag.name)) class=["tag", "color-tag-" + tag.kind.toLowerCase()]>${tag.name}</a>
<if-permitted user=input.user author=input.author permission="story.edit">
<a on-click("select", "edit") class="option color-menu">Manage Tags</a>
</if-permitted>
<if(input.open)>
<if-permitted user=input.user permission="member">
<a on-click("select", "edit") class="option color-menu">Manage Tags (Open)</a>
</if-permitted>
</if>
<else>
<if-permitted user=input.user author=input.author permission="story.edit">
<a on-click("select", "edit") class="option color-menu">Manage Tags</a>
</if-permitted>
</else>
</div>
@@ -4,7 +4,7 @@
<p key="error" class="color-error">${state.error}</p>
<label>Title</label>
<input key="name" placeholder="Name" class="big" on-change("change", "name") value=state.values.name />
<input key="name" autofocus placeholder="Name" class="big" on-change("change", "name") value=state.values.name />
<label>IC Date</label>
<input key="icdate" placeholder="IC Date" on-change("change", "fictionalDate") value=state.values.fictionalDate />
@@ -1,6 +1,7 @@
<menu user=input.user>
<menu-header>Story</menu-header>
<menu-link key="index" selected=input.selected.index icon="S" href="/story/">Stories</menu-link>
<menu-link if(input.user.loggedIn) key="unlisted" selected=input.selected.unlisted icon="S" href="/story/unlisted/">Unlisted</menu-link>
<menu-link selected=input.selected.tags icon="T" href="/story/tag-list/">Tags</menu-link>
<menu-gap if(input.menuAuthor || input.menuMonth || input.menuTags) />
<if(input.menuMonth != null)>
@@ -17,5 +18,4 @@
<menu-gap />
<menu-header>Categories</menu-header>
<menu-link for(category in (input.categories||[])) key=(category.name) selected=(input.selected.category == category.name) href=("/story/by-category/"+category.name) icon=category.name.charAt(0)>${category.name}</menu-link>
<menu-gap />
</menu>
+2 -2
View File
@@ -3,7 +3,7 @@ const config = require("../config")
module.exports = (req, res, next) => {
if (res.marko) {
res.markoAsync = async(template, input) => {
const locals = Object.assign((res.locals || {}), input)
const locals = Object.assign({}, (res.locals || {}), input)
try {
for (const key in locals) {
@@ -12,7 +12,7 @@ module.exports = (req, res, next) => {
locals[key] = await value
}
}
if (locals.user.permissions != null) {
locals.user.permissions = await locals.user.permissions
}
+1205 -48
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gisle/rpdata-frontend",
"version": "1.0.7",
"version": "1.1.0",
"description": "RPData (www.aiterp.net) frontend/proxy",
"main": "./index.js",
"scripts": {
@@ -12,6 +12,7 @@
"homepage": "https://github.com/marko-js-samples/marko-express",
"dependencies": {
"@lasso/marko-taglib": "^1.0.12",
"@marko/prebuild": "^4.0.5",
"babel-preset-es2015": "^6.24.1",
"body-parser": "^1.18.3",
"browser-refresh": "^1.7.3",
@@ -24,19 +25,26 @@
"express-http-proxy": "^1.2.0",
"express-session": "^1.15.6",
"graphql-query-compress": "^1.1.0",
"http-proxy-middleware": "^0.19.1",
"ip": "^1.1.5",
"isomorphic-fetch": "^2.2.1",
"jsonwebtoken": "^8.3.0",
"lasso": "^3.2.6",
"lasso-babel-transform": "^1.0.2",
"lasso-less": "^3.0.1",
"lasso-marko": "^2.4.4",
"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",
"moment": "^2.22.2",
"moment-timezone": "^0.5.23",
"passport": "^0.4.0",
"passport-auth0": "^1.0.0",
"pluralize": "^7.0.0"
},
"devDependencies": {
"@types/http-proxy-middleware": "^0.19.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
const express = require("express")
const router = express.Router()
const {changesApi} = require("../../rpdata/api/Change")
const changesTemplate = require("../../marko/page/data/changes.marko")
router.get("/", async(req, res) => {
res.markoAsync(changesTemplate, {
changes: changesApi.list({limit: 2000}),
selected: {changes: true},
})
})
module.exports = router
+23
View File
@@ -0,0 +1,23 @@
const fs = require("fs")
const express = require("express")
const less = require("less")
const router = express.Router()
let lessData = ""
let data = ""
for (const name of fs.readdirSync("./marko/global/", "utf8")) {
data += fs.readFileSync("./marko/global/"+name, {encoding:"utf8"}) + "\n\n"
}
data = less.render(data, (err, output) => {
if (err == null) {
lessData = output.css
}
})
router.get("/", (req, res) => {
res.contentType("text/css").end(lessData)
})
module.exports = router
+2
View File
@@ -1,3 +1,4 @@
const proxy = require("http-proxy-middleware")
const express = require("express")
const jwt = require("jsonwebtoken")
@@ -44,6 +45,7 @@ router.post("/", (req, res) => {
})
})
router.use("/", proxy(config.graphqlEndpoint, {ws: true}))
/**
* @param {string} user
+11 -1
View File
@@ -3,23 +3,31 @@ const router = express.Router()
const {logHeaderApi, eventNames} = require("../../rpdata/api/LogHeader")
const {charactersApi} = require("../../rpdata/api/Character")
const {channelApi} = require("../../rpdata/api/Channel")
const listTemplate = require("../../marko/page/logs/list.marko")
router.get("/", async(req, res) => {
const filter = {limit: 0}
const filter = {limit: 75}
if (req.query.characters) {
filter.characters = req.query.characters.split(",")
filter.limit = 0
}
if (req.query.channels) {
filter.channels = req.query.channels.split(",")
filter.limit = 0
}
if (req.query.events) {
filter.events = req.query.events.split(",")
filter.limit = 0
}
if (req.query.search) {
filter.search = req.query.search
filter.limit = 0
}
if (req.query.limit) {
filter.limit = parseInt(req.query.limit)
}
try {
@@ -27,6 +35,8 @@ router.get("/", async(req, res) => {
filter: filter,
logs: logHeaderApi.list(filter),
characters: charactersApi.listHeaders(),
channels: channelApi.list(),
eventNames: logHeaderApi.eventNames(),
selected: {index: true},
})
} catch(err) {
+33
View File
@@ -0,0 +1,33 @@
const jwt = require("jsonwebtoken")
const express = require("express")
const router = express.Router()
const common = require("./common")
const config = require("../../config")
const {storyApi} = require("../../rpdata/api/Story")
const listTemplate = require("../../marko/page/story/list.marko")
router.get("/", common, async(req, res) => {
if (!res.locals.user.loggedIn) {
return res.status(401).end("You are not logged in")
}
try {
await res.markoAsync(listTemplate, {
stories: storyApi.list({unlisted: true, author: res.locals.user.name}, {token: generateToken(res.locals.user.name)}),
selected: {unlisted: true},
})
} catch(err) {
console.error(err)
}
})
/**
* @param {string} user
*/
function generateToken(user) {
return jwt.sign({user, permissions: ["member"], exp: Math.floor((Date.now() / 1000) + 1200)}, config.backend.secret, {header: {kid: config.backend.kid}})
}
module.exports = router
+81
View File
@@ -0,0 +1,81 @@
const {query} = require("../client")
class Change {
/**
* @param {string} id
* @param {string} model
* @param {string} op
* @param {string} author
* @param {Date | string | number} date
* @param {{model:string,id:string}[]} keys '
* @param {{type:string, [x:string]:any}[]} objects
*/
constructor(id, model, op, author, date, keys, objects) {
this.id = id
this.model = model
this.op = op
this.author = author
this.date = date
this.keys = keys
this.objects = objects
}
static fromData(data) {
return new Change(data.id, data.model, data.op, data.author, data.date, data.keys, data.objects)
}
}
class ChangesAPI {
/**
* Call `channels(filter)` query
*
* @param {{limit:int, keys: {model:string,id:string}[]}} filter
* @returns {Promise<Change[]>}
*/
list(filter = null) {
return query(`
query ListChanges($filter:ChangesFilter) {
changes(filter:$filter) {
id
model
op
author
date
keys {
model
id
}
objects {
type: __typename
...on Character {
id
name
}
...on Chapter {
id
title
}
...on Story {
id
name
}
...on Post {
id
time
nick
}
}
}
}
`, {filter}).then(({changes}) => {
return changes.map(d => Change.fromData(d))
})
}
}
module.exports = { Change, changesApi: new ChangesAPI }
+23 -5
View File
@@ -21,7 +21,7 @@ class Chapter {
}
}
const chapterApi = {
class ChapterAPI {
/**
* Call `addChapter(input)` mutation, returns the entire object.
*
@@ -46,7 +46,7 @@ const chapterApi = {
return new Chapter(ac.id, ac.title, ac.author, ac.source, ac.createdDate, ac.fictionalDate, ac.editedDate)
})
},
}
/**
* Call `editChapter(input)` mutation, returns editable fields
@@ -70,7 +70,25 @@ const chapterApi = {
return editChapter
})
},
}
/**
* Call `moveChapter(input)` mutation, returns the ID.
*
* @param {{id:string, storyId:string}} input
* @returns {Promise<{id:string}>}
*/
moveChapter(input) {
return query(`
mutation MoveChapter($input: ChapterMoveInput!) {
moveChapter(input: $input) {
id
}
}
`, {input}, {permissions: ["member", "story.move"]}).then(({moveChapter}) => {
return moveChapter
})
}
/**
* Call `removeChapter(input)` mutation, returns only the id
@@ -88,7 +106,7 @@ const chapterApi = {
`, {input}, {permissions: ["member", "story.edit"]}).then(({removeChapter}) => {
return removeChapter
})
},
}
}
module.exports = {Chapter, chapterApi}
module.exports = {Chapter, chapterApi: new ChapterAPI}
+13
View File
@@ -190,6 +190,19 @@ class ChracterAPI {
return removeCharacter
})
}
unknownNicks(limit = 100) {
return query(`
query UnknownNicks($limit: Int!) {
unknownNicks(filter: {limit: $limit}) {
nick
score
}
}
`, {limit}).then(({unknownNicks}) => {
return unknownNicks
})
}
}
module.exports = { Character, charactersApi: new ChracterAPI }
+90 -2
View File
@@ -1,4 +1,4 @@
const {query} = require("../client")
const {query, Subscription} = require("../client")
const {Character} = require("./Character")
const {Channel} = require("./Channel")
@@ -124,6 +124,53 @@ class LogAPI {
})
}
/**
* Call `importLog(input)` mutation, returns multpile logs.
*
* @param {{importer:"MircLike"|"ForumLog", date?:Date|string|number, channel:string, timezone:string, data:string}} input
* @returns {Promise<Log[]>}
*/
import(input) {
return query(`
mutation ImportLog($input: LogImportInput!) {
importLog(input: $input) {
id
shortId
date
title
eventName
description
open
channel {
name
logged
hub
eventName
locationName
}
characters {
id
nicks
author
name
shortName
description
}
posts {
id
position
time
kind
nick
text
}
}
}
`, {input}, {permissions: ["log.add"]}).then(({importLog}) => {
return importLog.map(d => Log.fromData(d))
})
}
/**
* Call `editLog(input)` mutation, returns the id and all editable fields.
*
@@ -146,7 +193,6 @@ class LogAPI {
})
}
/**
* Call `removeLog(input)` mutation, returns the id
*
@@ -164,6 +210,48 @@ class LogAPI {
return editLog
})
}
/**
* Subscribe to the changes.
*
* @param {string} logId The long logId, the short one won't do.
*/
subscribeChanges(logId) {
return new Subscription(`
subscription Changes($keys: [ChangeKeyInput!]!) {
changes(keys: $keys) {
id
model
op
author
listed
keys {
model
id
}
date
objects {
type: __typename
...on Log {
id
title
eventName
description
open
}
...on Post {
id
time
kind
nick
text
position
}
}
}
}
`, {keys: [{model: "Log", id: logId}]})
}
}
module.exports = { Log, logsApi: new LogAPI }
+2 -2
View File
@@ -54,7 +54,7 @@ const storyApi = {
* @param {{author:string, category:string, tags:Tag[]|Tag, unlisted: boolean, open: boolean, earliestFictionalDate:Date|string, latestFictionalDate:Date|string, limit:number}} filter
* @returns {Promise<Story[]>}
*/
list(filter = {}) {
list(filter = {}, options = {}) {
if (filter.earliestFictionalDate != null && typeof(filter.earliestFictionalDate) !== "string") {
filter.earliestFictionalDate = filter.earliestFictionalDate.toISOString()
}
@@ -80,7 +80,7 @@ const storyApi = {
updatedDate
}
}
`, {filter}).then(({stories}) => {
`, {filter}, options).then(({stories}) => {
return stories.map(d => new Story(d.id, d.name, d.author, d.category, d.listed, d.open, d.createdDate, d.updatedDate, d.fictionalDate, d.tags))
})
},
+86 -1
View File
@@ -1,3 +1,4 @@
const EventEmitter = require("events")
const fetch = require("isomorphic-fetch")
const config = require("../config")
const compressQuery = require("graphql-query-compress")
@@ -30,4 +31,88 @@ function query(query, variables = {}, options = {}) {
})
}
module.exports = { query }
class Subscription extends EventEmitter {
constructor(query, variables) {
super()
this.query = query
this.variables = Object.assign({}, variables)
this.websocket = null
this.verboseLogging = false
}
connect() {
const baseUrl = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host
let id = 1
this.websocket = new WebSocket(baseUrl + config.graphqlEndpoint, "graphql-ws")
this.websocket.onopen = () => {
this.websocket.send(JSON.stringify({type: "connection_init", payload: {}}));
this.websocket.send(JSON.stringify({id: (id++).toFixed(0), type: "start", payload: {
query: this.query,
variables: this.variables,
extensions: {},
}}));
console.log("WS Open")
}
this.websocket.onmessage = (ev) => {
let data = {}
try {
data = JSON.parse(ev.data)
} catch (err) {
console.warn("Error", err, ev)
return
}
if (this.verboseLogging) {
console.log("WS Data:", data)
}
switch (data.type) {
case "connection_ack": {
this.emit("connect")
break;
}
case "connection_error": {
console.warn("WS Connection Error", data.payload.message)
this.websocket.close()
this.emit("error", new Error(data.payload.message))
break;
}
case "data": {
if (data.payload.errors != null && data.payload.errors.length > 0) {
this.emit("error", data.payload.errors)
} else {
this.emit("data", data.payload.data)
}
break;
}
}
}
this.websocket.onerror = (err) => {
console.warn("WS Error", err)
}
this.websocket.onclose = () => {
this.emit("disconnect")
}
}
disconnect() {
if (this.websocket != null) {
this.websocket.close();
}
}
}
module.exports = { query, Subscription }
+56 -59
View File
@@ -5,12 +5,12 @@ require("es6-promise").polyfill()
// Load server config
const config = require("./config")
// Load utilities
const build = require("./builder")
// Express depedencies
const express = require("express")
const proxy = require("express-http-proxy")
const proxy2 = require("http-proxy-middleware")
const ip = require("ip")
const dns = require("dns")
const lasso = require("lasso")
const lassoMiddleware = require("lasso/middleware")
const markoExpress = require("marko/express")
@@ -24,39 +24,43 @@ const session = require("./middleware/session")
const app = express()
// Configure lasso
const isProduction = process.env.NODE_ENV === "production"
lasso.configure({
plugins: [
"lasso-marko",
"lasso-less",
],
outputDir: "./.static", // Place all generated JS/CSS/etc. files into the "static" dir
bundlingEnabled: isProduction, // Only enable bundling in production
minify: isProduction, // Only minify JS and CSS code in production
fingerprintsEnabled: isProduction, // Only add fingerprints to URLs in production
require: {
builtins: {
fs: require.resolve("empty-module"),
},
transforms: isProduction ? [
{
transform: "lasso-babel-transform",
config: {
extensions: [".js", ".es6"], // Enabled file extensions. Default: [".js", ".es6"]
babelOptions: {
presets: [ "es2015" ]
}
}
}
] : null
}
})
lasso.configure(require("./marko-config"))
// Set trusted proxy
if (config.trustedProxy != null) {
app.set('trust proxy', config.trustedProxy)
if (ip.isV4Format(config.trustedProxy) || ip.isV6Format(config.trustedProxy)) {
app.set('trust proxy', config.trustedProxy)
console.log("Trusting proxy", config.trustedProxy);
} else {
let trusted = null
setInterval(() => {
dns.resolve4(config.trustedProxy, (err, res) => {
if (err != null) {
if (trusted === null) {
console.error("Failed to resolve trustedProxy hostname")
}
return
}
if (res.length === 0) {
if (trusted === null) {
console.error("No results for trustedProxy hostname")
}
return
}
if (trusted !== res[0]) {
console.log("Trusting proxy", res[0], `(${config.trustedProxy})`);
app.set('trust proxy', res[0])
trusted = res[0]
}
})
}, 1000)
}
}
// Apply middleware
@@ -79,8 +83,18 @@ app.use("/auth", require("./routes/auth"))
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"))
} else {
app.get("/hax/global.css", (req, res) => {
res.type(".css").end("")
})
}
// Page routes
app.use("/story/", require("./routes/story"))
app.use("/story/unlisted/", require("./routes/story/unlisted"))
app.use("/story/by-category/", require("./routes/story/by-category"))
app.use("/story/by-tag/", require("./routes/story/by-tag"))
app.use("/story/by-month/", require("./routes/story/by-month"))
@@ -93,39 +107,22 @@ 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"))
// Entry point
app.get("/", function(req, res) {
res.redirect("/story/")
})
async function main() {
// Build templates
if (isProduction) {
await build("./marko/page/story/list.marko")
await build("./marko/page/story/tag-list.marko", {tags: []})
await build("./marko/page/logs/list.marko")
await build("./marko/page/data/channels.marko", {channels: [], user: {}})
await build("./marko/page/data/characters.marko", {characters: [], user: {}})
await build("./marko/page/story-content/view.marko", {story: {chapters: []}})
await build("./marko/page/logs-content/view.marko", {log: {posts: [], channel: {}}})
// Start server
app.listen(config.port, function() {
console.log("Server started: http://localhost:" + config.port + "/")
if (process.send) {
setTimeout(() => process.send("online"), 100)
}
// Start server
app.listen(config.port, function() {
console.log("Server started: http://localhost:" + config.port + "/")
if (process.send) {
setTimeout(() => process.send("online"), 100)
}
})
}
})
// Handle shutdown signals (Docker needs this to shutdown quickly)
process.on('SIGINT', () => process.exit(0))
process.on('SIGTERM', () => process.exit(0))
main().catch(err => {
console.error(err)
process.exit(1)
})
process.on('SIGTERM', () => process.exit(0))