diff options
| author | s-ol <s+removethis@s-ol.nu> | 2022-01-13 17:27:20 +0000 |
|---|---|---|
| committer | s-ol <s+removethis@s-ol.nu> | 2022-01-13 17:27:20 +0000 |
| commit | 0c98276d324db649c4a15be7859a501588257972 (patch) | |
| tree | 8e2ee4784730e16290b043ee4b38418fa5166106 /client/src | |
| parent | Menu, DiscDAG loading (diff) | |
| download | fedidag-0c98276d324db649c4a15be7859a501588257972.tar.gz fedidag-0c98276d324db649c4a15be7859a501588257972.zip | |
move everything into client/
Diffstat (limited to 'client/src')
| -rw-r--r-- | client/src/config.js | 3 | ||||
| -rw-r--r-- | client/src/graph.js | 205 | ||||
| -rw-r--r-- | client/src/index.js | 162 | ||||
| -rw-r--r-- | client/src/layout.js | 48 | ||||
| -rw-r--r-- | client/src/ui/Attachment.js | 26 | ||||
| -rw-r--r-- | client/src/ui/Discussion.js | 122 | ||||
| -rw-r--r-- | client/src/ui/Links.js | 50 | ||||
| -rw-r--r-- | client/src/ui/Menu.js | 139 | ||||
| -rw-r--r-- | client/src/ui/Note.js | 249 | ||||
| -rw-r--r-- | client/src/ui/Selection.js | 93 | ||||
| -rw-r--r-- | client/src/ui/css.js | 13 | ||||
| -rw-r--r-- | client/src/ui/index.js | 10 | ||||
| -rw-r--r-- | client/src/ui/theme.js | 39 | ||||
| -rw-r--r-- | client/src/viz.es.js | 194 |
14 files changed, 1353 insertions, 0 deletions
diff --git a/client/src/config.js b/client/src/config.js new file mode 100644 index 0000000..91d6231 --- /dev/null +++ b/client/src/config.js @@ -0,0 +1,3 @@ +export const PUBLIC_URL = process.env.PUBLIC_URL || `${location.origin}`; +export const API_PREFIX = process.env.API_PREFIX || `${location.origin}`; +export const CORS_PREFIX = process.env.CORS_PREFIX || `${API_PREFIX}/remote`; diff --git a/client/src/graph.js b/client/src/graph.js new file mode 100644 index 0000000..7fe842c --- /dev/null +++ b/client/src/graph.js @@ -0,0 +1,205 @@ +import { Component } from 'preact'; +import * as jsonld from 'jsonld'; +import { CORS_PREFIX } from './config'; + +const cors = (url) => `${CORS_PREFIX}/${url}`; + +function wrapCache(target) { + const orig = target.descriptor.value; + + target.descriptor.value = function (id, ...args) { + this.cache[id] ||= orig.call(this, id, ...args); + return this.cache[id]; + }; + + return target; +}; + +const context = [ + 'https://www.w3.org/ns/activitystreams', + { + replies: { '@id': 'as:replies', '@container': '@set' }, + inReplyTo: { '@id': 'as:inReplyTo', '@container': '@set' }, + items: { '@id': 'as:items', '@type': '@id', '@container': '@set' }, + }, +]; + +export class GraphContainer extends Component { + state = { + loading: true, + name: "loading…", + items: {}, + error: null, + }; + + cache = {}; + + componentDidMount() { + this.componentDidUpdate({}); + } + + componentDidUpdate(prevProps) { + if (prevProps.url === this.props.url) return; + + this.loadData(this.props.url) + .catch(error => { + console.error(`Error loading ${this.props.url}:`, error); + this.setState({ + loading: false, + error, + }); + }); + } + + @wrapCache + async loadUser(id) { + if (id === "https://dag.s-ol.nu/users/unknown") { + return { + '@context': context, + id, + type: 'Person', + name: 'unknown', + summary: 'unknown user', + }; + } + + try { + const user = await jsonld.compact(id, context); + user.id = id; + return user; + } catch (error) { + console.error(`Error loading user '${id}':`, error); + return { + '@context': context, + id, + type: 'Person', + name: id, + summary: 'failed to load user information', + }; + } + } + + render() { + window.state = this.state; + return this.props.render(this.state); + } +} + +export class GraphContainerMastodon extends GraphContainer { + @wrapCache + async loadCollection(id, ...args) { + const collection = await jsonld.compact(cors(id), context); + let items = []; + + const addItems = (i) => { + if (Array.isArray(i)) + items = items.concat(i); + else if (i) + items.push(i); + }; + + // discover items + if (collection.items) { + addItems(collection.items); + } else { + let page = collection.first; + const seen = []; + while (page && seen.indexOf(page.id) < 0) { + seen.push(page.id); + addItems(page.items); + + page = page.next && await jsonld.compact(cors(page.next), context); + } + } + + // dereference items + return await Promise.all(items.map(item => this.loadNote(item.id ?? item, ...args))); + } + + @wrapCache + async loadNote(id, items, inReplyTo) { + let item; + try { + item = await jsonld.frame( + cors(id), + { + '@context': context, + type: 'Note', + context: { '@embed': '@never' }, + replies: { '@embed': '@always' }, + inReplyTo: { '@embed': '@never' }, + }, + { omitGraph: true } + ); + + if (item.replies.length) + item.replies = await this.loadCollection(item.replies[0].id, items, [id]); + } catch (error) { + console.error(`Error loading note '${id}':`, error); + item = { + '@context': context, + id, + type: 'Tombstone', + formerType: 'Note', + attributedTo: 'https://dag.s-ol.nu/users/unknown', + published: '1970-01-01T00:00:00Z', + content: `Error loading note: ${error.toString()}`, + replies: [], + inReplyTo, + }; + } + + item.attributedTo = await this.loadUser(item.attributedTo); + items[item.id] = item; + return item; + } + + async loadData(url) { + const items = {}; + const root = await this.loadNote(url, items, []); + + this.setState({ + name: root.name || root.content, + items, + loading: false, + }); + } +} + +export class GraphContainerJSONLD extends GraphContainer { + async loadData(url) { + const response = await fetch(url, { + headers: { 'Accept': 'application/ld+json, application/json' }, + credentials: 'include', + }); + + const discussion = await jsonld.frame( + await response.json(), + { + '@context': context, + type: 'Document', + first: { '@embed': '@never' }, + items: { + context: { '@embed': '@never' }, + replies: { '@embed': '@never' }, + inReplyTo: { '@embed': '@never' }, + attributedTo: { '@embed': '@always' }, + }, + }, + { omitGraph: true } + ); + + const indexedItems = {}; + await Promise.all(discussion.items.map(async (item) => { + item.attributedTo = await this.loadUser(item.attributedTo); + indexedItems[item.id] = item; + })); + + discussion.items = indexedItems; + + this.setState({ + ...discussion, + loading: false, + }); + } +} diff --git a/client/src/index.js b/client/src/index.js new file mode 100644 index 0000000..a30dbf1 --- /dev/null +++ b/client/src/index.js @@ -0,0 +1,162 @@ +import 'preact/debug'; +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; +import { h, Fragment, Component, render } from 'preact'; +import { GraphContainerJSONLD, GraphContainerMastodon } from './graph'; +import { Menu, Discussion, Selection } from './ui'; + +class SelectionContainer extends Component { + state = { + selection: [], + }; + + toggle = (id) => { + const { selection } = this.state; + if (selection.indexOf(id) < 0) + this.setState({ selection: [ ...selection, id ] }); + else + this.setState({ selection: selection.filter(i => i !== id) }); + } + + render() { + return this.props.render({ + ...this.state, + toggleSelected: this.toggle, + }); + } +} + +class CollapseContainer extends Component { + /** + * Contains the collapse-state for each node. + * + * - `false`: visible + * - `true`: implicitly hidden + * - `'explicit'`: explicitly hidden + */ + state = {}; + + /** + * Toggle a node's collapse-state. + * - `false` → `'explicit'` + * - `true`, `'explicit'` → `false` + */ + toggle = (id) => { + const { items } = this.props; + + const state = Object.assign({}, this.state); + state[id] = state[id] ? false : 'explicit'; + + this.setState(this.update(items[id], state)); + } + + /** + * Recursively update child node's collapse-state. + * + * If a node has parents and they are all collapsed, + * mark that node as implicitly hidden (`true`). + * + * Then update all of that node's children. + */ + update(node, state) { + const { items } = this.props; + + if (state[node.id] !== 'explicit') { + state[node.id] = + node.inReplyTo.length + && node.inReplyTo.every(p => state[p.id]); + } + + for (const child of node.replies) + this.update(items[child.id], state); + + return state; + } + + render() { + return this.props.render({ + collapsed: this.state, + toggleCollapsed: this.toggle, + }); + } +} + +const graphRender = ({ loading, error, name, items }) => { + if (loading) { + return ( + <article> + <div>loading...</div> + </article> + ); + } + + if (error) { + return ( + <article> + <h1>error loading</h1> + <div> + <p>{error.toString()}</p> + <pre> + <code> + {error.stack} + </code> + </pre> + </div> + </article> + ); + } + + return ( + <SelectionContainer render={({ selection, toggleSelected }) => ( + <> + <CollapseContainer + items={items} + render={({ collapsed, toggleCollapsed }) => ( + <Discussion + name={name} + items={items} + collapsed={collapsed} + toggleCollapsed={toggleCollapsed} + toggleSelected={toggleSelected} + /> + )} + /> + <Selection + items={selection.map(id => items[id])} + toggleSelected={toggleSelected} + /> + </> + )} /> + ); +}; + +const search = new URLSearchParams(window.location.search); +const graph = search.has('graph') ? search.get('graph') : 'lib/graph.json'; + +let app; +if (search.has('document')) { + app = ( + <GraphContainerJSONLD + url={search.get('document')} + render={graphRender} + /> + ); +} else if (search.has('note')) { + app = ( + <GraphContainerMastodon + url={search.get('note')} + render={graphRender} + /> + ); +} else if (search.has('graph')) { + app = ( + <GraphContainerMastodon + url={search.get('graph')} + render={graphRender} + /> + ); +} else { + app = <Menu />; +} + +render(app, document.body); diff --git a/client/src/layout.js b/client/src/layout.js new file mode 100644 index 0000000..c9c2a4d --- /dev/null +++ b/client/src/layout.js @@ -0,0 +1,48 @@ +import Viz from './viz.es'; + +const toNumber = (i) => Number(i); +const gv = new Viz({ workerURL: 'lib/lite.render.js' }); + +export default class Layout { + constructor() { + this.src = 'digraph { node [shape="rectangle"];\n'; + } + + addNode(id, width, height) { + this.src += ` "${id}" [fixedsize=true; width=${width / 72}; height=${height / 72}];\n`; + } + + addLink(from, to) { + this.src += ` "${from}" -> "${to}";\n`; + } + + async render() { + this.src += '}'; + + const info = await gv.renderJSONObject(this.src, { format: 'json0' }); + info.objects ||= []; + info.edges ||= []; + const [width, height] = info.bb.split(',').map(toNumber).slice(2); + + const positions = {}; + for (const object of info.objects) { + const [x, y] = object.pos.split(',').map(toNumber); + positions[object.name] = [ x, height - y ]; + } + + const links = info.edges.map((edge) => { + const pos = edge.pos.slice(2); + return pos.split(' ').map(p => { + const [x, y] = p.split(','); + return [toNumber(x), height - toNumber(y)]; + }); + + }); + + return { + width, height, + positions, + links, + }; + } +} diff --git a/client/src/ui/Attachment.js b/client/src/ui/Attachment.js new file mode 100644 index 0000000..3c07ee5 --- /dev/null +++ b/client/src/ui/Attachment.js @@ -0,0 +1,26 @@ +import { h } from 'preact'; +import css from './css'; + +css` +.attachment { + margin: 1rem; + + height: 7rem; + object-fit: contain; +} + +.attachment + .attachment { + margin-top: 0; +} +`; + +export const Attachment = ({ type, mediaType, url, style }) => { + let content; + if (mediaType.startsWith('video/')) { + content = <video class="attachment" src={url} controls />; + } else if (mediaType.startsWith('image/')) { + content = <img class="attachment" src={url} />; + } + + return content; +} diff --git a/client/src/ui/Discussion.js b/client/src/ui/Discussion.js new file mode 100644 index 0000000..5fef693 --- /dev/null +++ b/client/src/ui/Discussion.js @@ -0,0 +1,122 @@ +import { h } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import Layout from '../layout'; +import css from './css'; +import { Links } from './Links'; +import { Note } from './Note'; + +css` +article { + position: absolute; + inset: 0; + + overflow: auto; +} + +article > h1 { + display: flex; + align-items: baseline; + + position: sticky; + inset: 0; + bottom: auto; + z-index: 200; + + margin: 0; + padding: 0.75rem 1.5rem; + background: var(--theme-title-bg); + color: var(--theme-title-fg); +} + +article > h1 > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +article > h1 .back { + font-size: 0.8em; + color: inherit; + opacity: 0.5; + + margin-left: 1em; +} + +article > div { + position: relative; + margin: 2rem 2rem 6rem; +} +`; + +const tmp = document.createElement('span'); + +export const Discussion = ({ + name, items, collapsed, + toggleCollapsed, toggleSelected, +}) => { + const [{ width, height, positions, links }, setState] = useState({ + width: 0, + height: 0, + positions: {}, + links: [], + }); + + const layout = new Layout(); + const setNodeSize = layout.addNode.bind(layout); + + useEffect(() => { + layout.render().then(setState); + }, [ items, collapsed ]); + + tmp.innerHTML = name; + name = tmp.innerText; + + return ( + <article> + <h1> + <span>{name}</span> + <a class="back" href="?">back</a> + </h1> + <div> + <Links width={width} height={height} links={links} /> + <div> + {Object.values(items).map((item) => { + const isCollapsed = collapsed[item.id]; + const hidden = + item.inReplyTo.every(p => collapsed[p.id]) + && item.replies.every(c => collapsed[c.id]) + && item.inReplyTo.length !== 0; + if (hidden) + return; + + const onlyParent = item.inReplyTo.length === 1 && items[item.inReplyTo[0].id]; + + for (const reply of item.replies) { + if (isCollapsed && collapsed[reply.id]) + continue; + + layout.addLink(item.id, reply.id); + } + + return ( + <Note + {...item} + smallHeader={ + onlyParent // only one parent post + && onlyParent.attributedTo === item.attributedTo // posted by same user + && onlyParent.published === item.published // in the same second + } + collapsed={isCollapsed} + position={positions[item.id]} + onCollapse={toggleCollapsed} + onSelect={toggleSelected} + onSize={setNodeSize} + /> + ); + })} + </div> + </div> + </article> + ); +}; + diff --git a/client/src/ui/Links.js b/client/src/ui/Links.js new file mode 100644 index 0000000..0a0b1ef --- /dev/null +++ b/client/src/ui/Links.js @@ -0,0 +1,50 @@ +import { h } from 'preact'; +import { useRef, useLayoutEffect } from 'preact/hooks'; + +export const Links = ({ links, ...props }) => { + const canvas = useRef(null); + + useLayoutEffect(() => { + const ctx = canvas.current.getContext('2d'); + ctx.lineWidth = 4; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = '#696969'; + ctx.fillStyle = '#696969'; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 4; + + ctx.clearRect(0, 0, canvas.current.width, canvas.current.height); + + for (const points of links) { + ctx.shadowColor = 'rgba(0,0,0, 0.7)'; + ctx.beginPath(); + ctx.moveTo(...points[1]); + for (let i = 4; i < points.length; i += 3) { + const [ax, ay] = points[i - 2]; + const [bx, by] = points[i - 1]; + const [x, y] = points[i]; + ctx.bezierCurveTo(ax, ay, bx, by, x, y); + } + ctx.stroke(); + + const [lx, ly] = points[points.length - 1]; + const [tx, ty] = points[0]; + const [dx, dy] = [tx - lx, ty - ly]; + const [hx, hy] = [dy*0.7, dx*-0.7]; + + ctx.shadowColor = 'transparent'; + ctx.beginPath(); + ctx.moveTo(lx + dx*0.8, ly + dy*0.8); + ctx.lineTo(lx - dx*0.5 + hx, ly - dy*0.5 + hy); + ctx.lineTo(lx - dx*0.5 - hx, ly - dy*0.5 - hy); + ctx.fill(); + } + }); + + return ( + <canvas {...props} ref={canvas} /> + ); +}; + diff --git a/client/src/ui/Menu.js b/client/src/ui/Menu.js new file mode 100644 index 0000000..6f32280 --- /dev/null +++ b/client/src/ui/Menu.js @@ -0,0 +1,139 @@ +import { h, Fragment } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import cn from 'classnames'; +import { API_PREFIX } from '../config'; +import css from './css'; + +const fetchJSON = async (url) => { + const res = await fetch(API_PREFIX + url, { credentials: 'include' }); + if (res.status !== 200) + throw new Error("wrong status"); + + return res.json(); +}; + +css` +a.discussion { + display: flex; + + padding: 0.25rem 0.5rem; + text-size: 2rem; + line-height: 2rem; + text-decoration: none; + + border-radius: 0.3rem; + + color: var(--theme-note-fg); + background: var(--theme-note-bg); +} + +a.discussion .access { + opacity: 0.5; + margin-right: 0.75rem; +} + +a.discussion .name { + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +a.discussion > a { + margin-left: 0.75rem; + text-decoration: underline; + color: inherit; + opacity: 0.5; + + transition: opacity 0.3s; +} +a.discussion > a:hover { + opacity: 1; +} +`; + +const DiscussionLink = ({ id, name, url, attributedTo, user }) => { + const writable = attributedTo && attributedTo.indexOf(user) > -1; + + return ( + <a class="discussion" href={`?document=${id}`}> + <span class="access">{writable ? 'W' : 'R'}</span> + <span class="name">{name}</span> + {url && ( + <a href={url.href}>ext</a> + )} + </a> + ); +}; + +css` +ul.discussions { + list-style: none; + padding: 0; + + width: 26rem; +} + +ul.discussions > li { + margin: 0.2rem 0; +} +`; + +const DiscussionList = ({ discussions, user }) => { + return ( + <ul class="discussions"> + {discussions.map((discussion) => ( + <li> + <DiscussionLink {...discussion} user={user} /> + </li> + ))} + </ul> + ); +}; + +css` +div.menu { + margin: 1rem 2rem; +} +`; +export const Menu = () => { + const [user, setUser] = useState(null); + const [discussions, setDiscussions] = useState([]); + + useEffect(() => { + fetchJSON('/discdag/list') + .then(({ user, discussions }) => { + setUser(user ?? null); + setDiscussions(discussions); + }); + }, []); + + if (user === null) { + return ( + <div class="menu"> + <h2>Discussions</h2> + loading... + </div> + ); + } + + return ( + <div class="menu"> + <h2>Discussions</h2> + {/*user && user.name !== 'Guest' + ? ( + <> + <span>logged in as {user.name}</span> + <button>log out</button> + </> + ) + : ( + <> + <span>not logged in</span> + <button>log in</button> + </> + )*/} + <DiscussionList discussions={discussions} user={user?.id} /> + </div> + ); +}; diff --git a/client/src/ui/Note.js b/client/src/ui/Note.js new file mode 100644 index 0000000..16b31cb --- /dev/null +++ b/client/src/ui/Note.js @@ -0,0 +1,249 @@ +import { h } from 'preact'; +import { useRef, useLayoutEffect } from 'preact/hooks'; +import ColorHash from 'color-hash'; +import { formatRelative } from 'date-fns'; +import cn from 'classnames'; +import css from './css'; +import { Attachment } from './Attachment'; + +const now = new Date(); +const Time = ({ time }) => { + const dt = new Date(time); + return formatRelative(dt, now); +}; + +css` +.flex-space { + flex: 1; + margin: 0; +} +`; +const Space = (props) => <div class="flex-space" {...props} />; + +css` +section > header { + display: flex; + flex-direction: row; + + height: 1.75rem; + + color: var(--theme-header-fg); + background: var(--theme-header-bg); +} + +section > header.small { + height: 0.5rem; +} + +section > header > * { + margin: 0 0.35rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.75rem; +} + +section > header > .avatar { + height: 1.25rem; + width: 1.25rem; + flex: 0 0 auto; + margin: 0.25rem; + border-radius: 0.2rem; + + font-size: 1.25rem; + font-weight: bold; + line-height: 1.25rem; + text-align: center; + text-decoration: none; + background: white; + color: black; +} +section > header > .avatar img { + width: inherit; + height: inherit; +} +section > header > .collapse { + height: 1rem; + width: 1rem; + flex: 0 0 auto; + margin: 0.3rem; + border-radius: 0.2rem; + border: 0.05rem solid var(--theme-header-fg); + + line-height: 0.8rem; + text-align: center; + text-decoration: none; + text-weight: bold; + + transition: background 0.3s; +} +section > header > .collapse:hover { + background: var(--theme-note-bg); +} + +section > header > .user { + flex: 0 0.5 auto; +} + +section > header > .time { + flex: 0 0 auto; + font-size: 0.8em; + color: #363636; +} +`; +const Header = ({ id, user, published, collapsed, onCollapse }) => { + const username = user.name || user.preferredUsername; + + const onClick = onCollapse && ((e) => { + e.preventDefault(); + onCollapse(id); + }); + + return ( + <header> + <a class="avatar" href={user.id}> + {user.icon + ? <img src={new URL(user.icon.url, user.id)} /> + : "?"} + </a> + <span class="user">{username}</span> + <Space /> + <a class="time" href={id}> + <Time time={published} /> + </a> + <a class="collapse" href="#" onClick={onClick}> + {collapsed ? '+' : '-'} + </a> + </header> + ); +}; + +css` +section { + display: flex; + flex-direction: column; + justify-content: space-between; + + width: 15rem; + + overflow: hidden; + color: var(--theme-note-fg); + background: var(--theme-note-bg); + box-shadow: rgba(0,0,0, 0.7) 2px 4px 5px; + + border-radius: 0.3rem; +} + +section.type-Tombstone { + color: var(--theme-note-bg); + background: var(--theme-note-fg); +} + +section > main { + position: relative; + padding: 0.5rem; + overflow: hidden; + font-family: serif; +} + +section > main > p:first-child { + margin-top: 0; +} +section > main > p:last-child { + margin-bottom: 0; +} + +section.tombstone > main { + font-family: inherit; +} + +section.hidden { + display: none; +} + +section.ellipsis > main { + max-height: 7em; +} +section.ellipsis > .attachment { + display: none; +} +section.ellipsis > main::after { + position: absolute; + display: block; + content: ''; + + pointer-events: none; + + left: 0; + right: 0; + bottom: 0; + height: 1.25em; + background: linear-gradient(0deg, var(--theme-note-bg) 5%, var(--theme-note-bg-trans) 100%); +} +section.type-Tombstone.ellipsis > main::after { + background: linear-gradient(0deg, var(--theme-note-fg) 5%, var(--theme-note-fg-trans) 100%); +} + +section.collapsed > main { + max-height: 2em; +} +`; + +const colors = new ColorHash({ lightness: 0.8 }); +export const Note = ({ + id, type, attributedTo, published, content, attachment, + smallHeader = false, collapsed = false, ellipsis = false, position, + onCollapse, onSelect, onSize, +}) => { + ellipsis ||= collapsed; + attachment ||= []; + if (!Array.isArray(attachment)) + attachment = [attachment]; + + const backgroundColor = attributedTo.color || colors.hex(attributedTo.id); + + const onClick = onSelect && ((e) => { + e.preventDefault(); + onSelect(id); + }); + + const root = useRef(null); + onSize && useLayoutEffect(() => { + onSize(id, root.current.offsetWidth, root.current.offsetHeight); + }); + + return ( + <section + style={{ + '--theme-header-bg': backgroundColor, + 'position': position && 'absolute', + 'left': position && `${position[0] - root.current.offsetWidth / 2}px`, + 'top': position && `${position[1] - root.current.offsetHeight / 2}px`, + 'visibility': onSize && !position ? 'hidden' : 'visible', + }} + class={cn( + `type-${type}`, + collapsed && 'collapsed', + ellipsis && 'ellipsis', + )} + data-id={id} + ref={root} + > + {smallHeader + ? <header className="small" onClick={onCollapse} /> + : <Header + id={id} + user={attributedTo} + published={published} + collapsed={collapsed} + onCollapse={onCollapse} + /> + } + <main + innerHTML={content} + onClick={onClick} + /> + {attachment.map(a => <Attachment {...a} />)} + </section> + ); +}; diff --git a/client/src/ui/Selection.js b/client/src/ui/Selection.js new file mode 100644 index 0000000..d8dfa88 --- /dev/null +++ b/client/src/ui/Selection.js @@ -0,0 +1,93 @@ +import { h } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import cn from 'classnames'; +import css from './css'; +import { Note } from './Note'; + +css` +.drawer { + position: fixed; + inset: 0; + top: auto; + + z-index: 200; + background: var(--theme-note-fg); + + /* wtf webkit */ + -webkit-backface-visibility: hidden; +} + +.drawer .handle { + height: 1.5rem; + margin-bottom: 1rem; +} + +.drawer .handle button { + display: block; + + height: 1rem; + line-height: 1rem; + margin: -1rem auto 0; + padding: 0.25rem; + background: var(--theme-note-bg); + border-radius: 0.2rem; +} + +.drawer .scroller { + display: flex; + overflow: auto; + width: 100%; + + transition: max-height 0.3s; +} + +.drawer .contents { + display: flex; + + align-items: flex-start; + + gap: 1rem; + padding: 0 1.5rem; +} + +.drawer .contents > * { + flex: 0 0 auto; +} +`; + +export const Drawer = ({ height, children }) => { + const [ hidden, setHidden ] = useState(true); + + return ( + <div class="drawer"> + <div class="handle"> + <button onClick={() => setHidden(!hidden)}> + ### + </button> + </div> + <div + class="scroller" + style={{ + height, + 'max-height': hidden ? '1rem' : height, + }} + > + <div class="contents"> + {children} + </div> + </div> + </div> + ); +}; + +export const Selection = ({ items, toggleSelected }) => ( + <Drawer height="8.55rem" > + {items.map((item) => ( + <Note + {...item} + ellipsis + onSelect={toggleSelected} + /> + ))} + </Drawer> +); diff --git a/client/src/ui/css.js b/client/src/ui/css.js new file mode 100644 index 0000000..9089f1e --- /dev/null +++ b/client/src/ui/css.js @@ -0,0 +1,13 @@ +export default ([ code ]) => { + const style = document.createElement('style'); + document.head.appendChild(style); + style.appendChild(document.createTextNode('')); + + let i = 0; + for (const v of code.split('}')) { + if (v.indexOf('{') < 0) continue; + style.sheet.insertRule(v + '}', i++); + } + + return style; +}; diff --git a/client/src/ui/index.js b/client/src/ui/index.js new file mode 100644 index 0000000..f9ebf7f --- /dev/null +++ b/client/src/ui/index.js @@ -0,0 +1,10 @@ +import './theme'; +import { Discussion } from './Discussion'; +import { Selection } from './Selection'; +import { Menu } from './Menu'; + +export { + Discussion, + Selection, + Menu, +}; diff --git a/client/src/ui/theme.js b/client/src/ui/theme.js new file mode 100644 index 0000000..8977791 --- /dev/null +++ b/client/src/ui/theme.js @@ -0,0 +1,39 @@ +import css from './css'; + +css` +html { + margin: 0; + padding: 0; +} + +body { + --theme-backdrop: #1e1e1e; + --theme-fg: #eeeeee; + --theme-note-bg: #eeeeee; + --theme-note-fg: #363636; + --theme-note-bg-trans: rgba(238,238,238,0); + --theme-note-fg-trans: rgba(54,54,54,0); + --theme-header-fg: #121212; + + --theme-title-fg: #eeeeee; + --theme-title-bg: #363636; +} + +body.darktheme { + --theme-backdrop: #1e1e1e; + --theme-note-bg: #363636; + --theme-note-fg: #eeeeee; + --theme-note-bg-trans: rgba(54,54,54,0); + --theme-note-fg-trans: rgba(238,238,238,0); + --theme-header-fg: #121212; +} + +body { + color: var(--theme-fg); + background: var(--theme-backdrop); + font-family: sans-serif; + + margin: 0; + padding: 0; +} +`; diff --git a/client/src/viz.es.js b/client/src/viz.es.js new file mode 100644 index 0000000..56f6a98 --- /dev/null +++ b/client/src/viz.es.js @@ -0,0 +1,194 @@ +/* +Viz.js 2.1.2 (Graphviz 2.40.1, Expat {{EXPAT_VERSION}}, Emscripten 1.37.36) +Copyright (c) 2014-2018 Michael Daines +Licensed under MIT license + +This distribution contains other software in object code form: + +Graphviz +Licensed under Eclipse Public License - v 1.0 +http://www.graphviz.org + +Expat +Copyright (c) 1998, 1999, 2000 Thai Open Source Software Center Ltd and Clark Cooper +Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006 Expat maintainers. +Licensed under MIT license +http://www.libexpat.org + +zlib +Copyright (C) 1995-2013 Jean-loup Gailly and Mark Adler +http://www.zlib.net/zlib_license.html +*/ +var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { + return typeof obj; +} : function (obj) { + return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; +}; + +var classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } +}; + +var createClass = function () { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + return function (Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; +}(); + +var _extends = Object.assign || function (target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + + for (var key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + + return target; +}; + +var WorkerWrapper = function () { + function WorkerWrapper(worker) { + var _this = this; + + classCallCheck(this, WorkerWrapper); + + this.worker = worker; + this.listeners = []; + this.nextId = 0; + + this.worker.addEventListener('message', function (event) { + var id = event.data.id; + var error = event.data.error; + var result = event.data.result; + + _this.listeners[id](error, result); + delete _this.listeners[id]; + }); + } + + createClass(WorkerWrapper, [{ + key: 'render', + value: function render(src, options) { + var _this2 = this; + + return new Promise(function (resolve, reject) { + var id = _this2.nextId++; + + _this2.listeners[id] = function (error, result) { + if (error) { + reject(new Error(error.message, error.fileName, error.lineNumber)); + return; + } + resolve(result); + }; + + _this2.worker.postMessage({ id: id, src: src, options: options }); + }); + } + }]); + return WorkerWrapper; +}(); + +var ModuleWrapper = function ModuleWrapper(module, render) { + classCallCheck(this, ModuleWrapper); + + var instance = module(); + this.render = function (src, options) { + return new Promise(function (resolve, reject) { + try { + resolve(render(instance, src, options)); + } catch (error) { + reject(error); + } + }); + }; +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding + +var Viz = function () { + function Viz() { + var _ref3 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + workerURL = _ref3.workerURL, + worker = _ref3.worker, + Module = _ref3.Module, + render = _ref3.render; + + classCallCheck(this, Viz); + + if (typeof workerURL !== 'undefined') { + this.wrapper = new WorkerWrapper(new Worker(workerURL)); + } else if (typeof worker !== 'undefined') { + this.wrapper = new WorkerWrapper(worker); + } else if (typeof Module !== 'undefined' && typeof render !== 'undefined') { + this.wrapper = new ModuleWrapper(Module, render); + } else if (typeof Viz.Module !== 'undefined' && typeof Viz.render !== 'undefined') { + this.wrapper = new ModuleWrapper(Viz.Module, Viz.render); + } else { + throw new Error('Must specify workerURL or worker option, Module and render options, or include one of full.render.js or lite.render.js after viz.js.'); + } + } + + createClass(Viz, [{ + key: 'renderString', + value: function renderString(src) { + var _ref4 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + _ref4$format = _ref4.format, + format = _ref4$format === undefined ? 'svg' : _ref4$format, + _ref4$engine = _ref4.engine, + engine = _ref4$engine === undefined ? 'dot' : _ref4$engine, + _ref4$files = _ref4.files, + files = _ref4$files === undefined ? [] : _ref4$files, + _ref4$images = _ref4.images, + images = _ref4$images === undefined ? [] : _ref4$images, + _ref4$yInvert = _ref4.yInvert, + yInvert = _ref4$yInvert === undefined ? false : _ref4$yInvert, + _ref4$nop = _ref4.nop, + nop = _ref4$nop === undefined ? 0 : _ref4$nop; + + for (var i = 0; i < images.length; i++) { + files.push({ + path: images[i].path, + data: '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg width="' + images[i].width + '" height="' + images[i].height + '"></svg>' + }); + } + + return this.wrapper.render(src, { format: format, engine: engine, files: files, images: images, yInvert: yInvert, nop: nop }); + } + }, { + key: 'renderJSONObject', + value: function renderJSONObject(src) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + var format = options.format; + + + if (format !== 'json' || format !== 'json0') { + format = 'json'; + } + + return this.renderString(src, _extends({}, options, { format: format })).then(function (str) { + return JSON.parse(str); + }); + } + }]); + return Viz; +}(); + +export default Viz; |
