249 lines
		
	
	
		
			6.3 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			249 lines
		
	
	
		
			6.3 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 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.id, json(messages.content) AS content, messages.author, messages.timestamp 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.id, json(messages.content) AS content, messages.author, messages.timestamp 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 = await tfrpc.rpc.getActiveIdentity();
 | 
						|
			await tfrpc.rpc.appendMessage(whoami, {
 | 
						|
				type: 'issue-edit',
 | 
						|
				issues: [
 | 
						|
					{
 | 
						|
						link: id,
 | 
						|
						open: open,
 | 
						|
					},
 | 
						|
				],
 | 
						|
			});
 | 
						|
			await this.load();
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	async create_issue(event) {
 | 
						|
		let whoami = await tfrpc.rpc.getActiveIdentity();
 | 
						|
		await tfrpc.rpc.appendMessage(whoami, {
 | 
						|
			type: 'issue',
 | 
						|
			project: k_project,
 | 
						|
			text: event.detail.value,
 | 
						|
		});
 | 
						|
		await this.load();
 | 
						|
	}
 | 
						|
 | 
						|
	async reply_to_issue(event) {
 | 
						|
		let whoami = await tfrpc.rpc.getActiveIdentity();
 | 
						|
		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> `;
 | 
						|
		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);
 |