forked from cory/tildefriends
260 lines
6.9 KiB
JavaScript
260 lines
6.9 KiB
JavaScript
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
|
import * as tfrpc from '/static/tfrpc.js';
|
|
import * as tfutils from './tf-utils.js';
|
|
|
|
const k_project = '%Hr+4xEVtjplidSKBlRWi4Aw/0Tfw7B+1OR9BzlDKmOI=.sha256';
|
|
|
|
class TfIdPickerElement extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
ids: {type: Array},
|
|
selected: {type: String},
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.load();
|
|
}
|
|
|
|
async load() {
|
|
this.selected = await tfrpc.rpc.localStorageGet('whoami');
|
|
this.ids = (await tfrpc.rpc.getIdentities()) || [];
|
|
}
|
|
|
|
changed(event) {
|
|
this.selected = event.srcElement.value;
|
|
tfrpc.rpc.localStorageSet('whoami', this.selected);
|
|
}
|
|
|
|
render() {
|
|
if (this.ids) {
|
|
return html`
|
|
<select @change=${this.changed} style="max-width: 100%">
|
|
${(this.ids).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)}
|
|
</select>
|
|
`;
|
|
} else {
|
|
return html`<div>Loading...</div>`;
|
|
}
|
|
}
|
|
}
|
|
customElements.define('tf-id-picker', TfIdPickerElement);
|
|
|
|
class TfComposeElement extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
value: {type: String},
|
|
};
|
|
}
|
|
|
|
input() {
|
|
let input = this.renderRoot.getElementById('input');
|
|
let preview = this.renderRoot.getElementById('preview');
|
|
if (input && preview) {
|
|
preview.innerHTML = tfutils.markdown(input.value);
|
|
}
|
|
}
|
|
|
|
submit() {
|
|
this.dispatchEvent(new CustomEvent('tf-submit', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: {
|
|
value: this.renderRoot.getElementById('input').value,
|
|
},
|
|
}));
|
|
this.renderRoot.getElementById('input').value = '';
|
|
this.input();
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<div style="display: flex; flex-direction: row">
|
|
<textarea id="input" @input=${this.input} style="flex: 1 1">${this.value}</textarea>
|
|
<div id="preview" style="flex: 1 1"></div>
|
|
</div>
|
|
<input type="submit" value="Submit" @click=${this.submit}></input>
|
|
`;
|
|
}
|
|
}
|
|
customElements.define('tf-compose', TfComposeElement);
|
|
|
|
class TfIssuesAppElement extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
issues: {type: Array},
|
|
selected: {type: Object},
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.issues = [];
|
|
this.load();
|
|
}
|
|
|
|
async load() {
|
|
let issues = {};
|
|
let messages = await tfrpc.rpc.query(`
|
|
WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON
|
|
messages.id = messages_refs.message
|
|
WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'),
|
|
edits AS (SELECT messages.* FROM issues JOIN messages_refs ON
|
|
issues.id = messages_refs.ref JOIN messages ON
|
|
messages.id = messages_refs.message
|
|
WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post'))
|
|
SELECT * FROM issues
|
|
UNION
|
|
SELECT * FROM edits ORDER BY timestamp
|
|
`, [k_project]);
|
|
for (let message of messages) {
|
|
let content = JSON.parse(message.content);
|
|
switch (content.type) {
|
|
case 'issue':
|
|
issues[message.id] = {
|
|
id: message.id,
|
|
author: message.author,
|
|
text: content.text,
|
|
updates: [],
|
|
created: message.timestamp,
|
|
open: true,
|
|
};
|
|
break;
|
|
case 'issue-edit':
|
|
case 'post':
|
|
for (let issue of (content.issues || [])) {
|
|
if (issues[issue.link]) {
|
|
if (issue.open !== undefined) {
|
|
issues[issue.link].open = issue.open;
|
|
message.open = issue.open;
|
|
}
|
|
issues[issue.link].updates.push(message);
|
|
issues[issue.link].updated = message.timestamp;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
this.issues = Object.values(issues).sort((x, y) => (y.open - x.open) || (y.created - x.created));
|
|
if (this.selected) {
|
|
for (let issue of this.issues) {
|
|
if (issue.id == this.selected.id) {
|
|
this.selected = issue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
render_issue_table_row(issue) {
|
|
return html`
|
|
<tr>
|
|
<td>${issue.open ? '☐ open' : '☑ closed'}</td>
|
|
<td style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis">${issue.author}</td>
|
|
<td style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" @click=${() => this.selected = issue}>
|
|
${issue.text.split('\n')?.[0]}
|
|
</td>
|
|
<td>${new Date(issue.updated ?? issue.created).toLocaleDateString()}</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
render_update(update) {
|
|
let content = JSON.parse(update.content);
|
|
let message;
|
|
if (content.text) {
|
|
message = unsafeHTML(tfutils.markdown(content.text));
|
|
}
|
|
return html`
|
|
<div style="border-left: 2px solid #fff; padding-left: 8px">
|
|
<div>${new Date(update.timestamp).toLocaleString()}</div>
|
|
<div>${update.author}</div>
|
|
<div>${message}</div>
|
|
<div>${update.open !== undefined ? (update.open ? 'issue opened' : 'issue closed') : undefined}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async set_open(id, open) {
|
|
if (confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)) {
|
|
let whoami = this.shadowRoot.getElementById('picker').selected;
|
|
await tfrpc.rpc.appendMessage(whoami, {
|
|
type: 'issue-edit',
|
|
issues: [
|
|
{
|
|
link: id,
|
|
open: open,
|
|
},
|
|
],
|
|
});
|
|
await this.load();
|
|
}
|
|
}
|
|
|
|
async create_issue(event) {
|
|
let whoami = this.shadowRoot.getElementById('picker').selected;
|
|
await tfrpc.rpc.appendMessage(whoami, {
|
|
type: 'issue',
|
|
project: k_project,
|
|
text: event.detail.value,
|
|
});
|
|
await this.load();
|
|
}
|
|
|
|
async reply_to_issue(event) {
|
|
let whoami = this.shadowRoot.getElementById('picker').selected;
|
|
await tfrpc.rpc.appendMessage(whoami, {
|
|
type: 'post',
|
|
text: event.detail.value,
|
|
root: this.selected.id,
|
|
branch: this.selected.updates.length ? this.selected.updates[this.selected.updates.length - 1].id : this.selected.id,
|
|
issues: [
|
|
{
|
|
link: this.selected.id,
|
|
},
|
|
],
|
|
});
|
|
await this.load();
|
|
}
|
|
|
|
render() {
|
|
let header = html`
|
|
<h1>Tilde Friends Issues</h1>
|
|
<tf-id-picker id="picker"></tf-id-picker>
|
|
`;
|
|
if (this.selected) {
|
|
return html`
|
|
${header}
|
|
<div>
|
|
<input type="button" value="Back" @click=${() => this.selected = undefined}></input>
|
|
${this.selected.open ?
|
|
html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` :
|
|
html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`}
|
|
</div>
|
|
<div>${new Date(this.selected.created).toLocaleString()}</div>
|
|
<div>${this.selected.author}</div>
|
|
<div>${this.selected.id}</div>
|
|
<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div>
|
|
${this.selected.updates.map(x => this.render_update(x))}
|
|
<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose>
|
|
`;
|
|
} else {
|
|
return html`
|
|
${header}
|
|
<h2>New Issue</h2>
|
|
<tf-compose @tf-submit=${this.create_issue}></tf-compose>
|
|
<table>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Author</th>
|
|
<th>Title</th>
|
|
<th>Date</th>
|
|
</tr>
|
|
${this.issues.map(x => this.render_issue_table_row(x))}
|
|
</table>
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('tf-issues-app', TfIssuesAppElement); |