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/ui | |
| parent | Menu, DiscDAG loading (diff) | |
| download | fedidag-0c98276d324db649c4a15be7859a501588257972.tar.gz fedidag-0c98276d324db649c4a15be7859a501588257972.zip | |
move everything into client/
Diffstat (limited to 'client/src/ui')
| -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 |
9 files changed, 741 insertions, 0 deletions
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; +} +`; |
